@eddyskywalker/dsh-chatgpt-subscription 0.60.0 → 0.61.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/CHANGELOG.md +26 -0
- package/lib/client.js.map +1 -1
- package/lib/codemode.js +319 -89
- package/lib/entry-plan-mode-CLRKkgYG.js +1787 -0
- package/lib/{entry-progress-updates-CxQewio9.js → entry-progress-updates-DZ0ZRus4.js} +14 -1
- package/lib/index.js +905 -574
- package/lib/plan-mode.js +2 -779
- package/lib/progress-updates.js +1 -1
- package/lib/types/compat.d.ts +1 -1
- package/lib/types/entry-codemode.d.ts.map +1 -1
- package/lib/types/entry-plan-mode.d.ts +136 -0
- package/lib/types/entry-plan-mode.d.ts.map +1 -1
- package/lib/types/host/agent-preset.d.ts.map +1 -1
- package/lib/types/host/claude/adapter.d.ts +16 -0
- package/lib/types/host/claude/adapter.d.ts.map +1 -1
- package/lib/types/host/claude/client.d.ts +7 -1
- package/lib/types/host/claude/client.d.ts.map +1 -1
- package/lib/types/host/claude/mapper.d.ts +9 -0
- package/lib/types/host/claude/mapper.d.ts.map +1 -1
- package/lib/types/host/claude/types.d.ts +10 -0
- package/lib/types/host/claude/types.d.ts.map +1 -1
- package/lib/types/host/cline-pass/mapper.d.ts.map +1 -1
- package/lib/types/host/codemode/discovery.d.ts.map +1 -1
- package/lib/types/host/codemode/rules.d.ts.map +1 -1
- package/lib/types/host/codemode/transport.d.ts +38 -0
- package/lib/types/host/codemode/transport.d.ts.map +1 -1
- package/lib/types/host/command-code/mapper.d.ts.map +1 -1
- package/lib/types/host/common/orphan-tool-calls.d.ts +48 -0
- package/lib/types/host/common/orphan-tool-calls.d.ts.map +1 -0
- package/lib/types/host/dispatch-execution-guard.d.ts +32 -2
- package/lib/types/host/dispatch-execution-guard.d.ts.map +1 -1
- package/lib/types/host/glm-coding-plan/mapper-anthropic.d.ts.map +1 -1
- package/lib/types/host/glm-coding-plan/mapper-openai.d.ts.map +1 -1
- package/lib/types/host/kimi-code/mapper.d.ts.map +1 -1
- package/lib/types/host/minimax-code/mapper.d.ts.map +1 -1
- package/lib/types/host/model-catalog.d.ts +21 -0
- package/lib/types/host/model-catalog.d.ts.map +1 -1
- package/lib/types/host/opendesign/mapper.d.ts.map +1 -1
- package/lib/types/host/plan-mode-cache-guard.d.ts.map +1 -1
- package/lib/types/host/progress-updates/nudge.d.ts +13 -1
- package/lib/types/host/progress-updates/nudge.d.ts.map +1 -1
- package/lib/types/host/qoder/mapper.d.ts.map +1 -1
- package/lib/types/host/responses-client.d.ts.map +1 -1
- package/lib/types/host/responses-mapper.d.ts +12 -0
- package/lib/types/host/responses-mapper.d.ts.map +1 -1
- package/lib/types/host/settled-job-guard.d.ts +77 -0
- package/lib/types/host/settled-job-guard.d.ts.map +1 -0
- package/lib/types/host/step-code/mapper.d.ts.map +1 -1
- package/lib/types/host/workbuddy/mapper.d.ts.map +1 -1
- package/lib/types/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/presets/dispatch/README.md +7 -10
- package/presets/dispatch/agent.cordis.yml +4 -11
|
@@ -0,0 +1,1787 @@
|
|
|
1
|
+
import { createUserMessage } from "@deepseek-ai/dsh-llm";
|
|
2
|
+
import { defineTool } from "@deepseek-ai/dsh-tools";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { Service } from "@deepseek-ai/cordis";
|
|
5
|
+
//#region src/host/dispatch-execution-guard.ts
|
|
6
|
+
/**
|
|
7
|
+
* 「调度执行守卫」——在**执行期**拦住两类越界调用,并把主控自己提问时的畸形
|
|
8
|
+
* 载荷挡在弹窗之前。
|
|
9
|
+
*
|
|
10
|
+
* ## 为什么需要它
|
|
11
|
+
*
|
|
12
|
+
* 调度模式(dispatch preset)里主控与子代理跑在同一进程、同一套工具注册面上,
|
|
13
|
+
* 于是有三件事会悄悄坏掉:
|
|
14
|
+
*
|
|
15
|
+
* 1. **子代理越权调用主控专用工具**。`ask_user_question`、`exit_plan_mode`、
|
|
16
|
+
* `create_goal` 这些是**主控的**决定口:让子代理自己问用户、自己进/出计划模式、
|
|
17
|
+
* 自己建目标,都会把「一个决定」拆成两个决定——子代理问完,用户答了,主控却
|
|
18
|
+
* 不知道问过什么。子代理本来就该把未决问题**带回**主控,而不是自己去问。
|
|
19
|
+
* 2. **等待子代理期间空等**。子代理还在跑的时候,主控用 `sleep` 空转,既烧 token
|
|
20
|
+
* 又错过通知到达的那一刻。正确做法是**结束这一轮**,让内置的子代理完成通知把
|
|
21
|
+
* 它叫醒(R-W)。
|
|
22
|
+
* 3. **主控自己的提问载荷畸形**。模型会把一次「给候选方案」写成同一道题问三遍、
|
|
23
|
+
* 把选项整段 JSON 糊进键名(`"options [{"`)、把 `id` 写成 `":"` 或空串——
|
|
24
|
+
* 用户在弹窗里点完,回答却对不回任何问题。这种载荷必须在**弹窗之前**就退回给
|
|
25
|
+
* 模型,让它下一轮自己改对(见 {@link validateAskUserQuestionArgs})。
|
|
26
|
+
*
|
|
27
|
+
* ## 零 Kimi Code 缓存漂移
|
|
28
|
+
*
|
|
29
|
+
* 守卫**只**用 `ctx.tools.guard(exec => …)` 这一条执行期接缝:
|
|
30
|
+
*
|
|
31
|
+
* - 不包 `tools.definitions`,不包 `tools.register`,不听
|
|
32
|
+
* `system-prompt/assemble`——工具声明面(`tools:sdk` 段与顶层工具 schema)
|
|
33
|
+
* 保持逐字节恒定,Kimi Code 的前缀缓存不会因为这个守卫重建一次。
|
|
34
|
+
* - 拦截发生在**调用执行时**,模型看到的工具表完全没变,所以「看得见」与
|
|
35
|
+
* 「叫得动」仍然被拆开:工具照常出现在表里,只是越界的调用被拒。
|
|
36
|
+
*
|
|
37
|
+
* ## 判定从哪来
|
|
38
|
+
*
|
|
39
|
+
* 两条判定都走**结构探测**,不 import 任何 harness 代次专属的类型:
|
|
40
|
+
*
|
|
41
|
+
* - 「你是不是子代理」:先看**持久会话血缘**(`session.header.origin ===
|
|
42
|
+
* 'subagent'`),再看宿主活着的**存活根**(`agents.roots()`)。两条都是
|
|
43
|
+
* 读得着的既有事实,不新增任何状态。
|
|
44
|
+
* - 「你还有没有在跑的子代理」:依次问 `subagents.list` 与 `agents.list`,
|
|
45
|
+
* 看有没有 `status === 'running'` 的直接子代。
|
|
46
|
+
*
|
|
47
|
+
* @module @eddyskywalker/dsh-chatgpt-subscription/host/dispatch-execution-guard
|
|
48
|
+
*/
|
|
49
|
+
/**
|
|
50
|
+
* 子代理**不得**直接调用的主控专用工具。
|
|
51
|
+
*
|
|
52
|
+
* 这七个都是「决定口」或「交付口」,不是「干活口」:
|
|
53
|
+
*
|
|
54
|
+
* - `ask_user_question` / `exit_plan_mode` / `enter_plan_mode` /
|
|
55
|
+
* `leave_plan_mode`:计划与提问的决定权在主控;
|
|
56
|
+
* - `create_goal` / `update_goal`:目标由主控持有;
|
|
57
|
+
* - `present`:交付口由主控掌握。
|
|
58
|
+
*
|
|
59
|
+
* 子代理遇到需要决定的地方,正确动作是**把问题带回主控**,让主控去调这些工具。
|
|
60
|
+
*/
|
|
61
|
+
const DEFAULT_DELEGATED_BLOCKED_TOOLS = Object.freeze([
|
|
62
|
+
"ask_user_question",
|
|
63
|
+
"exit_plan_mode",
|
|
64
|
+
"enter_plan_mode",
|
|
65
|
+
"leave_plan_mode",
|
|
66
|
+
"create_goal",
|
|
67
|
+
"update_goal",
|
|
68
|
+
"present"
|
|
69
|
+
]);
|
|
70
|
+
/**
|
|
71
|
+
* 承载「等待」语义的 shell 工具。
|
|
72
|
+
*
|
|
73
|
+
* 这三个是本包与宿主都会用到的 shell 入口:它们的 `command` 参数里可能藏着
|
|
74
|
+
* `sleep`。空等只在**确实有在跑的子代理**时才是问题(有子代理时应当结束这一
|
|
75
|
+
* 轮等通知),没有子代理时 `sleep 1` 只是普通命令,不该拦。
|
|
76
|
+
*/
|
|
77
|
+
const DEFAULT_SHELL_TOOLS = Object.freeze([
|
|
78
|
+
"bash",
|
|
79
|
+
"pwsh",
|
|
80
|
+
"win_bash"
|
|
81
|
+
]);
|
|
82
|
+
/** 一次执行里被判定为「纯等待」的命令形态。 */
|
|
83
|
+
const SLEEP_COMMAND_PATTERN = /^(?:sleep\s+\d+(?:\.\d+)?[smhd]?|Start-Sleep\s+(?:-(?:Seconds|Milliseconds|s|m)\s+)?\d+(?:\.\d+)?|timeout(?:\.exe)?\s+(?:\/t\s+)?\d+(?:\s+\/nobreak)?|ping(?:\.exe)?\s+-(?:n|c)\s+\d+\s+(?:127\.0\.0\.1|localhost)(?:\s+>|\s*$).*)$/i;
|
|
84
|
+
/** 无害的空转语句(回显、真、冒号)。 */
|
|
85
|
+
const NOOP_COMMAND_PATTERN = /^(?:echo\b.*|Write-Host\b.*|Write-Output\b.*|true|:)$/;
|
|
86
|
+
/** 注释行。 */
|
|
87
|
+
const COMMENT_COMMAND_PATTERN = /^#/;
|
|
88
|
+
/**
|
|
89
|
+
* 读宿主上下文上「直接注入」的 running-child 判定回调。
|
|
90
|
+
*
|
|
91
|
+
* 这一步必须**try/catch**:真实 Cordis 的上下文代理对任何没有 inject 的属性直读
|
|
92
|
+
* 都当场抛 `cannot get property "..." without inject`,一次读失败不该让守卫在
|
|
93
|
+
* 每次工具调用上抛错。
|
|
94
|
+
*
|
|
95
|
+
* @param ctx - 宿主上下文。
|
|
96
|
+
* @returns 注入的回调;没有时为 undefined。
|
|
97
|
+
*/
|
|
98
|
+
function injectedHasRunningChildren(ctx) {
|
|
99
|
+
if (!isRecord(ctx)) return void 0;
|
|
100
|
+
let injected;
|
|
101
|
+
try {
|
|
102
|
+
injected = ctx["hasRunningChildren"];
|
|
103
|
+
} catch {
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
return typeof injected === "function" ? injected : void 0;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* 判断一个值是不是可读的记录对象。
|
|
110
|
+
*
|
|
111
|
+
* @param value - 候选值。
|
|
112
|
+
* @returns 是记录对象时为 true。
|
|
113
|
+
*/
|
|
114
|
+
function isRecord(value) {
|
|
115
|
+
return typeof value === "object" && value !== null;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* 从宿主上下文里读一个**可选**服务。
|
|
119
|
+
*
|
|
120
|
+
* 一律走 `ctx.get()`:没写进 `inject` 的服务名不能当属性读,真实 Cordis 会当场
|
|
121
|
+
* 抛 `cannot get property "..." without inject`,整个插件降级。读不到就是读不到,
|
|
122
|
+
* 守卫据此放行,绝不因此让插件挂不上。
|
|
123
|
+
*
|
|
124
|
+
* @param ctx - 宿主上下文。
|
|
125
|
+
* @param name - 服务名。
|
|
126
|
+
* @returns 服务实例;不存在或读取抛错时为 undefined。
|
|
127
|
+
*/
|
|
128
|
+
function readService(ctx, name) {
|
|
129
|
+
const reader = ctx?.get;
|
|
130
|
+
if (typeof reader !== "function") return void 0;
|
|
131
|
+
try {
|
|
132
|
+
return reader.call(ctx, name);
|
|
133
|
+
} catch {
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* 读一个宿主上下文里的工具注册面。
|
|
139
|
+
*
|
|
140
|
+
* @param ctx - 宿主上下文。
|
|
141
|
+
* @returns 带 `guard` 接缝的注册面;没有时为 undefined。
|
|
142
|
+
*/
|
|
143
|
+
function toolsOf(ctx) {
|
|
144
|
+
if (!isRecord(ctx)) return void 0;
|
|
145
|
+
let tools;
|
|
146
|
+
try {
|
|
147
|
+
tools = ctx["tools"];
|
|
148
|
+
} catch {
|
|
149
|
+
tools = void 0;
|
|
150
|
+
}
|
|
151
|
+
if (!isRecord(tools)) tools = readService(ctx, "tools");
|
|
152
|
+
return isRecord(tools) ? tools : void 0;
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* 读一条记录的 `id`。
|
|
156
|
+
*
|
|
157
|
+
* @param value - 候选记录。
|
|
158
|
+
* @returns id 字符串;没有时为 undefined。
|
|
159
|
+
*/
|
|
160
|
+
function idOf(value) {
|
|
161
|
+
if (!isRecord(value)) return void 0;
|
|
162
|
+
const id = value["id"];
|
|
163
|
+
return typeof id === "string" ? id : void 0;
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* 读一条会话记录头里的 `id`。
|
|
167
|
+
*
|
|
168
|
+
* @param agent - 候选 agent。
|
|
169
|
+
* @returns 会话 id;没有时为 undefined。
|
|
170
|
+
*/
|
|
171
|
+
function sessionIdOf(agent) {
|
|
172
|
+
if (!isRecord(agent)) return void 0;
|
|
173
|
+
const session = agent["session"];
|
|
174
|
+
if (!isRecord(session)) return void 0;
|
|
175
|
+
const header = session["header"];
|
|
176
|
+
return idOf(header);
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* 这个调用方是不是**子代理**。
|
|
180
|
+
*
|
|
181
|
+
* 判定分两级,先看能独立成立的持久事实,再看宿主当前活着的存活根:
|
|
182
|
+
*
|
|
183
|
+
* 1. **持久会话血缘**:`session.header.origin === 'subagent'`。这是子代理会话
|
|
184
|
+
* 创建时就写下的血缘,跨宿主代次都在,不依赖任何服务是否挂载。
|
|
185
|
+
* 2. **存活根判定**:宿主提供 `agents.roots()` 时,一个**不在**存活根集合里的
|
|
186
|
+
* agent 就是别人的孩子。先核对**存活**(`agents.get(id) === agent`):不是
|
|
187
|
+
* 那个 id 当前登记的存活 agent,说明这个调用方已经过期或已被顶替——判它是不是
|
|
188
|
+
* 子代理没有意义,放行。
|
|
189
|
+
*
|
|
190
|
+
* 宿主没有 `agents` 服务(或老代没有 `roots`)时,判不了就**放行**:把一个
|
|
191
|
+
* 读不出来的身份当成子代理去拦,会拦掉主控自己的正常调用。
|
|
192
|
+
*
|
|
193
|
+
* @param agent - 调用方 agent。
|
|
194
|
+
* @param ctx - 宿主上下文(可选;用于读 `agents` 服务)。
|
|
195
|
+
* @returns 是子代理时为 true。
|
|
196
|
+
*/
|
|
197
|
+
function isDelegatedCaller(agent, ctx) {
|
|
198
|
+
if (!isRecord(agent)) return false;
|
|
199
|
+
const session = agent["session"];
|
|
200
|
+
if (isRecord(session)) {
|
|
201
|
+
const header = session["header"];
|
|
202
|
+
if (isRecord(header) && header["origin"] === "subagent") return true;
|
|
203
|
+
}
|
|
204
|
+
const agents = readService(ctx, "agents");
|
|
205
|
+
if (!isRecord(agents)) return false;
|
|
206
|
+
const roots = rootsOf(agents);
|
|
207
|
+
if (roots === void 0) return false;
|
|
208
|
+
const id = idOf(agent);
|
|
209
|
+
if (typeof id === "string" && typeof agents["get"] === "function") {
|
|
210
|
+
let live;
|
|
211
|
+
try {
|
|
212
|
+
live = agents["get"].call(agents, id);
|
|
213
|
+
} catch {
|
|
214
|
+
live = void 0;
|
|
215
|
+
}
|
|
216
|
+
if (live !== agent) return false;
|
|
217
|
+
}
|
|
218
|
+
return !roots.includes(agent);
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* 读宿主存活根集合。
|
|
222
|
+
*
|
|
223
|
+
* @param agents - `agents` 服务。
|
|
224
|
+
* @returns 存活根数组;服务没有 `roots` 或读取抛错时为 undefined。
|
|
225
|
+
*/
|
|
226
|
+
function rootsOf(agents) {
|
|
227
|
+
const roots = agents["roots"];
|
|
228
|
+
if (typeof roots !== "function") return void 0;
|
|
229
|
+
try {
|
|
230
|
+
const listed = roots.call(agents);
|
|
231
|
+
return Array.isArray(listed) ? listed : void 0;
|
|
232
|
+
} catch {
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* 判断一条命令是不是**纯等待**。
|
|
238
|
+
*
|
|
239
|
+
* 判据:整条命令**按分隔符切开后**,至少有一条是 sleep/wait,而**每一条**都是
|
|
240
|
+
* sleep/wait 或者无害的空转(回显、真、冒号、注释)。只要有一条在做真事
|
|
241
|
+
* (`sleep 1 && npm test`、`git status`、`node …`),整条命令就不是纯等待——
|
|
242
|
+
* 这类命令**必须放行**,否则守卫会拦掉正当的后台工作。
|
|
243
|
+
*
|
|
244
|
+
* @param command - 候选命令。
|
|
245
|
+
* @returns 是纯等待时为 true。
|
|
246
|
+
*/
|
|
247
|
+
function isPureSleepCommand(command) {
|
|
248
|
+
if (typeof command !== "string") return false;
|
|
249
|
+
const trimmed = command.trim();
|
|
250
|
+
if (trimmed.length === 0) return false;
|
|
251
|
+
const statements = trimmed.split(/;|&&|\n/).map((statement) => statement.trim()).filter((statement) => statement.length > 0);
|
|
252
|
+
if (statements.length === 0) return false;
|
|
253
|
+
let sawWait = false;
|
|
254
|
+
for (const statement of statements) {
|
|
255
|
+
if (SLEEP_COMMAND_PATTERN.test(statement)) {
|
|
256
|
+
sawWait = true;
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
if (!isNoopStatement(statement)) return false;
|
|
260
|
+
}
|
|
261
|
+
return sawWait;
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* 判断一条语句是不是无害的空转。
|
|
265
|
+
*
|
|
266
|
+
* @param statement - 候选语句。
|
|
267
|
+
* @returns 是空转时为 true。
|
|
268
|
+
*/
|
|
269
|
+
function isNoopStatement(statement) {
|
|
270
|
+
return NOOP_COMMAND_PATTERN.test(statement) || COMMENT_COMMAND_PATTERN.test(statement);
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* 这个调用方当前**还有没有在跑的子代理**。
|
|
274
|
+
*
|
|
275
|
+
* 两条宿主探测,任何一条给出答案就算数:
|
|
276
|
+
*
|
|
277
|
+
* 1. `subagents.list(agent, 'children')`(老代退化成 `subagents.list(agent)`):
|
|
278
|
+
* 直接子代里有没有 `status === 'running'` 的。
|
|
279
|
+
* 2. `agents.list()`:活着且 `status === 'running'`、并且 `parent` 就是这个
|
|
280
|
+
* 调用方的那些 agent。
|
|
281
|
+
*
|
|
282
|
+
* 宿主提供 `hasRunningChildren` 回调时以它为准(直接注入口,见
|
|
283
|
+
* {@link DispatchExecutionGuardOptions.hasRunningChildren})。
|
|
284
|
+
*
|
|
285
|
+
* @param agent - 调用方 agent。
|
|
286
|
+
* @param ctx - 宿主上下文(可选;用于读 `subagents` / `agents` 服务)。
|
|
287
|
+
* @returns 有在跑的子代理时为 true。
|
|
288
|
+
*/
|
|
289
|
+
function hasRunningChildSubagent(agent, ctx) {
|
|
290
|
+
if (!isRecord(agent)) return false;
|
|
291
|
+
const injected = injectedHasRunningChildren(ctx);
|
|
292
|
+
if (injected !== void 0) try {
|
|
293
|
+
return injected(agent) === true;
|
|
294
|
+
} catch {
|
|
295
|
+
return false;
|
|
296
|
+
}
|
|
297
|
+
const subagents = readService(ctx, "subagents");
|
|
298
|
+
if (isRecord(subagents) && typeof subagents["list"] === "function") {
|
|
299
|
+
if (entriesOf(callList(subagents["list"], subagents, agent)).some((entry) => entry["status"] === "running")) return true;
|
|
300
|
+
}
|
|
301
|
+
const agents = readService(ctx, "agents");
|
|
302
|
+
if (isRecord(agents) && typeof agents["list"] === "function") {
|
|
303
|
+
const listed = callList(agents["list"], agents, agent, false);
|
|
304
|
+
const parentIds = /* @__PURE__ */ new Set();
|
|
305
|
+
const ownId = idOf(agent);
|
|
306
|
+
if (ownId !== void 0) parentIds.add(ownId);
|
|
307
|
+
const headerId = sessionIdOf(agent);
|
|
308
|
+
if (headerId !== void 0) parentIds.add(headerId);
|
|
309
|
+
if (entriesOf(listed).some((entry) => entry["status"] === "running" && typeof entry["parent"] === "string" && parentIds.has(entry["parent"]))) return true;
|
|
310
|
+
}
|
|
311
|
+
return false;
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* 调用一个「列出子代」的宿主方法。
|
|
315
|
+
*
|
|
316
|
+
* 先试带 scope 的形态(`list(agent, 'children')`),老代只认 `list(agent)`,
|
|
317
|
+
* 再老一代的 `agents.list()` 不接参数——三种都试,谁先成功用谁。
|
|
318
|
+
*
|
|
319
|
+
* @param fn - 宿主方法。
|
|
320
|
+
* @param self - 调用宿主。
|
|
321
|
+
* @param agent - 调用方 agent。
|
|
322
|
+
* @param scoped - 是否先试带 scope 的形态。
|
|
323
|
+
* @returns 宿主返回值;全部失败时为 undefined。
|
|
324
|
+
*/
|
|
325
|
+
function callList(fn, self, agent, scoped = true) {
|
|
326
|
+
if (typeof fn !== "function") return void 0;
|
|
327
|
+
const call = fn;
|
|
328
|
+
const attempts = scoped ? [
|
|
329
|
+
[agent, "children"],
|
|
330
|
+
[agent],
|
|
331
|
+
[]
|
|
332
|
+
] : [
|
|
333
|
+
[],
|
|
334
|
+
[agent],
|
|
335
|
+
[agent, "children"]
|
|
336
|
+
];
|
|
337
|
+
for (const args of attempts) try {
|
|
338
|
+
const listed = call.apply(self, args);
|
|
339
|
+
if (listed !== void 0) return listed;
|
|
340
|
+
} catch {}
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* 从宿主返回值里取出「条目数组」。
|
|
344
|
+
*
|
|
345
|
+
* 宿主可能直接给数组,也可能给一个包着数组的记录(`children` / `agents`)。
|
|
346
|
+
*
|
|
347
|
+
* @param listed - 宿主返回值。
|
|
348
|
+
* @returns 条目数组;取不到时是空数组。
|
|
349
|
+
*/
|
|
350
|
+
function entriesOf(listed) {
|
|
351
|
+
if (Array.isArray(listed)) return listed.filter(isRecord);
|
|
352
|
+
if (!isRecord(listed)) return [];
|
|
353
|
+
const entries = [];
|
|
354
|
+
for (const key of [
|
|
355
|
+
"children",
|
|
356
|
+
"agents",
|
|
357
|
+
"entries",
|
|
358
|
+
"list"
|
|
359
|
+
]) {
|
|
360
|
+
const candidate = listed[key];
|
|
361
|
+
if (!Array.isArray(candidate)) continue;
|
|
362
|
+
for (const entry of candidate) if (isRecord(entry)) entries.push(entry);
|
|
363
|
+
}
|
|
364
|
+
return entries;
|
|
365
|
+
}
|
|
366
|
+
/**
|
|
367
|
+
* 解析「调度执行守卫」当前是否启用。
|
|
368
|
+
*
|
|
369
|
+
* 沿 volatile 活引用取当前值,所以设置保存后立刻生效。缺省、非法值、读取抛错都
|
|
370
|
+
* 按**开启**处理:守卫是保护,不是功能开关,读失败不该把保护关掉。
|
|
371
|
+
*
|
|
372
|
+
* @param config - 插件配置(活引用或普通值)。
|
|
373
|
+
* @returns 是否启用。
|
|
374
|
+
*/
|
|
375
|
+
function resolveDispatchExecutionGuardEnabled(config) {
|
|
376
|
+
const field = config?.dispatchExecutionGuard;
|
|
377
|
+
if (field === void 0 || field === null) return true;
|
|
378
|
+
if (typeof field === "object" && typeof field.get === "function") try {
|
|
379
|
+
return field.get() !== false;
|
|
380
|
+
} catch {
|
|
381
|
+
return true;
|
|
382
|
+
}
|
|
383
|
+
return field !== false;
|
|
384
|
+
}
|
|
385
|
+
/**
|
|
386
|
+
* 子代理越权调用时的拒绝文案。
|
|
387
|
+
*
|
|
388
|
+
* 文案要**可执行**:光说「不许调」模型只会换个花样再试一次,所以这里写清楚
|
|
389
|
+
* 它该做什么——把未决问题带回主控,并带上报告状态。
|
|
390
|
+
*
|
|
391
|
+
* @param toolName - 被拦下的工具名。
|
|
392
|
+
* @returns 拒绝理由。
|
|
393
|
+
*/
|
|
394
|
+
function delegatedDenial(toolName) {
|
|
395
|
+
return `DELEGATED_CALLER: "${toolName}" is a dispatch-level tool and a delegated subagent must not call it directly. Report back to the dispatcher instead: include status: "hand_back" or status: "blocked" and the unresolved question or decision in your report, and let the dispatcher act on it.`;
|
|
396
|
+
}
|
|
397
|
+
/**
|
|
398
|
+
* 子代理还在跑时用 sleep 空等的拒绝文案。
|
|
399
|
+
*
|
|
400
|
+
* @returns 拒绝理由。
|
|
401
|
+
*/
|
|
402
|
+
function activeChildSleepDenial() {
|
|
403
|
+
return "ACTIVE_CHILD_SLEEP_FORBIDDEN: a child subagent is still running, so sleeping to wait for it is not allowed. End your turn now and let the built-in subagent completion notification wake you.";
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* 主控可以向用户提问的那一个工具名。
|
|
407
|
+
*/
|
|
408
|
+
const ASK_USER_QUESTION_TOOL = "ask_user_question";
|
|
409
|
+
/**
|
|
410
|
+
* 一个问题条目上可以出现的键。
|
|
411
|
+
*
|
|
412
|
+
* 这份清单只用在**修正指引**里:错位键被拦下时把它抄给模型,模型才知道该
|
|
413
|
+
* 把字段放回哪里。清单本身不做白名单校验——宿主往后加字段不该被守卫拦掉。
|
|
414
|
+
*/
|
|
415
|
+
const ASK_QUESTION_KEYS = Object.freeze([
|
|
416
|
+
"id",
|
|
417
|
+
"question",
|
|
418
|
+
"header",
|
|
419
|
+
"options",
|
|
420
|
+
"multi_select",
|
|
421
|
+
"multiSelect",
|
|
422
|
+
"detail",
|
|
423
|
+
"intent",
|
|
424
|
+
"timeout"
|
|
425
|
+
]);
|
|
426
|
+
/**
|
|
427
|
+
* 合法 `id` 至少要含一个字母或数字(含 CJK)。
|
|
428
|
+
*
|
|
429
|
+
* 光有标点或空白的 id 不是 id:生产里见过 `""`、`":"`、`"---"`,用户看到的
|
|
430
|
+
* 回答回执全变成同一个无名标签,主控也没法把回答对回问题。
|
|
431
|
+
*/
|
|
432
|
+
const READABLE_ID_PATTERN = /[\p{L}\p{N}]/u;
|
|
433
|
+
/**
|
|
434
|
+
* 判断一个键名是不是 `options` 系列。
|
|
435
|
+
*
|
|
436
|
+
* 生产里的畸形载荷把 options 整段 JSON 糊进了键名:`"options [{"`、`"options:"`
|
|
437
|
+
* 、`"options:"`。它们的共同特征是**去掉首尾空白、转小写后以 `options` 开头**,
|
|
438
|
+
* 所以判据就取这个前缀——`options` 本身也落在其中,调用方另行放过。
|
|
439
|
+
*
|
|
440
|
+
* @param key - 候选键名。
|
|
441
|
+
* @returns 是 options 系列键时为 true。
|
|
442
|
+
*/
|
|
443
|
+
function isOptionsKey(key) {
|
|
444
|
+
return key.trim().toLowerCase().startsWith("options");
|
|
445
|
+
}
|
|
446
|
+
/**
|
|
447
|
+
* 找出问题条目上**本该嵌在 `options` 里**的错位键。
|
|
448
|
+
*
|
|
449
|
+
* 两类错位都见过:
|
|
450
|
+
*
|
|
451
|
+
* - `label` / `description` 直接摊在问题对象上(选项字段漏了一层嵌套);
|
|
452
|
+
* - 键名本身是 options 的碎片(`"options [{"`、`"options:"`),值往往是空串。
|
|
453
|
+
*
|
|
454
|
+
* @param question - 候选问题条目。
|
|
455
|
+
* @returns 第一个错位键名;没有时为 undefined。
|
|
456
|
+
*/
|
|
457
|
+
function misplacedQuestionKey(question) {
|
|
458
|
+
for (const key of Object.keys(question)) {
|
|
459
|
+
if (key === "options") continue;
|
|
460
|
+
if (key === "label" || key === "description") return key;
|
|
461
|
+
if (isOptionsKey(key)) return key;
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
/**
|
|
465
|
+
* 找出选项条目上的错位键。
|
|
466
|
+
*
|
|
467
|
+
* 一个选项只该有 `label` / `description`;出现 `question` 说明调用方把整道题
|
|
468
|
+
* 又抄进了选项里,出现 `options*` 说明嵌套被摊平过了。
|
|
469
|
+
*
|
|
470
|
+
* @param option - 候选选项条目。
|
|
471
|
+
* @returns 第一个错位键名;没有时为 undefined。
|
|
472
|
+
*/
|
|
473
|
+
function misplacedOptionKey(option) {
|
|
474
|
+
for (const key of Object.keys(option)) if (key === "question" || isOptionsKey(key)) return key;
|
|
475
|
+
}
|
|
476
|
+
/**
|
|
477
|
+
* 一段供拒绝文案引用的短摘录。
|
|
478
|
+
*
|
|
479
|
+
* @param value - 原始文本。
|
|
480
|
+
* @param limit - 最多保留多少个字符。
|
|
481
|
+
* @returns 单行摘录。
|
|
482
|
+
*/
|
|
483
|
+
function excerpt(value, limit = 60) {
|
|
484
|
+
const collapsed = value.trim().replace(/\s+/g, " ");
|
|
485
|
+
return JSON.stringify(collapsed.length > limit ? `${collapsed.slice(0, limit)}…` : collapsed);
|
|
486
|
+
}
|
|
487
|
+
/**
|
|
488
|
+
* 生成一条「提问载荷畸形」的拒绝文案。
|
|
489
|
+
*
|
|
490
|
+
* @param detail - 具体坏在哪里、该怎么改。
|
|
491
|
+
* @returns 带 `INVALID_USER_QUESTION:` 前缀的拒绝理由。
|
|
492
|
+
*/
|
|
493
|
+
function invalidAskUserQuestion(detail) {
|
|
494
|
+
return `INVALID_USER_QUESTION: ${detail}`;
|
|
495
|
+
}
|
|
496
|
+
/**
|
|
497
|
+
* 校验主控 `ask_user_question` 的调用载荷。
|
|
498
|
+
*
|
|
499
|
+
* 这是**执行期**的结构校验:工具声明面一个字节不动(前缀缓存零漂移),但畸形
|
|
500
|
+
* 的载荷在真正弹给用户之前就被挡回给模型,让模型下一步就能自己改对。
|
|
501
|
+
*
|
|
502
|
+
* 三类畸形各自能被模型自助修好:
|
|
503
|
+
*
|
|
504
|
+
* 1. **选项嵌套被摊平**——`"options [{"`、`"options:"` 这类键名,或者把
|
|
505
|
+
* `label` / `description` 摊在问题对象上。指引它写成
|
|
506
|
+
* `{"id": …, "question": …, "options": [{"label": …, "description": …}]}`。
|
|
507
|
+
* 2. **`id` / `question` 退化**——空串、`":"`、`"---"`、纯空白,或者在同一个
|
|
508
|
+
* 批次里重复。重复的问题文本要并成**一道题多个选项**,而不是问两遍。
|
|
509
|
+
* 3. **选项内部坏掉**——不是对象、缺 `label`、同一题里 `label` 重复。
|
|
510
|
+
*
|
|
511
|
+
* **开放题必须继续放行**:`options` 缺省就是一道开放题,`options: []`(适配层
|
|
512
|
+
* 的空数组兜底)同样放行;只有数组里真有条目时才逐条校验。
|
|
513
|
+
*
|
|
514
|
+
* 非对象载荷、缺 `questions`、`questions` 不是数组或为空时一律返回 undefined:
|
|
515
|
+
* 那几种形态归底层工具自己的 schema 与空检查管,守卫不重复判、也不抢它的文案。
|
|
516
|
+
*
|
|
517
|
+
* @param args - 已解析的工具参数。
|
|
518
|
+
* @returns 合法时为 undefined;畸形时是 `INVALID_USER_QUESTION: …` 文案。
|
|
519
|
+
*/
|
|
520
|
+
function validateAskUserQuestionArgs(args) {
|
|
521
|
+
if (!isRecord(args)) return void 0;
|
|
522
|
+
const questions = args["questions"];
|
|
523
|
+
if (!Array.isArray(questions) || questions.length === 0) return void 0;
|
|
524
|
+
const seenIds = /* @__PURE__ */ new Set();
|
|
525
|
+
const seenQuestions = /* @__PURE__ */ new Set();
|
|
526
|
+
for (let index = 0; index < questions.length; index += 1) {
|
|
527
|
+
const question = questions[index];
|
|
528
|
+
const at = `questions[${index}]`;
|
|
529
|
+
if (!isRecord(question)) return invalidAskUserQuestion(`${at} must be a question object. Known question keys: ${ASK_QUESTION_KEYS.join(", ")}.`);
|
|
530
|
+
const misplaced = misplacedQuestionKey(question);
|
|
531
|
+
if (misplaced !== void 0) return invalidAskUserQuestion(`${at} carries the misplaced key ${JSON.stringify(misplaced)}: option fields collapsed into the question object. Build ONE question object and nest its choices inside it: {"id": "palette", "question": "Which palette?", "options": [{"label": "Warm", "description": "…"}]}. Known question keys: ${ASK_QUESTION_KEYS.join(", ")}.`);
|
|
532
|
+
const id = question["id"];
|
|
533
|
+
if (typeof id !== "string" || id.trim().length === 0 || !READABLE_ID_PATTERN.test(id)) return invalidAskUserQuestion(`${at} needs "id" to be a non-empty string with at least one letter or digit. A blank string, whitespace, ":" or "---" is not a usable id: the answer is echoed back against that id, so use a stable slug such as "palette-choice".`);
|
|
534
|
+
const trimmedId = id.trim();
|
|
535
|
+
if (seenIds.has(trimmedId)) return invalidAskUserQuestion(`${at}.id ${JSON.stringify(trimmedId)} is already used by an earlier question in this batch. Every question needs a distinct stable id so each answer maps back to one question.`);
|
|
536
|
+
seenIds.add(trimmedId);
|
|
537
|
+
const text = question["question"];
|
|
538
|
+
if (typeof text !== "string" || text.trim().length === 0) return invalidAskUserQuestion(`${at} needs "question" to be a non-empty string with the text the user reads.`);
|
|
539
|
+
const normalizedText = text.trim().replace(/\s+/g, " ");
|
|
540
|
+
if (seenQuestions.has(normalizedText)) return invalidAskUserQuestion(`${at} repeats the question text ${excerpt(text)} already asked earlier in this batch. To offer several candidate choices, put them into ONE question's "options" array instead of asking the same question again.`);
|
|
541
|
+
seenQuestions.add(normalizedText);
|
|
542
|
+
const options = question["options"];
|
|
543
|
+
if (options === void 0) continue;
|
|
544
|
+
if (!Array.isArray(options)) return invalidAskUserQuestion(`${at}.options must be an array of {"label": "…", "description": "…"} objects. Omit "options" entirely when the question is open-ended.`);
|
|
545
|
+
if (options.length === 0) continue;
|
|
546
|
+
const seenLabels = /* @__PURE__ */ new Set();
|
|
547
|
+
for (let optionIndex = 0; optionIndex < options.length; optionIndex += 1) {
|
|
548
|
+
const option = options[optionIndex];
|
|
549
|
+
const optionAt = `${at}.options[${optionIndex}]`;
|
|
550
|
+
if (!isRecord(option)) return invalidAskUserQuestion(`${optionAt} must be an object like {"label": "…", "description": "…"}.`);
|
|
551
|
+
const optionMisplaced = misplacedOptionKey(option);
|
|
552
|
+
if (optionMisplaced !== void 0) return invalidAskUserQuestion(`${optionAt} carries the misplaced key ${JSON.stringify(optionMisplaced)}: an option object holds only "label" and "description", while "question" and "options" belong to the question object.`);
|
|
553
|
+
const label = option["label"];
|
|
554
|
+
if (typeof label !== "string" || label.trim().length === 0) return invalidAskUserQuestion(`${optionAt} needs a non-empty "label" string: the label is what the user clicks.`);
|
|
555
|
+
const trimmedLabel = label.trim();
|
|
556
|
+
if (seenLabels.has(trimmedLabel)) return invalidAskUserQuestion(`${optionAt}.label ${JSON.stringify(trimmedLabel)} repeats inside ${at}. Every option of one question needs a distinct label.`);
|
|
557
|
+
seenLabels.add(trimmedLabel);
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
/**
|
|
562
|
+
* 装一台「调度执行守卫」。
|
|
563
|
+
*
|
|
564
|
+
* 只用 `ctx.tools.guard()` 这一条执行期接缝:工具声明面(`tools:sdk` 段与顶层
|
|
565
|
+
* 工具 schema)一个字节都不改,Kimi Code 的前缀缓存不会因为这个守卫重建。
|
|
566
|
+
*
|
|
567
|
+
* @param ctx - 宿主上下文。
|
|
568
|
+
* @param options - 守卫选项。
|
|
569
|
+
* @returns 注销守卫的函数;宿主没有 `guard` 接缝时是一个空操作。
|
|
570
|
+
*/
|
|
571
|
+
function installDispatchExecutionGuard(ctx, options = {}) {
|
|
572
|
+
const tools = toolsOf(ctx);
|
|
573
|
+
const register = tools?.guard;
|
|
574
|
+
if (typeof register !== "function") return () => void 0;
|
|
575
|
+
const enabled = options.enabled ?? (() => true);
|
|
576
|
+
const delegatedBlocked = new Set(DEFAULT_DELEGATED_BLOCKED_TOOLS);
|
|
577
|
+
const shellTools = new Set(DEFAULT_SHELL_TOOLS);
|
|
578
|
+
const hasRunningChildren = options.hasRunningChildren;
|
|
579
|
+
const guard = (execution) => {
|
|
580
|
+
if (!isEnabled(enabled)) return void 0;
|
|
581
|
+
const toolName = typeof execution?.name === "string" ? execution.name : void 0;
|
|
582
|
+
if (toolName === void 0) return void 0;
|
|
583
|
+
const agent = execution?.agent;
|
|
584
|
+
if (delegatedBlocked.has(toolName) && isDelegatedCaller(agent, ctx)) return delegatedDenial(toolName);
|
|
585
|
+
if (toolName === ASK_USER_QUESTION_TOOL) {
|
|
586
|
+
const malformed = validateAskUserQuestionArgs(execution?.arguments);
|
|
587
|
+
if (malformed !== void 0) return malformed;
|
|
588
|
+
}
|
|
589
|
+
if (shellTools.has(toolName)) {
|
|
590
|
+
const args = isRecord(execution?.arguments) ? execution.arguments : void 0;
|
|
591
|
+
if (!(args?.["run_in_background"] === true) && isPureSleepCommand(args?.["command"])) {
|
|
592
|
+
if (hasRunningChildren !== void 0 ? hasRunningChildren(agent) === true : hasRunningChildSubagent(agent, ctx)) return activeChildSleepDenial();
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
};
|
|
596
|
+
const dispose = register.call(tools, guard);
|
|
597
|
+
return typeof dispose === "function" ? dispose : () => void 0;
|
|
598
|
+
}
|
|
599
|
+
/**
|
|
600
|
+
* 读一次开关。读失败按**开启**处理:保护不该因为一次读失败而失效。
|
|
601
|
+
*
|
|
602
|
+
* @param enabled - 开关读取函数。
|
|
603
|
+
* @returns 是否启用。
|
|
604
|
+
*/
|
|
605
|
+
function isEnabled(enabled) {
|
|
606
|
+
try {
|
|
607
|
+
return enabled() !== false;
|
|
608
|
+
} catch {
|
|
609
|
+
return true;
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
//#endregion
|
|
613
|
+
//#region src/entry-plan-mode.ts
|
|
614
|
+
/**
|
|
615
|
+
* 「计划模式」服务 —— ctx.planMode 的 **fork**(本包在 dispatch preset 里替换
|
|
616
|
+
* harness 官方的 @deepseek-ai/dsh-plan-mode)。
|
|
617
|
+
*
|
|
618
|
+
* ## 为什么必须 fork
|
|
619
|
+
*
|
|
620
|
+
* 官方实现的动态 plan:policy 段在进入/离开计划模式时翻转;exit_plan_mode 也带
|
|
621
|
+
* { when } 逐步准入。0.2.2 起工具在首次进入后会持续保留,减少了后续切换的工具表
|
|
622
|
+
* 变化,但首次准入与 system 段变化仍会重建 native tools 与 PTC tools:sdk 的前缀缓存。
|
|
623
|
+
*
|
|
624
|
+
* Kimi 线路按内容前缀命中,这两次重建是**计划模式必经**的固定税。这个 fork 把税
|
|
625
|
+
* 取消掉,靠的是同一个观察:**末尾追加不破坏前缀**。
|
|
626
|
+
*
|
|
627
|
+
* ## 三个决定(每一个都对应上面一处重建)
|
|
628
|
+
*
|
|
629
|
+
* 1. **不注册任何动态 system 段。** system 段在整个会话里逐字节恒定。
|
|
630
|
+
* 2. **exit_plan_mode 无条件注册。** 没有 { when },它从会话第一步就在工具表里,
|
|
631
|
+
* 进出计划模式都不改工具表;「只在计划模式可用」由 execute 里的 loggedActive
|
|
632
|
+
* 判定执行(报错文案与官方一致)。代价是每一步多带一个工具声明,收益是
|
|
633
|
+
* native / PTC 两侧零重建。
|
|
634
|
+
* 3. **部署方的 section 走尾部用户消息**(agent/pre-step 的 decision.messages,
|
|
635
|
+
* 无回合时走 agent.inject),一次进入只投一次。手册性指引留在 enter_plan_mode
|
|
636
|
+
* 的工具返回里(见 entry-plan-mode-pipeline.ts)。
|
|
637
|
+
*
|
|
638
|
+
* ## 第四件事:回合结束时的提醒(也不进前缀缓存)
|
|
639
|
+
*
|
|
640
|
+
* 计划模式里模型讲完一段话就收尾(`finish: stop`、零工具调用)时,宿主不会替它做任何
|
|
641
|
+
* 事:那一回合就停在那里,直到人去催「确认呢」。`installPlanModeExitReminder` 在
|
|
642
|
+
* `llm/stream` 上认一次**已完成、只有可见文本、零工具调用**的对话请求,在 agent 空闲
|
|
643
|
+
* 之后补一条尾部消息(`createPlanNotice`),让模型自己选 `exit_plan_mode` /
|
|
644
|
+
* `ask_user_question` / `leave_plan_mode`。它同样不注册 system 段、不动工具表,所以和
|
|
645
|
+
* 上面三个决定一样零缓存漂移;cap 是每条连续收尾一条。
|
|
646
|
+
*
|
|
647
|
+
* ## 与官方实现的其余差别一律是「代次协商」,不是功能裁剪
|
|
648
|
+
*
|
|
649
|
+
* - plan 投影的 stateVersion 在 0.2.1-rc.5 及更早是 3(无 entered),0.2.2-alpha
|
|
650
|
+
* 起是 4(含 entered)。sessionProjections.register 对同一个 key 的版本不一致是
|
|
651
|
+
* **硬失败**,而同一个宿主里另一个 preset 可能已经注册过它,所以这里先按 4 注册,
|
|
652
|
+
* 失败时读错误里的 already registered at stateVersion N 用那个版本重试一次。
|
|
653
|
+
* - register() 在旧代是同步的、状态要经 ctx.sessionProjections.stateOf() 读;新代
|
|
654
|
+
* 返回可 await 的 ProjectionHandle。两种形状都在下面接住。
|
|
655
|
+
* - 没有 sessionProjections(老代宿主)时退回进程内 WeakMap:计划模式照常可用,
|
|
656
|
+
* 只是不随会话日志持久化。
|
|
657
|
+
*
|
|
658
|
+
* ## 不能 import 的东西
|
|
659
|
+
*
|
|
660
|
+
* @deepseek-ai/dsh-plan-mode / dsh-agent / dsh-session / dsh-commands /
|
|
661
|
+
* dsh-user-questions / dsh-session-projection 都不在本包 peerDependencies 里,所以
|
|
662
|
+
* 顶层**不 import 它们**,只按结构探测:ctx.get('sessionProjections')、
|
|
663
|
+
* ctx.get('userQuestions')、ctx.get('tools')、ctx.get('agents')、ctx.get('commands')。
|
|
664
|
+
*
|
|
665
|
+
* @module @eddyskywalker/dsh-chatgpt-subscription/plan-mode
|
|
666
|
+
*/
|
|
667
|
+
/** 组件行名;preset 里的行 id 与模块说明符另由 preset 文件决定。 */
|
|
668
|
+
const name = "dsh-chatgpt-subscription-plan-mode";
|
|
669
|
+
/** 模型侧审批出口的工具名。冻结契约的一部分,不要改动。 */
|
|
670
|
+
const EXIT_PLAN_MODE = "exit_plan_mode";
|
|
671
|
+
/** 官方实现用来承载计划指引的 prompt 段名(这个 fork 刻意**不注册**它)。 */
|
|
672
|
+
const PLAN_POLICY_SECTION = "plan:policy";
|
|
673
|
+
/** plan 投影的 key。跨 preset 共享,不能改名。 */
|
|
674
|
+
const PLAN_PROJECTION_KEY = "plan";
|
|
675
|
+
/** 会话日志里记录计划模式开关的事件类型(与官方同名,投影可互换折叠)。 */
|
|
676
|
+
const PLAN_MODE_EVENT = "plan/mode";
|
|
677
|
+
/** 最新一代的投影状态版本(0.2.2-alpha 起:含 entered)。 */
|
|
678
|
+
const PLAN_STATE_VERSION = 4;
|
|
679
|
+
/** 旧一代(≤0.2.1-rc.5)的投影状态版本:没有 entered。 */
|
|
680
|
+
const LEGACY_PLAN_STATE_VERSION = 3;
|
|
681
|
+
/** 审批问题在答案里的 id。 */
|
|
682
|
+
const REVIEW_ID = "plan-review";
|
|
683
|
+
/** 审批问题「批准」选项的文案。 */
|
|
684
|
+
const APPROVE_LABEL = "Approve";
|
|
685
|
+
/** 审批问题「继续规划」选项的文案。 */
|
|
686
|
+
const KEEP_PLANNING_LABEL = "Keep planning";
|
|
687
|
+
/** 尾部通知消息的来源 kind(本包自有,见下方 MessageSourceMap 增补)。 */
|
|
688
|
+
const PLAN_NOTICE_SOURCE_KIND = "plan-mode-notice";
|
|
689
|
+
/**
|
|
690
|
+
* 校验部署方配置。缺失、空白、类型不符与未知字段都在**挂载时**失败,而不是被忽略。
|
|
691
|
+
* @param config - 原始插件配置。
|
|
692
|
+
* @returns 一份脱离入参的校验后配置。
|
|
693
|
+
* @throws {Error} 判定与文案与官方一致,便于既有部署配置与测试直接迁移。
|
|
694
|
+
*/
|
|
695
|
+
function resolveConfig(config) {
|
|
696
|
+
const section = config?.section;
|
|
697
|
+
if (typeof section !== "string") throw new Error("PlanModeConfig needs a string `section`");
|
|
698
|
+
if (section.trim() === "") throw new Error("PlanModeConfig needs a non-empty `section`");
|
|
699
|
+
const unknown = Object.keys(config ?? {}).filter((key) => key !== "section");
|
|
700
|
+
if (unknown.length > 0) throw new Error("PlanModeConfig has unknown key(s) " + unknown.join(", ") + " — config is { section }");
|
|
701
|
+
return { section };
|
|
702
|
+
}
|
|
703
|
+
/**
|
|
704
|
+
* 投影状态 schema。
|
|
705
|
+
*
|
|
706
|
+
* 刻意**不用 strict()、并给 entered 一个默认值**:同一个 key 可能被按 v3 注册的
|
|
707
|
+
* 另一份定义(≤0.2.1-rc.5,没有 entered 字段)写进持久化缓存,严格的 schema 会让
|
|
708
|
+
* 那份缓存整行作废,进而把服务从「共享」变成「冲突」。
|
|
709
|
+
*/
|
|
710
|
+
const planUnitStateSchema = z.object({
|
|
711
|
+
active: z.boolean(),
|
|
712
|
+
wanted: z.boolean().nullable(),
|
|
713
|
+
running: z.object({
|
|
714
|
+
commandId: z.string(),
|
|
715
|
+
wanted: z.boolean()
|
|
716
|
+
}).nullable(),
|
|
717
|
+
activeAtLastHeader: z.boolean().nullable(),
|
|
718
|
+
entered: z.boolean().default(false)
|
|
719
|
+
});
|
|
720
|
+
/** 线上视图 schema。 */
|
|
721
|
+
const planProjectionViewSchema = z.object({
|
|
722
|
+
active: z.boolean(),
|
|
723
|
+
pending: z.boolean()
|
|
724
|
+
});
|
|
725
|
+
/**
|
|
726
|
+
* 造一份 plan 投影定义。
|
|
727
|
+
*
|
|
728
|
+
* 折叠逻辑与官方逐句等价:/plan 命令的生命周期(command/run -> command/done)、
|
|
729
|
+
* plan/mode 整值记录、request/header 记下「上一次告诉模型的模式」。等价是硬要求:
|
|
730
|
+
* 同一个宿主里另一份定义可能已经按别的 stateVersion 注册了同一个 key,两边折叠出的
|
|
731
|
+
* 状态必须是同一个东西,共享才安全。
|
|
732
|
+
* @param stateVersion - 投影状态版本,默认最新一代(4)。冲突时用冲突方的版本重试。
|
|
733
|
+
* @returns 投影定义。
|
|
734
|
+
*/
|
|
735
|
+
function createPlanProjectionDefinition(stateVersion = 4) {
|
|
736
|
+
return {
|
|
737
|
+
key: PLAN_PROJECTION_KEY,
|
|
738
|
+
stateVersion,
|
|
739
|
+
stateSchema: planUnitStateSchema,
|
|
740
|
+
init: () => ({
|
|
741
|
+
active: false,
|
|
742
|
+
wanted: null,
|
|
743
|
+
running: null,
|
|
744
|
+
activeAtLastHeader: null,
|
|
745
|
+
entered: false
|
|
746
|
+
}),
|
|
747
|
+
apply: (state, event) => {
|
|
748
|
+
const data = event.data ?? {};
|
|
749
|
+
if (event.type === "command/run" && data.name === "plan") {
|
|
750
|
+
if (data.args === void 0) return state;
|
|
751
|
+
const wanted = typeof data.args === "string" ? data.args.trim() !== "off" : false;
|
|
752
|
+
return {
|
|
753
|
+
...state,
|
|
754
|
+
running: {
|
|
755
|
+
commandId: String(data.commandId ?? ""),
|
|
756
|
+
wanted
|
|
757
|
+
}
|
|
758
|
+
};
|
|
759
|
+
}
|
|
760
|
+
if (event.type === "command/done" && state.running !== null && data.commandId === state.running.commandId) {
|
|
761
|
+
const wanted = data.kind === "success" && state.running.wanted !== state.active ? state.running.wanted : null;
|
|
762
|
+
return {
|
|
763
|
+
...state,
|
|
764
|
+
wanted,
|
|
765
|
+
running: null
|
|
766
|
+
};
|
|
767
|
+
}
|
|
768
|
+
if (event.type === "plan/mode") {
|
|
769
|
+
const active = data.active === true;
|
|
770
|
+
return {
|
|
771
|
+
...state,
|
|
772
|
+
active,
|
|
773
|
+
wanted: null,
|
|
774
|
+
entered: state.entered || active
|
|
775
|
+
};
|
|
776
|
+
}
|
|
777
|
+
if (event.type === "request/header") return {
|
|
778
|
+
...state,
|
|
779
|
+
activeAtLastHeader: state.active
|
|
780
|
+
};
|
|
781
|
+
return state;
|
|
782
|
+
},
|
|
783
|
+
wire: {
|
|
784
|
+
viewSchema: planProjectionViewSchema,
|
|
785
|
+
view: (state) => {
|
|
786
|
+
const wanted = state.running?.wanted ?? state.wanted;
|
|
787
|
+
return {
|
|
788
|
+
active: state.active,
|
|
789
|
+
pending: wanted !== null && wanted !== state.active
|
|
790
|
+
};
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
};
|
|
794
|
+
}
|
|
795
|
+
/** 最新一代(stateVersion 4)的 plan 投影定义。 */
|
|
796
|
+
const planProjectionDefinition = createPlanProjectionDefinition();
|
|
797
|
+
/** 什么读不到时的空状态。 */
|
|
798
|
+
const EMPTY_PLAN_STATE = Object.freeze({
|
|
799
|
+
active: false,
|
|
800
|
+
wanted: null,
|
|
801
|
+
running: null,
|
|
802
|
+
activeAtLastHeader: null,
|
|
803
|
+
entered: false
|
|
804
|
+
});
|
|
805
|
+
/**
|
|
806
|
+
* 调用时读一个可选服务。服务缺席或读取抛错一律 undefined —— 这一行不该因为
|
|
807
|
+
* 某个服务不在而挂不上。
|
|
808
|
+
* @param ctx - 宿主上下文(结构类型:老代的 ctx 也认)。
|
|
809
|
+
* @param service - 服务名。
|
|
810
|
+
* @returns 服务实例;不存在或读取抛错时 undefined。
|
|
811
|
+
*/
|
|
812
|
+
function serviceOf(ctx, service) {
|
|
813
|
+
const read = ctx?.get;
|
|
814
|
+
if (typeof read !== "function") return void 0;
|
|
815
|
+
try {
|
|
816
|
+
return read.call(ctx, service);
|
|
817
|
+
} catch {
|
|
818
|
+
return;
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
/**
|
|
822
|
+
* 记一条日志。日志服务缺席时安静跳过。
|
|
823
|
+
* @param ctx - 宿主上下文。
|
|
824
|
+
* @param level - 级别。
|
|
825
|
+
* @param message - 正文。
|
|
826
|
+
*/
|
|
827
|
+
function log(ctx, level, message) {
|
|
828
|
+
const logger = ctx?.logger;
|
|
829
|
+
try {
|
|
830
|
+
if (level === "warn") logger?.warn(message);
|
|
831
|
+
else logger?.info(message);
|
|
832
|
+
} catch {}
|
|
833
|
+
}
|
|
834
|
+
/**
|
|
835
|
+
* 会话对象作为 WeakMap 键。
|
|
836
|
+
* @param session - 任意值。
|
|
837
|
+
* @returns 是对象时原样返回,否则 undefined(没有会话就等于没有状态)。
|
|
838
|
+
*/
|
|
839
|
+
function objectKey(session) {
|
|
840
|
+
return typeof session === "object" && session !== null ? session : void 0;
|
|
841
|
+
}
|
|
842
|
+
/**
|
|
843
|
+
* 从版本冲突错误里读冲突方的 stateVersion。
|
|
844
|
+
* @param error - register 抛出的错误。
|
|
845
|
+
* @returns 冲突方版本;不是版本冲突时 undefined。
|
|
846
|
+
*/
|
|
847
|
+
function conflictingStateVersion(error) {
|
|
848
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
849
|
+
const match = /already registered at stateVersion (\d+)/.exec(message);
|
|
850
|
+
if (match === null) return void 0;
|
|
851
|
+
const version = Number.parseInt(match[1], 10);
|
|
852
|
+
return Number.isSafeInteger(version) && version >= 0 ? version : void 0;
|
|
853
|
+
}
|
|
854
|
+
/**
|
|
855
|
+
* 把 register() 的返回值接成句柄。
|
|
856
|
+
*
|
|
857
|
+
* 三种形状:可 await 的 ProjectionHandle(新代)、同步 disposer 函数(旧代,状态要
|
|
858
|
+
* 经 ctx.sessionProjections.stateOf 读)、以及两者都不是(这一代没有投影注册面)。
|
|
859
|
+
* @param ready - register 的返回值。
|
|
860
|
+
* @param projections - 投影服务本身(旧代读状态的兜底)。
|
|
861
|
+
* @returns 句柄;接不住时 undefined。
|
|
862
|
+
*/
|
|
863
|
+
async function handleOf(ready, projections) {
|
|
864
|
+
if (typeof ready?.then === "function") {
|
|
865
|
+
const handle = await ready;
|
|
866
|
+
return typeof handle?.stateOf === "function" ? handle : legacyHandle(projections);
|
|
867
|
+
}
|
|
868
|
+
return legacyHandle(projections);
|
|
869
|
+
}
|
|
870
|
+
/**
|
|
871
|
+
* 旧代的读法:按 key 读。
|
|
872
|
+
* @param projections - 投影服务。
|
|
873
|
+
* @returns 句柄;服务没有按 key 读的能力时 undefined。
|
|
874
|
+
*/
|
|
875
|
+
function legacyHandle(projections) {
|
|
876
|
+
if (typeof projections.stateOf !== "function") return void 0;
|
|
877
|
+
return { stateOf: (session) => projections.stateOf?.(session, PLAN_PROJECTION_KEY) };
|
|
878
|
+
}
|
|
879
|
+
/**
|
|
880
|
+
* 订阅一个事件;这一代没有事件总线时安静跳过。
|
|
881
|
+
* @param ctx - 宿主上下文。
|
|
882
|
+
* @param event - 事件名。
|
|
883
|
+
* @param listener - 监听器。
|
|
884
|
+
* @param options - cordis 订阅选项(`{ global: true }` 只看全局事件)。省略时按两参数订阅。
|
|
885
|
+
* @returns 解绑函数;订不上时 undefined。
|
|
886
|
+
*/
|
|
887
|
+
function subscribe(ctx, event, listener, options) {
|
|
888
|
+
const on = ctx?.on;
|
|
889
|
+
if (typeof on !== "function") return void 0;
|
|
890
|
+
if (options !== void 0) try {
|
|
891
|
+
return on.call(ctx, event, listener, options);
|
|
892
|
+
} catch {}
|
|
893
|
+
try {
|
|
894
|
+
return on.call(ctx, event, listener);
|
|
895
|
+
} catch {
|
|
896
|
+
return;
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
/**
|
|
900
|
+
* ctx.planMode 的实现。
|
|
901
|
+
*
|
|
902
|
+
* 一个会话一份(agent plane),所以内部所有状态都按会话/agent 分桶:待投递选择按
|
|
903
|
+
* 会话 WeakMap,投影句柄一份(投影本身按会话折叠),内存兜底状态按会话 WeakMap。
|
|
904
|
+
*/
|
|
905
|
+
var PlanModeController = class extends Service {
|
|
906
|
+
/**
|
|
907
|
+
* 依赖:tools 注册审批出口,sessionProjections 折叠状态,agents 读回合边界。
|
|
908
|
+
*
|
|
909
|
+
* **故意不含 systemPrompt** —— 这个 fork 一个 prompt 段都不注册,system 段在
|
|
910
|
+
* 整个会话里逐字节恒定,进出计划模式都不重建前缀缓存。
|
|
911
|
+
*/
|
|
912
|
+
static inject = [
|
|
913
|
+
"tools",
|
|
914
|
+
"sessionProjections",
|
|
915
|
+
"agents"
|
|
916
|
+
];
|
|
917
|
+
/** 校验后的部署指引,只经尾部消息投递。 */
|
|
918
|
+
section;
|
|
919
|
+
/** plan 投影的读面;没有投影服务时 undefined。 */
|
|
920
|
+
planProjectionHandle;
|
|
921
|
+
/** 尚未落到日志的选择,按会话。 */
|
|
922
|
+
pendingIntents = /* @__PURE__ */ new WeakMap();
|
|
923
|
+
/** 没有投影服务时的内存状态兜底(计划模式照常可用,只是不持久化)。 */
|
|
924
|
+
memoryStates = /* @__PURE__ */ new WeakMap();
|
|
925
|
+
/** 服务在自己脚下被卸载:此时批过的计划不能再被追加,必须让模型重新呈交。 */
|
|
926
|
+
disposed = false;
|
|
927
|
+
/**
|
|
928
|
+
* @param ctx - 宿主上下文。
|
|
929
|
+
* @param config - 部署配置(默认空 section,会在挂载时被 resolveConfig 拒绝)。
|
|
930
|
+
*/
|
|
931
|
+
constructor(ctx, config = { section: "" }) {
|
|
932
|
+
super(ctx, "planMode");
|
|
933
|
+
this.section = resolveConfig(config).section;
|
|
934
|
+
}
|
|
935
|
+
/**
|
|
936
|
+
* 挂载主体:注册投影、订阅 pre-step、注册审批出口与 /plan 命令。
|
|
937
|
+
*
|
|
938
|
+
* 顺序有讲究:投影先就位,pre-step 与 exit_plan_mode 才能读到状态。
|
|
939
|
+
* @returns 挂载完成(投影注册可能是异步的)。
|
|
940
|
+
*/
|
|
941
|
+
async [Service.init]() {
|
|
942
|
+
const ctx = this.ctx;
|
|
943
|
+
this.planProjectionHandle = await this.acquirePlanProjection();
|
|
944
|
+
const effect = ctx?.effect;
|
|
945
|
+
if (typeof effect === "function") try {
|
|
946
|
+
effect.call(ctx, () => () => {
|
|
947
|
+
this.disposed = true;
|
|
948
|
+
}, "dsh-chatgpt-subscription: close plan-mode service");
|
|
949
|
+
} catch {}
|
|
950
|
+
this.subscribePreStep();
|
|
951
|
+
this.subscribeExitReminder();
|
|
952
|
+
this.registerExitTool();
|
|
953
|
+
this.registerPlanCommand();
|
|
954
|
+
}
|
|
955
|
+
/**
|
|
956
|
+
* 订阅「回合以纯文本收尾」的提醒。
|
|
957
|
+
*
|
|
958
|
+
* 读法是 {@link PlanModeController.selectedActive}:它含未决选择,所以
|
|
959
|
+
* `exit_plan_mode` 刚排进 `{ active: false }`、日志还没落的那一刻不会误提醒。
|
|
960
|
+
* 宿主里同时装着 `installPlanModeCacheGuard` 时,进程级登记册保证一条
|
|
961
|
+
* `llm/stream` 只被一条监听器接手,两处不会各提醒一次。
|
|
962
|
+
*/
|
|
963
|
+
subscribeExitReminder() {
|
|
964
|
+
const controller = this;
|
|
965
|
+
const dispose = installPlanModeExitReminder(this.ctx, { isActive: (agent) => controller.selectedActive(agent?.session) });
|
|
966
|
+
if (dispose === void 0) return;
|
|
967
|
+
const effect = this.ctx?.effect;
|
|
968
|
+
if (typeof effect !== "function") return;
|
|
969
|
+
try {
|
|
970
|
+
effect.call(this.ctx, () => dispose, "dsh-chatgpt-subscription: close the plan-mode exit reminder");
|
|
971
|
+
} catch {
|
|
972
|
+
dispose();
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
/**
|
|
976
|
+
* 注册 plan 投影,并在版本冲突时按冲突方的 stateVersion 重试一次。
|
|
977
|
+
*
|
|
978
|
+
* 冲突本身是「同一个宿主里另一个 preset 已经注册了 plan」:那不是错误,而是
|
|
979
|
+
* 共享同一个折叠单元的前提。所以只读错误里的版本号,不按 harness 版本猜。
|
|
980
|
+
* @returns 投影读面;这一代没有投影注册面时 undefined。
|
|
981
|
+
*/
|
|
982
|
+
async acquirePlanProjection() {
|
|
983
|
+
const projections = serviceOf(this.ctx, "sessionProjections");
|
|
984
|
+
if (projections === void 0 || typeof projections.register !== "function") {
|
|
985
|
+
log(this.ctx, "info", "[dsh-chatgpt-subscription] plan mode has no sessionProjections service: plan mode works but its state is not persisted in this host");
|
|
986
|
+
return;
|
|
987
|
+
}
|
|
988
|
+
let stateVersion = 4;
|
|
989
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
990
|
+
const definition = createPlanProjectionDefinition(stateVersion);
|
|
991
|
+
try {
|
|
992
|
+
return await handleOf(projections.register(definition), projections);
|
|
993
|
+
} catch (error) {
|
|
994
|
+
const reported = conflictingStateVersion(error);
|
|
995
|
+
if (reported === void 0 || reported === stateVersion) {
|
|
996
|
+
log(this.ctx, "warn", "[dsh-chatgpt-subscription] plan mode could not register the plan projection: " + (error instanceof Error ? error.message : String(error)));
|
|
997
|
+
return;
|
|
998
|
+
}
|
|
999
|
+
stateVersion = reported;
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
/**
|
|
1004
|
+
* 服务是否已在自己脚下被卸载。
|
|
1005
|
+
*
|
|
1006
|
+
* 登记册({@link registeredPlanControllerActive})用它把「已经不算数的控制器」
|
|
1007
|
+
* 摘出判定:卸载之后残留的登记不能让宿主继续按老状态提醒。
|
|
1008
|
+
* @returns true 表示已卸载。
|
|
1009
|
+
*/
|
|
1010
|
+
isDisposed() {
|
|
1011
|
+
return this.disposed;
|
|
1012
|
+
}
|
|
1013
|
+
/**
|
|
1014
|
+
* 把会话对象绑进进程级登记册,让**宿主根作用域**也能读到本控制器的判定。
|
|
1015
|
+
*
|
|
1016
|
+
* 挂载点不止一个(`installPlanModeCacheGuard` 在根作用域、本控制器可能在
|
|
1017
|
+
* `isolate: { planMode: true }` 的子 realm),而 `ctx.get()` 看不见子 realm。
|
|
1018
|
+
* 控制器每观察到一个会话就登记一次,登记册跨 realm 共享,于是两边读到同一份
|
|
1019
|
+
* 判定。已卸载就不再登记。
|
|
1020
|
+
* @param session - 会话对象。
|
|
1021
|
+
*/
|
|
1022
|
+
bindSession(session) {
|
|
1023
|
+
if (this.disposed) return;
|
|
1024
|
+
const key = objectKey(session);
|
|
1025
|
+
if (key === void 0) return;
|
|
1026
|
+
exitReminderRegistry().controllers.set(key, this);
|
|
1027
|
+
}
|
|
1028
|
+
/**
|
|
1029
|
+
* 已记录的计划状态。
|
|
1030
|
+
* @param session - 会话。
|
|
1031
|
+
* @returns 状态;读不到时是空状态(绝不抛错 —— 一个读失败不该打断整步)。
|
|
1032
|
+
*/
|
|
1033
|
+
planState(session) {
|
|
1034
|
+
this.bindSession(session);
|
|
1035
|
+
const handle = this.planProjectionHandle;
|
|
1036
|
+
if (handle !== void 0) try {
|
|
1037
|
+
const state = handle.stateOf(session);
|
|
1038
|
+
if (typeof state === "object" && state !== null && typeof state.active === "boolean") return state;
|
|
1039
|
+
} catch {}
|
|
1040
|
+
const key = objectKey(session);
|
|
1041
|
+
return key === void 0 ? EMPTY_PLAN_STATE : this.memoryStates.get(key) ?? EMPTY_PLAN_STATE;
|
|
1042
|
+
}
|
|
1043
|
+
/**
|
|
1044
|
+
* 已记录的计划模式开关。
|
|
1045
|
+
* @param session - 会话。
|
|
1046
|
+
* @returns 是否在计划模式。
|
|
1047
|
+
*/
|
|
1048
|
+
loggedActive(session) {
|
|
1049
|
+
return this.planState(session).active;
|
|
1050
|
+
}
|
|
1051
|
+
/**
|
|
1052
|
+
* 本步会呈现给模型的状态:未决选择优先,否则是已记录状态。
|
|
1053
|
+
* @param session - 会话。
|
|
1054
|
+
* @returns 是否生效。
|
|
1055
|
+
*/
|
|
1056
|
+
selectedActive(session) {
|
|
1057
|
+
const key = objectKey(session);
|
|
1058
|
+
return (key === void 0 ? void 0 : this.pendingIntents.get(key))?.active ?? this.loggedActive(session);
|
|
1059
|
+
}
|
|
1060
|
+
/**
|
|
1061
|
+
* 上一次告诉模型的模式;还没发过请求时 undefined。
|
|
1062
|
+
* @param session - 会话。
|
|
1063
|
+
* @returns true / false / undefined。
|
|
1064
|
+
*/
|
|
1065
|
+
loggedActiveAtLastHeader(session) {
|
|
1066
|
+
return this.planState(session).activeAtLastHeader ?? void 0;
|
|
1067
|
+
}
|
|
1068
|
+
/**
|
|
1069
|
+
* 会话是否处在打开的回合里(决定「立刻落日志」还是「等下一个被接受的 pre-step」)。
|
|
1070
|
+
*
|
|
1071
|
+
* 三级降级,与官方等价:agents.turnBoundaryOf(新代)> 投影里的 turnBoundary
|
|
1072
|
+
* 状态(旧代)> 判定为没有打开回合(无从判定时按回合结束处理,与官方的
|
|
1073
|
+
* 「回合之外立刻提交」一致)。
|
|
1074
|
+
* @param session - 会话。
|
|
1075
|
+
* @returns 是否有打开回合。
|
|
1076
|
+
*/
|
|
1077
|
+
hasOpenTurn(session) {
|
|
1078
|
+
const agents = serviceOf(this.ctx, "agents");
|
|
1079
|
+
if (typeof agents?.turnBoundaryOf === "function") try {
|
|
1080
|
+
const boundary = agents.turnBoundaryOf(session);
|
|
1081
|
+
if (boundary !== void 0) return boundary?.openTurnStartSeq !== null;
|
|
1082
|
+
} catch {}
|
|
1083
|
+
const projections = serviceOf(this.ctx, "sessionProjections");
|
|
1084
|
+
if (typeof projections?.stateOf === "function") try {
|
|
1085
|
+
const boundary = projections.stateOf(session, "turnBoundary");
|
|
1086
|
+
if (boundary !== void 0) return boundary?.openTurnStartSeq !== null;
|
|
1087
|
+
} catch {}
|
|
1088
|
+
return false;
|
|
1089
|
+
}
|
|
1090
|
+
/**
|
|
1091
|
+
* 读当前计划模式(已记录 + 未决选择),供调用方展示。
|
|
1092
|
+
* @param agent - 要读的 agent。
|
|
1093
|
+
* @returns 已记录状态,以及在果有未决选择时的目标。
|
|
1094
|
+
*/
|
|
1095
|
+
get(agent) {
|
|
1096
|
+
const session = agent?.session;
|
|
1097
|
+
const active = this.loggedActive(session);
|
|
1098
|
+
const key = objectKey(session);
|
|
1099
|
+
const pending = key === void 0 ? void 0 : this.pendingIntents.get(key);
|
|
1100
|
+
return pending === void 0 ? { active } : {
|
|
1101
|
+
active,
|
|
1102
|
+
pending: pending.active
|
|
1103
|
+
};
|
|
1104
|
+
}
|
|
1105
|
+
/**
|
|
1106
|
+
* 选择计划模式是否生效。
|
|
1107
|
+
*
|
|
1108
|
+
* 回合之外立刻落日志并补一句说明;回合之内留作未决选择,等下一个被接受的
|
|
1109
|
+
* pre-step(那时才写日志、才补说明)。重复选择当前值或已未决的同值是 no-op。
|
|
1110
|
+
* @param agent - 要切换的 agent。
|
|
1111
|
+
* @param active - 目标状态。
|
|
1112
|
+
* @param options - 可选:narrate=false 表示调用方的工具结果已经把话说完了。
|
|
1113
|
+
* @returns 发生了什么。
|
|
1114
|
+
*/
|
|
1115
|
+
set(agent, active, options = {}) {
|
|
1116
|
+
const session = agent?.session;
|
|
1117
|
+
const key = objectKey(session);
|
|
1118
|
+
if (key === void 0) return "noop";
|
|
1119
|
+
this.bindSession(session);
|
|
1120
|
+
const narrate = options.narrate !== false;
|
|
1121
|
+
if (active === (this.pendingIntents.get(key)?.active ?? this.loggedActive(session))) return "noop";
|
|
1122
|
+
if (this.hasOpenTurn(session)) {
|
|
1123
|
+
this.pendingIntents.set(key, {
|
|
1124
|
+
active,
|
|
1125
|
+
narrate
|
|
1126
|
+
});
|
|
1127
|
+
return this.loggedActive(session) === active ? "cancelled" : "queued";
|
|
1128
|
+
}
|
|
1129
|
+
if (active === this.loggedActive(session)) {
|
|
1130
|
+
this.pendingIntents.delete(key);
|
|
1131
|
+
return "cancelled";
|
|
1132
|
+
}
|
|
1133
|
+
this.appendMode(session, active);
|
|
1134
|
+
this.pendingIntents.delete(key);
|
|
1135
|
+
const narration = this.narration(session, active);
|
|
1136
|
+
if (narration !== void 0) this.injectMessage(agent, narration);
|
|
1137
|
+
return "committed";
|
|
1138
|
+
}
|
|
1139
|
+
/**
|
|
1140
|
+
* 把一个未决选择落进日志。
|
|
1141
|
+
* @param session - 会话。
|
|
1142
|
+
*/
|
|
1143
|
+
onBoundary(session) {
|
|
1144
|
+
const key = objectKey(session);
|
|
1145
|
+
if (key === void 0) return;
|
|
1146
|
+
const pending = this.pendingIntents.get(key);
|
|
1147
|
+
if (pending === void 0) return;
|
|
1148
|
+
const target = pending.active;
|
|
1149
|
+
if (target === this.loggedActive(session)) {
|
|
1150
|
+
this.pendingIntents.delete(key);
|
|
1151
|
+
return;
|
|
1152
|
+
}
|
|
1153
|
+
this.appendMode(session, target);
|
|
1154
|
+
this.pendingIntents.delete(key);
|
|
1155
|
+
}
|
|
1156
|
+
/**
|
|
1157
|
+
* 追加一条 plan/mode 事件,并同步内存兜底状态。
|
|
1158
|
+
*
|
|
1159
|
+
* 只在 append 成功后更新内存:写失败要留下可重试的选择,而不是一个假状态。
|
|
1160
|
+
* @param session - 会话。
|
|
1161
|
+
* @param active - 目标状态。
|
|
1162
|
+
*/
|
|
1163
|
+
appendMode(session, active) {
|
|
1164
|
+
const append = session?.append;
|
|
1165
|
+
if (typeof append === "function") append.call(session, PLAN_MODE_EVENT, { active });
|
|
1166
|
+
const key = objectKey(session);
|
|
1167
|
+
if (key === void 0) return;
|
|
1168
|
+
const current = this.memoryStates.get(key) ?? EMPTY_PLAN_STATE;
|
|
1169
|
+
this.memoryStates.set(key, {
|
|
1170
|
+
...current,
|
|
1171
|
+
active,
|
|
1172
|
+
wanted: null,
|
|
1173
|
+
entered: current.entered || active
|
|
1174
|
+
});
|
|
1175
|
+
}
|
|
1176
|
+
/**
|
|
1177
|
+
* 把一条尾部消息塞进当前会话。
|
|
1178
|
+
* @param agent - 调用方 agent。
|
|
1179
|
+
* @param message - 用户角色消息。
|
|
1180
|
+
*/
|
|
1181
|
+
injectMessage(agent, message) {
|
|
1182
|
+
const inject = agent?.inject;
|
|
1183
|
+
if (typeof inject === "function") {
|
|
1184
|
+
inject.call(agent, message);
|
|
1185
|
+
return;
|
|
1186
|
+
}
|
|
1187
|
+
const steer = agent?.steer;
|
|
1188
|
+
if (typeof steer === "function") steer.call(agent, message);
|
|
1189
|
+
}
|
|
1190
|
+
/**
|
|
1191
|
+
* 造一条「用户切换了模式」的尾部通知。
|
|
1192
|
+
*
|
|
1193
|
+
* 与官方的差别只有一处:进入时的通知**携带部署指引**(section)。官方把指引放在
|
|
1194
|
+
* 动态 system 段里,那正是前缀重建的来源;改成一次性尾部消息后,指引只付一次
|
|
1195
|
+
* 输入费,而且之后每一步都还在历史里(末尾追加不破坏前缀)。
|
|
1196
|
+
*
|
|
1197
|
+
* 触发条件也放宽了一点:官方只在「上一次请求头告诉过模型另一种模式」时才讲;
|
|
1198
|
+
* 这里在进入方向还接受 told === undefined(用户在第一发请求之前就 /plan)。
|
|
1199
|
+
* @param session - 会话。
|
|
1200
|
+
* @param target - 目标状态。
|
|
1201
|
+
* @returns 通知消息;不需要讲时 undefined。
|
|
1202
|
+
*/
|
|
1203
|
+
narration(session, target) {
|
|
1204
|
+
const told = this.loggedActiveAtLastHeader(session);
|
|
1205
|
+
if (target) {
|
|
1206
|
+
if (told === true) return void 0;
|
|
1207
|
+
return createPlanNotice("The user switched this session to plan mode.", this.section);
|
|
1208
|
+
}
|
|
1209
|
+
if (told !== true) return void 0;
|
|
1210
|
+
return createPlanNotice("The user switched this session back to the default mode.");
|
|
1211
|
+
}
|
|
1212
|
+
/**
|
|
1213
|
+
* 订阅 agent/pre-step:先 await next(),只对已经落定的决策动手。
|
|
1214
|
+
*
|
|
1215
|
+
* 三件事:把未决选择落进日志;在需要时把通知**追加到消息末尾**(末尾追加不改变
|
|
1216
|
+
* 已发前缀,这是这个 fork 的全部意义所在)。
|
|
1217
|
+
*/
|
|
1218
|
+
subscribePreStep() {
|
|
1219
|
+
const listener = async (payload, next) => {
|
|
1220
|
+
const decision = await next();
|
|
1221
|
+
const session = (payload?.agent)?.session;
|
|
1222
|
+
const key = objectKey(session);
|
|
1223
|
+
if (decision === void 0 || decision.kind === "reject" || key === void 0) return decision;
|
|
1224
|
+
if (payload?.signal?.aborted === true) return decision;
|
|
1225
|
+
if (!this.disposed) this.bindSession(session);
|
|
1226
|
+
const pending = this.pendingIntents.get(key);
|
|
1227
|
+
if (pending === void 0) return decision;
|
|
1228
|
+
const narration = this.narration(session, pending.active);
|
|
1229
|
+
try {
|
|
1230
|
+
this.onBoundary(session);
|
|
1231
|
+
} catch (error) {
|
|
1232
|
+
log(this.ctx, "warn", "[dsh-chatgpt-subscription] failed to append the selected plan mode at step start: " + (error instanceof Error ? error.message : String(error)));
|
|
1233
|
+
return decision;
|
|
1234
|
+
}
|
|
1235
|
+
if (pending.narrate === false || narration === void 0) return decision;
|
|
1236
|
+
const messages = Array.isArray(decision.messages) ? decision.messages : [];
|
|
1237
|
+
return {
|
|
1238
|
+
...decision,
|
|
1239
|
+
messages: [...messages, narration]
|
|
1240
|
+
};
|
|
1241
|
+
};
|
|
1242
|
+
subscribe(this.ctx, "agent/pre-step", listener);
|
|
1243
|
+
}
|
|
1244
|
+
/**
|
|
1245
|
+
* 注册 exit_plan_mode —— **不带 { when }**。
|
|
1246
|
+
*
|
|
1247
|
+
* 这是与官方唯一的工具面差别,也是 tools / tools:sdk 两边零重建的全部原因:
|
|
1248
|
+
* 条件准入会让工具表在进入那一步变大,PTC 的 run_code 绑定与 native 声明同时重发。
|
|
1249
|
+
* 无条件注册后,「只在计划模式可用」由 execute 里的 loggedActive 判定执行,报错
|
|
1250
|
+
* 文案与官方一致。
|
|
1251
|
+
*/
|
|
1252
|
+
registerExitTool() {
|
|
1253
|
+
const tools = serviceOf(this.ctx, "tools");
|
|
1254
|
+
if (tools === void 0 || typeof tools.register !== "function") {
|
|
1255
|
+
log(this.ctx, "info", "[dsh-chatgpt-subscription] plan mode has no tools service: exit_plan_mode was not registered, so the plan can only be left through /plan off");
|
|
1256
|
+
return;
|
|
1257
|
+
}
|
|
1258
|
+
try {
|
|
1259
|
+
tools.register(this.exitDefinition());
|
|
1260
|
+
} catch (error) {
|
|
1261
|
+
log(this.ctx, "warn", "[dsh-chatgpt-subscription] plan mode could not register exit_plan_mode: " + (error instanceof Error ? error.message : String(error)));
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
/**
|
|
1265
|
+
* 造 exit_plan_mode 的定义。
|
|
1266
|
+
* @returns 工具定义。
|
|
1267
|
+
*/
|
|
1268
|
+
exitDefinition() {
|
|
1269
|
+
const ctx = this.ctx;
|
|
1270
|
+
const controller = this;
|
|
1271
|
+
return defineTool({
|
|
1272
|
+
name: EXIT_PLAN_MODE,
|
|
1273
|
+
description: "Use only in plan mode. Present your plan for the user's review and, on approval, leave plan mode. The user may approve (carry out the plan from your next step) or keep planning — their feedback comes back in the tool result; revise and present again.",
|
|
1274
|
+
parameters: { plan: {
|
|
1275
|
+
type: "string",
|
|
1276
|
+
required: true,
|
|
1277
|
+
description: "The complete plan, as markdown, starting with a # heading that names it."
|
|
1278
|
+
} },
|
|
1279
|
+
output: {
|
|
1280
|
+
schema: {
|
|
1281
|
+
type: "object",
|
|
1282
|
+
additionalProperties: false,
|
|
1283
|
+
properties: { approved: {
|
|
1284
|
+
type: "boolean",
|
|
1285
|
+
required: true
|
|
1286
|
+
} }
|
|
1287
|
+
},
|
|
1288
|
+
render: (_args, value) => {
|
|
1289
|
+
return [{
|
|
1290
|
+
type: "text",
|
|
1291
|
+
text: value?.approved === true ? "Plan approved — plan mode exited; carry out the plan starting with your next step." : "The user dismissed the plan review to reply in their own words; plan mode remains active."
|
|
1292
|
+
}];
|
|
1293
|
+
}
|
|
1294
|
+
},
|
|
1295
|
+
async execute(args, exec) {
|
|
1296
|
+
const execution = exec;
|
|
1297
|
+
const agent = execution.agent;
|
|
1298
|
+
if (agent === void 0 || agent === null || !controller.loggedActive(agent.session)) throw new Error("exit_plan_mode is only available in plan mode");
|
|
1299
|
+
const plan = typeof args.plan === "string" ? args.plan : "";
|
|
1300
|
+
if (!/^#\s+\S/.test(plan.trim())) throw new Error("exit_plan_mode requires a non-empty markdown plan starting with a # heading");
|
|
1301
|
+
const interaction = serviceOf(ctx, "userQuestions");
|
|
1302
|
+
if (interaction === void 0 || typeof interaction.ask !== "function") throw new Error("no user-questions channel is available to review the plan; ask the user to switch the session mode instead");
|
|
1303
|
+
let answer;
|
|
1304
|
+
try {
|
|
1305
|
+
answer = await interaction.ask({
|
|
1306
|
+
questions: [{
|
|
1307
|
+
id: REVIEW_ID,
|
|
1308
|
+
header: "Plan review",
|
|
1309
|
+
question: "Approve this plan and leave plan mode?",
|
|
1310
|
+
detail: plan,
|
|
1311
|
+
options: [{
|
|
1312
|
+
label: APPROVE_LABEL,
|
|
1313
|
+
description: "Leave plan mode; the plan is carried out from the next step."
|
|
1314
|
+
}, {
|
|
1315
|
+
label: KEEP_PLANNING_LABEL,
|
|
1316
|
+
description: "Stay in plan mode; feedback goes back to the model."
|
|
1317
|
+
}],
|
|
1318
|
+
intent: {
|
|
1319
|
+
kind: "plan-review",
|
|
1320
|
+
approve: APPROVE_LABEL,
|
|
1321
|
+
callId: execution.callId
|
|
1322
|
+
}
|
|
1323
|
+
}],
|
|
1324
|
+
agent,
|
|
1325
|
+
signal: execution.signal
|
|
1326
|
+
});
|
|
1327
|
+
} catch (cause) {
|
|
1328
|
+
if (cause?.code === "ASK_CANCELLED") {
|
|
1329
|
+
if (typeof execution.concludeTurn === "function") {
|
|
1330
|
+
execution.concludeTurn();
|
|
1331
|
+
return { approved: false };
|
|
1332
|
+
}
|
|
1333
|
+
throw new Error("the user dismissed the plan review and this harness generation cannot conclude the turn from a tool; present the plan again or switch the session mode instead");
|
|
1334
|
+
}
|
|
1335
|
+
throw cause;
|
|
1336
|
+
}
|
|
1337
|
+
if (controller.disposed) throw new Error("the plan-mode service was reloaded while the plan was under review; present the plan again");
|
|
1338
|
+
const items = (answer?.answers ?? []).filter((entry) => entry.id === REVIEW_ID);
|
|
1339
|
+
const item = items.length === 1 ? items[0] : void 0;
|
|
1340
|
+
if (item?.selected.length !== 1 || item.selected[0] !== "Approve" || item.custom !== void 0) {
|
|
1341
|
+
const feedback = typeof item?.custom === "string" ? item.custom : "";
|
|
1342
|
+
throw new Error(feedback === "" ? "The user chose to keep planning; revise the plan and present it again." : "The user chose to keep planning; their feedback: " + feedback);
|
|
1343
|
+
}
|
|
1344
|
+
const key = objectKey(agent.session);
|
|
1345
|
+
if (key !== void 0) controller.pendingIntents.set(key, {
|
|
1346
|
+
active: false,
|
|
1347
|
+
narrate: false
|
|
1348
|
+
});
|
|
1349
|
+
return { approved: true };
|
|
1350
|
+
}
|
|
1351
|
+
});
|
|
1352
|
+
}
|
|
1353
|
+
/**
|
|
1354
|
+
* 注册 /plan 命令(手动进入/离开计划模式的入口)。
|
|
1355
|
+
*
|
|
1356
|
+
* 命令面板在部分宿主里不存在,所以这里用运行时注入:有 commands 才注册,
|
|
1357
|
+
* 没有就安静跳过 —— 缺一个可选服务不该让计划模式整体挂不上。
|
|
1358
|
+
*/
|
|
1359
|
+
registerPlanCommand() {
|
|
1360
|
+
const ctx = this.ctx;
|
|
1361
|
+
const inject = ctx.inject;
|
|
1362
|
+
if (typeof inject !== "function") return;
|
|
1363
|
+
try {
|
|
1364
|
+
inject.call(ctx, ["commands"], (scope) => {
|
|
1365
|
+
const commands = serviceOf(scope, "commands");
|
|
1366
|
+
if (commands === void 0 || typeof commands.register !== "function") return;
|
|
1367
|
+
try {
|
|
1368
|
+
commands.register({
|
|
1369
|
+
definitionId: name,
|
|
1370
|
+
name: "plan",
|
|
1371
|
+
description: "Enter or leave plan mode",
|
|
1372
|
+
input: {
|
|
1373
|
+
hint: "[off|message]",
|
|
1374
|
+
attachments: true
|
|
1375
|
+
},
|
|
1376
|
+
handler: (payload) => {
|
|
1377
|
+
const raw = typeof payload?.rawInput === "string" ? payload.rawInput.trim() : "";
|
|
1378
|
+
const attachments = Array.isArray(payload?.attachments) ? payload.attachments : [];
|
|
1379
|
+
if (raw === "off" && attachments.length > 0) return {
|
|
1380
|
+
kind: "error",
|
|
1381
|
+
text: "Attachments cannot accompany /plan off."
|
|
1382
|
+
};
|
|
1383
|
+
if (raw === "off") switch (this.set(payload.agent, false)) {
|
|
1384
|
+
case "committed": return {
|
|
1385
|
+
kind: "success",
|
|
1386
|
+
text: "Plan mode off."
|
|
1387
|
+
};
|
|
1388
|
+
case "queued": return {
|
|
1389
|
+
kind: "success",
|
|
1390
|
+
text: "Leaving plan mode (applies from the next step)."
|
|
1391
|
+
};
|
|
1392
|
+
case "cancelled": return {
|
|
1393
|
+
kind: "success",
|
|
1394
|
+
text: "Plan mode entry cancelled."
|
|
1395
|
+
};
|
|
1396
|
+
case "noop": return this.loggedActive(payload.agent?.session) ? {
|
|
1397
|
+
kind: "success",
|
|
1398
|
+
text: "Leaving plan mode (applies from the next step)."
|
|
1399
|
+
} : {
|
|
1400
|
+
kind: "success",
|
|
1401
|
+
text: "Plan mode is already inactive."
|
|
1402
|
+
};
|
|
1403
|
+
}
|
|
1404
|
+
const outcome = this.set(payload.agent, true);
|
|
1405
|
+
if (raw !== "" || attachments.length > 0) {
|
|
1406
|
+
const steer = payload.agent?.steer;
|
|
1407
|
+
if (typeof steer === "function") steer.call(payload.agent, createUserMessage({
|
|
1408
|
+
content: [...attachments, ...raw === "" ? [] : [{
|
|
1409
|
+
type: "text",
|
|
1410
|
+
text: raw
|
|
1411
|
+
}]],
|
|
1412
|
+
source: { kind: "user" }
|
|
1413
|
+
}));
|
|
1414
|
+
}
|
|
1415
|
+
return {
|
|
1416
|
+
kind: "success",
|
|
1417
|
+
text: outcome === "committed" ? "Plan mode on. Use /plan off to leave." : "Entering plan mode (applies from the next step). Use /plan off to leave."
|
|
1418
|
+
};
|
|
1419
|
+
}
|
|
1420
|
+
});
|
|
1421
|
+
} catch {}
|
|
1422
|
+
});
|
|
1423
|
+
} catch {}
|
|
1424
|
+
}
|
|
1425
|
+
};
|
|
1426
|
+
/**
|
|
1427
|
+
* 计划模式下「只用纯文本收尾」时投给模型的提醒正文。
|
|
1428
|
+
*
|
|
1429
|
+
* 冻结契约的一部分:只经 {@link createPlanNotice} 的尾部用户消息投递,**不进 system
|
|
1430
|
+
* 段、不动工具表**,所以它不会让任何前端前缀缓存重建。
|
|
1431
|
+
*/
|
|
1432
|
+
const PLAN_EXIT_REMINDER_TEXT = "Plan mode is still active, and the previous response ended in plain text without calling a tool. If the plan is ready, call exit_plan_mode now with the complete markdown plan (starting with a # heading) as the sole tool call. If an unsettled user decision blocks planning, call ask_user_question instead. If the user explicitly asked to abandon planning, call leave_plan_mode.";
|
|
1433
|
+
/** 登记册在 `globalThis` 上的键。跨模块实例共享,值本身只由本模块写入。 */
|
|
1434
|
+
const EXIT_REMINDER_KEY = Symbol.for("dsh-chatgpt-subscription.plan-mode.exit-reminder.claimed");
|
|
1435
|
+
/**
|
|
1436
|
+
* 取(或建)进程级登记册。
|
|
1437
|
+
*
|
|
1438
|
+
* 用 `Symbol.for` 而不是模块级常量:同一个进程里可能有两份本包实例(宿主插件与
|
|
1439
|
+
* preset 行分别加载),它们必须看到同一份 `claimed` / `states`。
|
|
1440
|
+
* @returns 登记册。
|
|
1441
|
+
*/
|
|
1442
|
+
function exitReminderRegistry() {
|
|
1443
|
+
const holder = globalThis;
|
|
1444
|
+
const existing = holder[EXIT_REMINDER_KEY];
|
|
1445
|
+
if (existing !== void 0 && existing.claimed instanceof WeakSet && existing.states instanceof WeakMap && existing.controllers instanceof WeakMap) return existing;
|
|
1446
|
+
const created = {
|
|
1447
|
+
claimed: /* @__PURE__ */ new WeakSet(),
|
|
1448
|
+
states: /* @__PURE__ */ new WeakMap(),
|
|
1449
|
+
controllers: /* @__PURE__ */ new WeakMap()
|
|
1450
|
+
};
|
|
1451
|
+
holder[EXIT_REMINDER_KEY] = created;
|
|
1452
|
+
return created;
|
|
1453
|
+
}
|
|
1454
|
+
/**
|
|
1455
|
+
* 用会话对象问本包的 {@link PlanModeController}:此刻算不算在计划模式。
|
|
1456
|
+
*
|
|
1457
|
+
* 与 `ctx.get('planMode')` 的区别是**看不见 realm 边界**:登记册挂在
|
|
1458
|
+
* `globalThis` 上,控制器往里面登记的是「这个会话由我负责」,所以宿主根作用域
|
|
1459
|
+
* 也能读到 preset 里 `isolate: { planMode: true }` 那个子 realm 的判定 —— 含未决
|
|
1460
|
+
* 选择(`exit_plan_mode` 刚排进 `{ active: false }`、日志还没落的那一刻)。
|
|
1461
|
+
*
|
|
1462
|
+
* 未登记(不是本包的控制器,比如内置 preset 挂的官方服务)、已卸载、或读法抛错
|
|
1463
|
+
* 时返回 undefined,由调用方自己退回投影。
|
|
1464
|
+
* @param session - 会话对象。
|
|
1465
|
+
* @returns true / false / undefined。
|
|
1466
|
+
*/
|
|
1467
|
+
function registeredPlanControllerActive(session) {
|
|
1468
|
+
const key = objectKey(session);
|
|
1469
|
+
if (key === void 0) return void 0;
|
|
1470
|
+
const controller = exitReminderRegistry().controllers.get(key);
|
|
1471
|
+
if (controller === void 0 || controller.isDisposed()) return void 0;
|
|
1472
|
+
try {
|
|
1473
|
+
const active = controller.selectedActive(session);
|
|
1474
|
+
return typeof active === "boolean" ? active : void 0;
|
|
1475
|
+
} catch {
|
|
1476
|
+
return;
|
|
1477
|
+
}
|
|
1478
|
+
}
|
|
1479
|
+
/**
|
|
1480
|
+
* 一个 agent 的提醒桶键:优先会话对象(同一会话换一个 agent 也还是同一桶),
|
|
1481
|
+
* 否则退回 agent 自己。
|
|
1482
|
+
* @param agent - 任意值。
|
|
1483
|
+
* @returns 桶键;两者都不是对象时 undefined。
|
|
1484
|
+
*/
|
|
1485
|
+
function reminderBucket(agent) {
|
|
1486
|
+
const session = agent?.session;
|
|
1487
|
+
return objectKey(session) ?? objectKey(agent);
|
|
1488
|
+
}
|
|
1489
|
+
/**
|
|
1490
|
+
* 取(或建)一个桶的提醒状态。
|
|
1491
|
+
* @param registry - 登记册。
|
|
1492
|
+
* @param bucket - 桶键。
|
|
1493
|
+
* @returns 状态(就地可变)。
|
|
1494
|
+
*/
|
|
1495
|
+
function reminderStateOf(registry, bucket) {
|
|
1496
|
+
let state = registry.states.get(bucket);
|
|
1497
|
+
if (state === void 0) {
|
|
1498
|
+
state = {
|
|
1499
|
+
consecutive: 0,
|
|
1500
|
+
pending: false
|
|
1501
|
+
};
|
|
1502
|
+
registry.states.set(bucket, state);
|
|
1503
|
+
}
|
|
1504
|
+
return state;
|
|
1505
|
+
}
|
|
1506
|
+
/**
|
|
1507
|
+
* 读宿主的 Agent 登记表,两条路线都试(`ctx.get('agents')` 与结构化属性)。
|
|
1508
|
+
* @param ctx - 宿主上下文。
|
|
1509
|
+
* @returns 登记表;读不到时 undefined。
|
|
1510
|
+
*/
|
|
1511
|
+
function agentRegistryOf(ctx) {
|
|
1512
|
+
const viaGetter = serviceOf(ctx, "agents");
|
|
1513
|
+
if (typeof viaGetter?.get === "function") return viaGetter;
|
|
1514
|
+
let direct;
|
|
1515
|
+
try {
|
|
1516
|
+
direct = ctx?.agents;
|
|
1517
|
+
} catch {
|
|
1518
|
+
return;
|
|
1519
|
+
}
|
|
1520
|
+
return typeof direct?.get === "function" ? direct : void 0;
|
|
1521
|
+
}
|
|
1522
|
+
/**
|
|
1523
|
+
* 这个块是不是一段可见文本。
|
|
1524
|
+
*
|
|
1525
|
+
* 空白一律 trim:只有空白的文本等于没说话,那种回合归 `no-answer-resume` 管,不该
|
|
1526
|
+
* 在这里被当成「讲完了话」。
|
|
1527
|
+
* @param chunk - 候选块。
|
|
1528
|
+
* @returns 是可见文本时为 true。
|
|
1529
|
+
*/
|
|
1530
|
+
function isVisibleTextChunk(chunk) {
|
|
1531
|
+
return chunk?.type === "text-delta" && typeof chunk.text === "string" && chunk.text.trim() !== "";
|
|
1532
|
+
}
|
|
1533
|
+
/**
|
|
1534
|
+
* 这个块是不是工具调用的开始。
|
|
1535
|
+
* @param chunk - 候选块。
|
|
1536
|
+
* @returns 是工具调用时为 true。
|
|
1537
|
+
*/
|
|
1538
|
+
function isToolCallChunk(chunk) {
|
|
1539
|
+
if (chunk?.type === "tool-call-delta") return true;
|
|
1540
|
+
return chunk?.type === "block-start" && chunk.blockType === "tool-call";
|
|
1541
|
+
}
|
|
1542
|
+
/**
|
|
1543
|
+
* 这个块是不是「模型自己讲完了」的终结块。
|
|
1544
|
+
* @param chunk - 候选块。
|
|
1545
|
+
* @returns `finish` 且原因为 `stop` 时为 true。
|
|
1546
|
+
*/
|
|
1547
|
+
function isStopFinish(chunk) {
|
|
1548
|
+
return chunk?.type === "finish" && chunk.reason?.kind === "stop";
|
|
1549
|
+
}
|
|
1550
|
+
/**
|
|
1551
|
+
* 把一条订阅句柄解掉(cordis 两代形状:函数或带 dispose 的对象)。
|
|
1552
|
+
* @param handle - `subscribe` 的返回值。
|
|
1553
|
+
*/
|
|
1554
|
+
function releaseSubscription(handle) {
|
|
1555
|
+
if (typeof handle === "function") {
|
|
1556
|
+
handle();
|
|
1557
|
+
return;
|
|
1558
|
+
}
|
|
1559
|
+
const disposable = handle;
|
|
1560
|
+
try {
|
|
1561
|
+
disposable?.dispose?.();
|
|
1562
|
+
} catch {}
|
|
1563
|
+
}
|
|
1564
|
+
/**
|
|
1565
|
+
* 装一台「计划模式回合结束提醒」。
|
|
1566
|
+
*
|
|
1567
|
+
* ## 它解决什么
|
|
1568
|
+
*
|
|
1569
|
+
* 计划模式里模型讲完一段话就收尾(`finish: stop`、零工具调用)时,宿主不会替它做
|
|
1570
|
+
* 任何事:那一回合就停在那里,直到人去催「确认呢」。这里在 `llm/stream` 上观察一
|
|
1571
|
+
* 次已完成、只有文本、没有工具调用的对话请求,然后在 agent 空闲之后补一条尾部通知,
|
|
1572
|
+
* 让模型自己选三条出路(`exit_plan_mode` / `ask_user_question` / `leave_plan_mode`)。
|
|
1573
|
+
*
|
|
1574
|
+
* ## 为什么零缓存漂移
|
|
1575
|
+
*
|
|
1576
|
+
* 一条 `agent.steer(createPlanNotice(...))` 的尾部用户消息:不注册 system 段、不动
|
|
1577
|
+
* `tools.definitions`、不改任何工具 schema。末尾追加不破坏已发前缀。
|
|
1578
|
+
*
|
|
1579
|
+
* ## 为什么不会重复提醒
|
|
1580
|
+
*
|
|
1581
|
+
* 同一进程里两个挂载点共享 {@link PlanModeExitReminderRegistry}:`claimed` 让一条
|
|
1582
|
+
* `llm/stream` 只被一条监听器接手(另一条原样透传),`states` 让两处读同一个计数。
|
|
1583
|
+
*
|
|
1584
|
+
* @param ctx - 宿主上下文(结构化:老代 ctx 也认)。
|
|
1585
|
+
* @param options - 见 {@link PlanModeExitReminderOptions}。
|
|
1586
|
+
* @returns 摘掉全部订阅的 disposer;这一代没有事件总线、或一条都没订上时 undefined。
|
|
1587
|
+
*/
|
|
1588
|
+
function installPlanModeExitReminder(ctx, options = {}) {
|
|
1589
|
+
if (typeof ctx?.on !== "function") return void 0;
|
|
1590
|
+
const registry = exitReminderRegistry();
|
|
1591
|
+
let closed = false;
|
|
1592
|
+
/**
|
|
1593
|
+
* 读「此刻还在计划模式吗」。读法抛错等于读不出来。
|
|
1594
|
+
* @param agent - 调用方 agent。
|
|
1595
|
+
* @returns true / false / undefined。
|
|
1596
|
+
*/
|
|
1597
|
+
const activeOf = (agent) => {
|
|
1598
|
+
if (typeof options.isActive !== "function") return void 0;
|
|
1599
|
+
try {
|
|
1600
|
+
return options.isActive(agent);
|
|
1601
|
+
} catch {
|
|
1602
|
+
return;
|
|
1603
|
+
}
|
|
1604
|
+
};
|
|
1605
|
+
/**
|
|
1606
|
+
* 用会话 id 反查活着的 agent。每次现读:这个插件可能比 agents 服务先加载。
|
|
1607
|
+
* @param sessionId - 请求上的会话 id。
|
|
1608
|
+
* @returns agent;读不到时 undefined。
|
|
1609
|
+
*/
|
|
1610
|
+
const resolveAgent = (sessionId) => {
|
|
1611
|
+
if (typeof sessionId !== "string") return void 0;
|
|
1612
|
+
let resolved;
|
|
1613
|
+
try {
|
|
1614
|
+
resolved = agentRegistryOf(ctx)?.get(sessionId);
|
|
1615
|
+
} catch {
|
|
1616
|
+
return;
|
|
1617
|
+
}
|
|
1618
|
+
return objectKey(resolved) === void 0 ? void 0 : resolved;
|
|
1619
|
+
};
|
|
1620
|
+
/**
|
|
1621
|
+
* 空闲之后真正投递。排队期间插件可能已经卸载、会话可能已经关掉、计划模式可能
|
|
1622
|
+
* 已经退出、子代理可能还在跑 —— 这些都要在**投递那一刻**重新判断。
|
|
1623
|
+
* @param state - 本会话的提醒状态。
|
|
1624
|
+
* @param agent - 调用方 agent。
|
|
1625
|
+
*/
|
|
1626
|
+
const fire = (state, agent) => {
|
|
1627
|
+
state.pending = false;
|
|
1628
|
+
if (closed) return;
|
|
1629
|
+
if (isDelegatedCaller(agent, ctx)) return;
|
|
1630
|
+
const active = activeOf(agent);
|
|
1631
|
+
if (active !== true) {
|
|
1632
|
+
if (active === false) state.consecutive = 0;
|
|
1633
|
+
return;
|
|
1634
|
+
}
|
|
1635
|
+
if (hasRunningChildSubagent(agent, ctx)) {
|
|
1636
|
+
state.consecutive = 0;
|
|
1637
|
+
return;
|
|
1638
|
+
}
|
|
1639
|
+
if (state.consecutive >= 1) return;
|
|
1640
|
+
state.consecutive += 1;
|
|
1641
|
+
const message = createPlanNotice(PLAN_EXIT_REMINDER_TEXT, void 0, "Plan mode exit reminder");
|
|
1642
|
+
try {
|
|
1643
|
+
if (typeof agent.steer === "function") agent.steer(message);
|
|
1644
|
+
else if (typeof agent.inject === "function") agent.inject(message);
|
|
1645
|
+
} catch (error) {
|
|
1646
|
+
log(ctx, "warn", "[dsh-chatgpt-subscription] could not queue the plan-mode exit reminder: " + (error instanceof Error ? error.message : String(error)));
|
|
1647
|
+
}
|
|
1648
|
+
};
|
|
1649
|
+
/**
|
|
1650
|
+
* 等 agent 空闲,再投递。
|
|
1651
|
+
*
|
|
1652
|
+
* `whenIdle` 是整件事的前提:回合还在跑的时候 `steer` 只会被塞进收件箱而没有
|
|
1653
|
+
* 唤醒跟着,那条消息永远不会被读到。这一代没有 `whenIdle` 时退成微任务,投递
|
|
1654
|
+
* 仍会发生,只是取件时机由宿主决定。
|
|
1655
|
+
* @param state - 本会话的提醒状态。
|
|
1656
|
+
* @param agent - 调用方 agent。
|
|
1657
|
+
*/
|
|
1658
|
+
const queue = (state, agent) => {
|
|
1659
|
+
const whenIdle = agent.whenIdle;
|
|
1660
|
+
let idle;
|
|
1661
|
+
if (typeof whenIdle === "function") try {
|
|
1662
|
+
idle = whenIdle.call(agent);
|
|
1663
|
+
} catch {
|
|
1664
|
+
idle = void 0;
|
|
1665
|
+
}
|
|
1666
|
+
if (idle === void 0 || idle === null) {
|
|
1667
|
+
queueMicrotask(() => fire(state, agent));
|
|
1668
|
+
return;
|
|
1669
|
+
}
|
|
1670
|
+
Promise.resolve(idle).then(() => fire(state, agent), () => fire(state, agent));
|
|
1671
|
+
};
|
|
1672
|
+
/**
|
|
1673
|
+
* 一次已完成的对话流落定之后做什么。
|
|
1674
|
+
* @param request - `llm/stream` 的请求对象。
|
|
1675
|
+
* @param observed - 这次流里观察到的事实。
|
|
1676
|
+
*/
|
|
1677
|
+
const settleTurn = (request, observed) => {
|
|
1678
|
+
const call = request;
|
|
1679
|
+
const agent = resolveAgent(call?.sessionId);
|
|
1680
|
+
const bucket = reminderBucket(agent);
|
|
1681
|
+
if (observed.toolCall) {
|
|
1682
|
+
if (bucket !== void 0) reminderStateOf(registry, bucket).consecutive = 0;
|
|
1683
|
+
return;
|
|
1684
|
+
}
|
|
1685
|
+
if (call?.purpose !== void 0) return;
|
|
1686
|
+
if (!observed.visibleText || !observed.stopped) return;
|
|
1687
|
+
if (agent === void 0 || bucket === void 0) return;
|
|
1688
|
+
if (typeof agent.steer !== "function" && typeof agent.inject !== "function") return;
|
|
1689
|
+
if (closed) return;
|
|
1690
|
+
const state = reminderStateOf(registry, bucket);
|
|
1691
|
+
if (state.pending || state.consecutive >= 1) return;
|
|
1692
|
+
if (activeOf(agent) === false) {
|
|
1693
|
+
state.consecutive = 0;
|
|
1694
|
+
return;
|
|
1695
|
+
}
|
|
1696
|
+
state.pending = true;
|
|
1697
|
+
queue(state, agent);
|
|
1698
|
+
};
|
|
1699
|
+
/**
|
|
1700
|
+
* 包住下游流:原样透传每个块,只记下判定需要的三件事。
|
|
1701
|
+
*
|
|
1702
|
+
* `finally` 在每条退出路径上都会跑,而其中两条什么都没证明:消费者中途放弃
|
|
1703
|
+
* (用户按了停止),或下游抛错。两者都没有交付终结块,所以都不落定。
|
|
1704
|
+
* @param request - `llm/stream` 的请求对象。
|
|
1705
|
+
* @param next - 下一棒。
|
|
1706
|
+
* @returns 透传流。
|
|
1707
|
+
*/
|
|
1708
|
+
const watch = (request, next) => {
|
|
1709
|
+
return (async function* () {
|
|
1710
|
+
let visibleText = false;
|
|
1711
|
+
let toolCall = false;
|
|
1712
|
+
let stopped = false;
|
|
1713
|
+
let completed = false;
|
|
1714
|
+
try {
|
|
1715
|
+
for await (const chunk of next()) {
|
|
1716
|
+
const seen = chunk;
|
|
1717
|
+
if (isVisibleTextChunk(seen)) visibleText = true;
|
|
1718
|
+
else if (isToolCallChunk(seen)) toolCall = true;
|
|
1719
|
+
else if (isStopFinish(seen)) stopped = true;
|
|
1720
|
+
yield chunk;
|
|
1721
|
+
}
|
|
1722
|
+
completed = true;
|
|
1723
|
+
} finally {
|
|
1724
|
+
if (completed) settleTurn(request, {
|
|
1725
|
+
visibleText,
|
|
1726
|
+
toolCall,
|
|
1727
|
+
stopped
|
|
1728
|
+
});
|
|
1729
|
+
}
|
|
1730
|
+
})();
|
|
1731
|
+
};
|
|
1732
|
+
/**
|
|
1733
|
+
* `llm/stream` 的监听器:接手一条流,或原样透传。
|
|
1734
|
+
* @param request - 请求对象。
|
|
1735
|
+
* @param next - 下一棒。
|
|
1736
|
+
* @returns 流。
|
|
1737
|
+
*/
|
|
1738
|
+
const onStream = (request, next) => {
|
|
1739
|
+
const claim = objectKey(request);
|
|
1740
|
+
if (claim !== void 0) {
|
|
1741
|
+
if (registry.claimed.has(claim)) return next();
|
|
1742
|
+
registry.claimed.add(claim);
|
|
1743
|
+
}
|
|
1744
|
+
return watch(request, next);
|
|
1745
|
+
};
|
|
1746
|
+
/**
|
|
1747
|
+
* 会话关掉时丢掉它的计数:连续收尾的账属于那个会话,不该活过它。
|
|
1748
|
+
* @param payload - `agent/disposed` 的载荷。
|
|
1749
|
+
*/
|
|
1750
|
+
const onAgentDisposed = (payload) => {
|
|
1751
|
+
const bucket = reminderBucket(payload?.agent);
|
|
1752
|
+
if (bucket === void 0) return;
|
|
1753
|
+
registry.states.delete(bucket);
|
|
1754
|
+
};
|
|
1755
|
+
const offStream = subscribe(ctx, "llm/stream", onStream, { global: true });
|
|
1756
|
+
const offDisposed = subscribe(ctx, "agent/disposed", onAgentDisposed);
|
|
1757
|
+
if (offStream === void 0 && offDisposed === void 0) return void 0;
|
|
1758
|
+
return () => {
|
|
1759
|
+
closed = true;
|
|
1760
|
+
releaseSubscription(offStream);
|
|
1761
|
+
releaseSubscription(offDisposed);
|
|
1762
|
+
};
|
|
1763
|
+
}
|
|
1764
|
+
/**
|
|
1765
|
+
* 造一条计划模式尾部通知。
|
|
1766
|
+
*
|
|
1767
|
+
* 用 createUserMessage 而不是手写消息字面量:DSH 对每条被接纳的消息都校验 id 与
|
|
1768
|
+
* source,手写字面量会在第一次 steer 时同步抛错(见 SKILL 的陷阱清单)。
|
|
1769
|
+
* @param text - 通知正文;指引会接在它后面。
|
|
1770
|
+
* @param section - 计划模式指引正文,接在通知之后。
|
|
1771
|
+
* @returns 用户角色消息。
|
|
1772
|
+
*/
|
|
1773
|
+
function createPlanNotice(text, section, summary) {
|
|
1774
|
+
return createUserMessage({
|
|
1775
|
+
content: [{
|
|
1776
|
+
type: "text",
|
|
1777
|
+
text: section === void 0 || section.trim() === "" ? text : text + "\n\n" + section
|
|
1778
|
+
}],
|
|
1779
|
+
source: {
|
|
1780
|
+
kind: PLAN_NOTICE_SOURCE_KIND,
|
|
1781
|
+
form: "notice",
|
|
1782
|
+
summary: summary ?? text
|
|
1783
|
+
}
|
|
1784
|
+
});
|
|
1785
|
+
}
|
|
1786
|
+
//#endregion
|
|
1787
|
+
export { planProjectionDefinition as _, PLAN_EXIT_REMINDER_TEXT as a, installDispatchExecutionGuard as b, PLAN_POLICY_SECTION as c, PlanModeController as d, REVIEW_ID as f, name as g, installPlanModeExitReminder as h, LEGACY_PLAN_STATE_VERSION as i, PLAN_PROJECTION_KEY as l, createPlanProjectionDefinition as m, EXIT_PLAN_MODE as n, PLAN_MODE_EVENT as o, createPlanNotice as p, KEEP_PLANNING_LABEL as r, PLAN_NOTICE_SOURCE_KIND as s, APPROVE_LABEL as t, PLAN_STATE_VERSION as u, registeredPlanControllerActive as v, resolveDispatchExecutionGuardEnabled as x, resolveConfig as y };
|