@leonhardthust/dsh-decision-engine 1.10.0 → 1.11.1

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.
@@ -257,7 +257,8 @@ pointAuthorize: auto
257
257
 
258
258
  # ---- 2. 模型路由:机械步骤下沉到便宜模型 -----------------------------------
259
259
  # 热路径。三道闸控量:每轮最多 routeMaxStepsPerTurn 步、路由未变不问、
260
- # 新用户输入后的步骤不问。
260
+ # 最近一条是用户消息就不问(**含每轮第一步**——新任务开局没有「下一步是
261
+ # 机械活」的证据,不该一上来就下沉)。
261
262
  # 必须同时配好 routeCheapProvider + routeCheapModel 才会真的换模型;
262
263
  # 两者留空时即便打开此点也只会记录「本该换」。
263
264
  pointRoute: off
@@ -269,7 +270,8 @@ routeCheapModel: ""
269
270
  # routeRiskFloor: 0.3 # 风险达线一律保留强模型
270
271
 
271
272
  # ---- 3. 子任务分派:接着做 / 开 subagent / 组 agent team -------------------
272
- # 热路径。只在每轮第一步且带新用户输入时判断(分派只在这一刻可能改变)。
273
+ # 热路径。只在每轮第一步、且本步带**真正来自用户**的消息时判断。插件自己
274
+ # 注入的 [decision-engine-*] 提示不算用户任务(否则会对着自己的建议再判一次)。
273
275
  # 生效时注入一条提示,不替模型决定——工具选择仍归模型。
274
276
  pointDelegate: off
275
277
  # delegateConfidenceFloor: 0.5 # 三选项均分 0.33,实测需 ≥0.5 才算有倾向
@@ -278,18 +280,23 @@ pointDelegate: off
278
280
 
279
281
  # ---- 4. 上下文压缩:摘要 vs 只丢陈旧工具结果 -------------------------------
280
282
  # 非热路径。只在实测 token 达触发线(compactThresholdRatio × 窗口)才问。
281
- # 生效方式:先跑宿主的确定性工具结果裁剪;compaction-basic 随后重新测压,
282
- # 低于阈值就跳过摘要——这才是真正省下的那一次调用。
283
+ # 默认**不会**只裁剪:state 里只有「有几条超长结果」,看不到它们是什么、
284
+ # 哪些结论真的保住了,而裁剪不可逆。默认一律交给宿主的摘要压缩;只有
285
+ # compactAllowPrune: true 才允许「只丢陈旧工具结果、跳过摘要」。
283
286
  pointCompact: off
284
287
  # compactContextWindow: 0 # 0 = 用 compactTriggerTokens 绝对阈值
285
288
  # compactThresholdRatio: 0.7 # 提前于 compaction-basic 的 0.8 介入
286
289
  # compactTriggerTokens: 100000
287
290
  # compactToolResultChars: 8192
288
- # compactDistilledFloor: 0.6 # 结论已落地 → 允许只裁剪
291
+ # compactDistilledFloor: 0.6 # 结论已落地 → 才考虑只裁剪(仍需 compactAllowPrune)
289
292
  # compactVerbatinFloor: 0.6 # 仍需逐字原文 → 一律摘要(安全否决优先)
293
+ # compactAllowPrune: false # 运维旋钮:允许不可逆裁剪,默认关;不在设置页
290
294
 
291
295
  # ---- 5. 轮次延续:再来一轮有没有增量价值 -----------------------------------
292
296
  # 非热路径。生效方式是用 agent.steer() 续一轮,每轮最多 turnStopMaxSteers 次。
297
+ # 判断依据包含:用户最初的要求、最近一条助手文本、**真实的未完成工具调用数**、
298
+ # 以及「是否在等用户」。最后一项缺席(旧端点不认识)时**保守收束不续轮**——
299
+ # 边界未知时续轮等于替用户把选择顶掉。
293
300
  pointTurnStop: off
294
301
  # turnStopMaxSteers: 1
295
302
  # turnStopClosedFloor: 0.6
@@ -303,6 +310,9 @@ pointTurnStop: off
303
310
 
304
311
  # ---- 6. 检索必要性:这条信息是不是已经知道 ---------------------------------
305
312
  # 非热路径。只对检索类工具生效,其余工具零开销。
313
+ # 确定性保护(不问模型,直接放行):web_fetch、查询里带 URL、以及命中
314
+ # 时效性关键词(最新/今天/当前/版本/发布/价格/新闻/实时/latest/today/…)。
315
+ # 模型只用于「明显的重复检索」,不会单独拦下明确要求的实时查询。
306
316
  pointRetrieval: off
307
317
  # retrievalTools: [web_search, web_fetch, research, multi_search, advanced_search]
308
318
  # retrievalCoveredFloor: 0.5 # 上下文已覆盖该事实
@@ -311,13 +321,16 @@ pointRetrieval: off
311
321
  # ---- 7. 技能选择:该装哪个技能 ---------------------------------------------
312
322
  # 非热路径。只对 skill 工具、且候选 ≥2 时判断。
313
323
  # 生效时拦下不匹配的加载并指路,避免「装错再换」付两遍正文 token。
324
+ # 模型建议的技能名必须属于候选集,否则视为无效建议直接放行;当前请求的
325
+ # 技能即使排在候选上限之外也会被保留。
314
326
  pointSkillPick: off
315
327
  # skillPickCandidates: 8 # 送给模型挑选的候选上限(只带名称+描述)
316
328
  # skillPickConfidenceFloor: 0.5
317
329
 
318
330
  # ---- 8. 思维链翻译门槛:这条内容真的需要翻译吗 -----------------------------
319
- # 热路径(每条消息)。本地先做免费预筛(去代码块/URL 后判断有无正文),
320
- # 预筛不过的直接跳过,不发请求。生效时判为「不需要」则返回原文。
331
+ # 热路径(每条消息)。本地先做免费预筛(去代码块/URL 后判断有无正文)。
332
+ # 只有 **enforce** 时预筛才会真的跳过;observe 模式下预筛只记录「本该跳过」,
333
+ # 原文照常交给翻译器——观察模式绝不改变行为。
321
334
  pointCotTranslate: off
322
335
  # cotProseFloor: 0.5
323
336
  # cotSameLanguageFloor: 0.8
package/docs/releasing.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # npm 发布
2
2
 
3
- 发布目标:`@leonhardthust/dsh-decision-engine`,当前版本 `1.10.0`,公开包。
3
+ 发布目标:`@leonhardthust/dsh-decision-engine`,当前版本 `1.11.1`,公开包。
4
4
 
5
5
  本分支基于 `zuosus/dsh-decision-engine` 的 MIT 代码,保留原作者与许可证;npm scope、源码仓库、bundle 引用和浏览器主模块 / charts chunk 标识已同步为本分支身份。内部 API、样式标识和用户数据目录保留旧名称以兼容已有配置。不要同时启用原包与本 scoped fork,它们提供相同的服务与页面。
6
6
 
@@ -9,7 +9,7 @@
9
9
  - `.github/workflows/ci.yml`:main push、PR、手动运行或发布工作流复用;Ubuntu 24.04 + Node.js 24 + Vite+ 1.6.0。执行 frozen install、完整测试与接线、隔离情景、实际打包和 tarball 检查。失败不会被忽略。
10
10
  - `.github/workflows/publish.yml`:每次 `main` push 自动检测版本。先比较整个 push 的 `before` 与当前 manifest,拒绝错误包名、非法或倒退版本;再只读查询 npmjs。当前版本已上传则跳过;当前版本高于 npmjs latest 且未上传则进入完整验证及发布。多提交 push 比较整个 push 前后,不仅比较 HEAD 的父提交。
11
11
  - 后续发版只需修改 `package.json` 版本并提交到 main;无需创建 tag 或手动点运行。发布依赖检测和本次提交的完整 CI 均成功,不跳过测试。稳定版本发布到 `latest`,预发布版本自动进入 `next`。
12
- - 当前 `1.10.0` 包含:安全档位改名与事实修正(不问/少问/默认/多问)、`think_mode` 作用域澄清与 provider 能力位、决策日志按日期/会话/工作区分文件与保留清理、裁决列短词与结果着色、本地未知副作用不再单独触发回退模型。设计记录见 `DECISIONS.md` 的 DEC-062 ~ DEC-066。
12
+ - 当前 `1.11.1` 包含 DEC-070:空闲但仍可接续的同一棵树的子代理不再被误判为「接收方权限未核验」,普通队友消息不再升级成人审/拒绝。`1.11.0` 的第二轮审计修复见 DEC-067 ~ DEC-069,`1.10.0` 的五项改动见 DEC-062 ~ DEC-066。
13
13
  - checkout 与 setup-vp Action 固定到已核对的 commit SHA;不通过 `pull_request_target` 运行不可信 PR。
14
14
  - 验证任务只有 `contents: read`;只有发布 job 有 `id-token: write`。发布 step 只使用 OIDC(trusted publishing),不再读取任何 npm token secret。代码 checkout 不保留 Git 凭据。此工作流不会自动修改版本、提交源码或推送 Git。
15
15
 
@@ -58,7 +58,7 @@ Windows 全量测试最近仍有 3 个既有 POSIX 文件权限断言失败;
58
58
  授权及完整验证均满足后:
59
59
 
60
60
  ```powershell
61
- npm publish ./release-artifacts/leonhardthust-dsh-decision-engine-1.10.0.tgz --registry "https://registry.npmjs.org/" --access public --tag latest
61
+ npm publish ./release-artifacts/leonhardthust-dsh-decision-engine-1.11.1.tgz --registry "https://registry.npmjs.org/" --access public --tag latest
62
62
  npm view @leonhardthust/dsh-decision-engine version --registry "https://registry.npmjs.org/" --json
63
63
  ```
64
64
 
@@ -66,4 +66,4 @@ npm view @leonhardthust/dsh-decision-engine version --registry "https://registry
66
66
 
67
67
  ## 安装
68
68
 
69
- DSH 插件管理中使用 `@leonhardthust/dsh-decision-engine` 或指定 `@leonhardthust/dsh-decision-engine@1.10.0`。安装和启用是另外的用户动作,发布工作流不会修改本机 DSH profile。
69
+ DSH 插件管理中使用 `@leonhardthust/dsh-decision-engine` 或指定 `@leonhardthust/dsh-decision-engine@1.11.1`。安装和启用是另外的用户动作,发布工作流不会修改本机 DSH profile。
@@ -86,19 +86,21 @@ export function mountAgentPoints(ctx, deps) {
86
86
  // ---- 子任务分派 ------------------------------------------------
87
87
  // 只在「每轮第一步 + 这一步确实带着新的用户输入」时判断:
88
88
  // 分派决策只在这个时刻可能改变,后续步骤再问必然是 inline。
89
- if (runner.participates("delegate") && payload.step === 1 && payload.messages.length > 0) {
90
- const incoming = payload.messages
91
- .map((message) => textOf(message))
92
- .filter((text) => text.length > 0)
93
- .join("\n")
94
- .slice(0, 1200);
95
- if (incoming.length > 0) {
89
+ //
90
+ // 只认**用户来源**的消息。修之前这里把 `payload.messages` 全部拼接:
91
+ // 插件自己注入的 `[decision-engine-*]` 提示也是 `role: 'user'`,于是
92
+ // 「插件建议开 subagent」会被当成一条新的用户任务,让分派判断对着
93
+ // 自己的建议再判一次——模型很容易把建议读成用户要求,滚成自我确认。
94
+ if (runner.participates("delegate") && payload.step === 1 && Array.isArray(payload.messages)) {
95
+ const userInput = collectUserInput(payload.messages);
96
+ if (userInput !== undefined) {
96
97
  const result = await runner.ask(
97
98
  "delegate",
98
99
  {
99
100
  turn: payload.turn,
100
101
  step: payload.step,
101
- incoming,
102
+ origin: userInput.origin,
103
+ incoming: userInput.incoming,
102
104
  recent: digestRecent(agent.session, cfg.recentMessages, cfg.recentMessageChars),
103
105
  },
104
106
  // session / turn / step 只给「决策历史」明细用,不参与裁决。
@@ -151,8 +153,14 @@ export function mountAgentPoints(ctx, deps) {
151
153
  "turnStop",
152
154
  {
153
155
  turn: payload.turn,
156
+ // 用户最初要什么。修之前 state 里没有它,模型只能从最后一句助手
157
+ // 文本反推「还有没有活没干完」——没有对照物,判断退化成猜测。
158
+ goal: firstUserText(agent.session, cfg.recentMessageChars * 4),
154
159
  last,
155
- pendingTools: 0,
160
+ // 真实未完成计数。修之前这里是硬编码的 0:模型永远看到「没有
161
+ // 未完成的工具调用」,卡在工具执行上的轮次与正常收束的轮次在
162
+ // 证据上一模一样(详见 countPendingToolCalls)。
163
+ pendingTools: countPendingToolCalls(agent.session),
156
164
  steered: steersThisTurn(agent.session, payload.turn),
157
165
  },
158
166
  // session / turn 只给「决策历史」明细用,不参与裁决。
@@ -195,6 +203,156 @@ function textOf(message) {
195
203
  .join(" ");
196
204
  }
197
205
 
206
+ /**
207
+ * 插件自己注入的合成消息统一带这个文本前缀。
208
+ *
209
+ * 只用于**老宿主**兜底(那时消息没有 `source`):新宿主上 `source.kind`
210
+ * 已经能确定性区分,不需要猜文本。
211
+ */
212
+ const SYNTHETIC_PREFIX = "[decision-engine-";
213
+
214
+ /**
215
+ * 判断一条消息是不是用户亲手输入的。
216
+ *
217
+ * 为什么需要它:`role: 'user'` 不代表「用户说的」——插件通过 `agent.steer()`
218
+ * 注入的提示、宿主注入的运行时上下文都是 role=user。把它们当成新任务,
219
+ * 会让分派判断对着自己的建议做决策(见 pre-step 里的注释)。
220
+ *
221
+ * 判定顺序(从严到宽):
222
+ * 1. 有 `source` → 只认 `source.kind === 'user'`(宿主自己声明的来源);
223
+ * 2. 没有 `source`(老宿主)→ `role === 'user'` 且文本不带插件前缀。
224
+ *
225
+ * @param message - 一条消息(Message 或 Message-like 对象)。
226
+ * @returns `'user'` / `'legacy'`(老宿主兜底)/ undefined(不是用户输入)。
227
+ */
228
+ function userOriginOf(message) {
229
+ const source = message?.source;
230
+ if (source !== undefined && source !== null && typeof source === "object") {
231
+ return source.kind === "user" ? "user" : undefined;
232
+ }
233
+ if (message?.role !== "user") return undefined;
234
+ return textOf(message).startsWith(SYNTHETIC_PREFIX) ? undefined : "legacy";
235
+ }
236
+
237
+ /**
238
+ * 从本次 pre-step 的 messages 里收出真正来自用户的文本。
239
+ *
240
+ * 修之前:把所有消息(包括插件注入的 `[decision-engine-*]` 提示)拼成一段
241
+ * 当作「新的用户任务」,于是每一轮都可能对着自己的提示重新分派。
242
+ *
243
+ * @param messages - pre-step 载荷里的消息数组。
244
+ * @returns `{ incoming, origin }`;没有可归因的用户文本时返回 undefined
245
+ * (→ 整个分派点不判断,而不是拿空串去问模型)。
246
+ */
247
+ function collectUserInput(messages) {
248
+ const parts = [];
249
+ let sawUser = false;
250
+ let sawLegacy = false;
251
+ for (const message of messages) {
252
+ const origin = userOriginOf(message);
253
+ if (origin === undefined) continue;
254
+ if (origin === "user") sawUser = true;
255
+ else sawLegacy = true;
256
+ const text = textOf(message);
257
+ if (text.length > 0) parts.push(text);
258
+ }
259
+ if (parts.length === 0) return undefined;
260
+ return {
261
+ incoming: parts.join("\n").slice(0, 1200),
262
+ origin: sawUser ? (sawLegacy ? "user+legacy" : "user") : "legacy",
263
+ };
264
+ }
265
+
266
+ /**
267
+ * 取会话里**第一条真实用户消息**,作为这一轮的原始目标(截断)。
268
+ *
269
+ * 只认 `source.kind === 'user'`(老宿主按 role 兜底并排除插件前缀):
270
+ * 插件注入的 `[decision-engine-*]` 提示如果参与了 goal,目标就会从
271
+ * 「用户要什么」漂移到「继续把没做完的部分做完」,续轮判断失去基准。
272
+ *
273
+ * @param session - 宿主 Session。
274
+ * @param chars - 截断长度(超出部分用省略号标记)。
275
+ * @returns 目标文本;找不到返回空串。
276
+ */
277
+ function firstUserText(session, chars) {
278
+ for (const event of sessionEventsOf(session)) {
279
+ if (event?.type !== "user/message") continue;
280
+ // 新宿主把消息放在 data.message 里,老宿主/兼容层可能直接放 data。
281
+ const message = event.data?.message ?? event.data;
282
+ if (userOriginOf(message) === undefined) continue;
283
+ const text = textOf(message);
284
+ if (text.length === 0) continue;
285
+ return text.length > chars ? `${text.slice(0, chars)}…` : text;
286
+ }
287
+ return "";
288
+ }
289
+
290
+ /**
291
+ * 数「已经发出 tool/call 但还没有对应 tool/result」的数量。
292
+ *
293
+ * 修之前 `state.pendingTools` 是硬编码的 0:模型永远看到「没有未完成的
294
+ * 工具调用」,于是「卡在工具执行上的一轮」与「正常收束的一轮」在证据上
295
+ * 完全一样。
296
+ *
297
+ * 宿主的事件是可直接配对的**数据**(不是猜意图):
298
+ * tool/call 的 `data.callId`
299
+ * tool/result 的 `data.message.toolCallId`
300
+ * 配不上对的就是还没回来。老格式两侧都可能缺 id,此时按先进先出配一对。
301
+ *
302
+ * 只统计**最近一条用户消息之后**的调用:被中断的轮次会留下永远没有结果的
303
+ * 历史调用,若跨轮累计,`pendingTools` 会永久 ≥1——那不是在报告事实,而是
304
+ * 在给模型制造「还有活没干完」的假证据。
305
+ *
306
+ * @param session - 宿主 Session。
307
+ * @returns 最近一段里未完成的工具调用数。
308
+ */
309
+ function countPendingToolCalls(session) {
310
+ /** callId → 仍在等待结果的调用。 */
311
+ const open = new Map();
312
+ /** 两侧都没有 id 的老格式调用:只能按数量配。 */
313
+ let anonymous = 0;
314
+ const reset = () => {
315
+ open.clear();
316
+ anonymous = 0;
317
+ };
318
+ for (const event of sessionEventsOf(session)) {
319
+ // 新的用户输入 = 新的一段工作:之前没回来的调用不再属于「现在」。
320
+ if (event?.type === "user/message") {
321
+ reset();
322
+ continue;
323
+ }
324
+ if (event?.type === "tool/call") {
325
+ const callId = callIdOf(event);
326
+ if (callId === undefined) anonymous += 1;
327
+ else open.set(callId, true);
328
+ continue;
329
+ }
330
+ if (event?.type !== "tool/result") continue;
331
+ const callId = callIdOf(event);
332
+ if (callId !== undefined && open.has(callId)) {
333
+ open.delete(callId);
334
+ } else if (anonymous > 0) {
335
+ anonymous -= 1;
336
+ } else if (callId === undefined) {
337
+ // 两侧都缺 id:按先进先出配最早那个未完成调用。
338
+ const oldest = open.keys().next();
339
+ if (!oldest.done) open.delete(oldest.value);
340
+ }
341
+ // 有 id 但找不到对应 call(例如日志被截断):忽略,不虚构一个待办。
342
+ }
343
+ return open.size + anonymous;
344
+ }
345
+
346
+ /**
347
+ * 从 tool 事件里取调用 id(兼容两种宿主形状)。
348
+ * @param event - tool/call 或 tool/result 事件。
349
+ * @returns 调用 id,或 undefined(老格式没有 id)。
350
+ */
351
+ function callIdOf(event) {
352
+ const id = event?.data?.callId ?? event?.data?.message?.toolCallId;
353
+ return typeof id === "string" && id.length > 0 ? id : undefined;
354
+ }
355
+
198
356
  /**
199
357
  * 上下文压缩:判断「现在只丢陈旧工具结果够不够」。
200
358
  *
@@ -8,8 +8,8 @@
8
8
  * 一个任务的「要不要强模型」在开头就定了,逐步重问是浪费。
9
9
  * 闸2 路由闸 提议的 config 与上一次实际使用的不同才问。同一 provider/model
10
10
  * 连续多步时,裁决已经生效,不必再问。
11
- * 闸3 机械闸 只有本地判定「看起来机械」的步骤才问:上一步有工具结果、
12
- * 且最近没有用户新输入。新推理多半需要强模型,问了也是 keep。
11
+ * 闸3 机械闸 最近一条相关事件不是用户新输入才问。带新用户输入的步骤
12
+ * (尤其是新任务的第一步)是新推理,问了也是 keep,还白等一步。
13
13
  *
14
14
  * 生效方式:返回替换后的 LlmCallConfig。observe 只记录「本该换成什么」。
15
15
  *
@@ -46,15 +46,24 @@ export function mountRoutePoint(ctx, deps) {
46
46
  return state;
47
47
  };
48
48
 
49
- /** 最近一条 user 消息是否比最近一条工具结果更新(= 有新推理进入)。 */
50
- const hasFreshUserInput = (session) => {
49
+ /**
50
+ * 最近一条相关事件的**归类**:用户新输入、工具结果,还是什么都没有。
51
+ *
52
+ * 为什么不是一个布尔(旧的 `hasFreshUserInput`):闸3 要回答的不是
53
+ * 「有没有新输入」,而是「这一步有没有机械延续的证据」。三态才分得清
54
+ * 「明确是新推理」「明确是工具结果之后」「事件读不到」——第三种不能
55
+ * 当成前两种中的任何一个(详见闸3 的注释)。
56
+ *
57
+ * @returns `"after-user-message"` / `"after-tool-result"` / undefined(读不到)。
58
+ */
59
+ const lastRelevantKind = (session) => {
51
60
  const events = sessionEventsOf(session);
52
61
  for (let index = events.length - 1; index >= 0; index -= 1) {
53
- const event = events[index];
54
- if (event?.type === "tool/result") return false;
55
- if (event?.type === "user/message") return true;
62
+ const type = events[index]?.type;
63
+ if (type === "tool/result") return "after-tool-result";
64
+ if (type === "user/message") return "after-user-message";
56
65
  }
57
- return false;
66
+ return undefined;
58
67
  };
59
68
 
60
69
  const listener = async (payload, next) => {
@@ -89,8 +98,23 @@ export function mountRoutePoint(ctx, deps) {
89
98
  if (state.askedSteps.size > 0) return proposed;
90
99
  }
91
100
 
92
- // 闸3:新用户输入带来的步骤通常是新推理,问了也多半 keep。
93
- if (hasFreshUserInput(agent.session) && state.askedSteps.size > 0) return proposed;
101
+ // 闸3 机械闸:最近一条相关事件是用户新输入,就不判断。
102
+ //
103
+ // 修之前这里还要求 `state.askedSteps.size > 0`——「本轮问过步了才拦」。
104
+ // 于是**新用户任务的第一步**(本轮还没问过任何一步)会照常问模型要不要
105
+ // 下沉,而那恰恰是最不该下沉的一步:用户刚提出目标,后文还没展开,
106
+ // `state` 里只有 current/recent、没有任何「下一步是机械活」的证据。
107
+ // 问出来的多半是 keep(白付一次 ~2 秒 / ~1.9k token),**万一**判成机械,
108
+ // 整个新任务就从开局跑在便宜模型上——第一步走偏,后面全在错的前提上继续。
109
+ //
110
+ // 因此去掉「本轮问过步」这个前提:只要最近一条相关事件是 user/message,
111
+ // 这一步就是新推理,一律不判断。剩下的两种事件才允许继续往下走:
112
+ // tool/result 明确的机械延续(读结果、改格式、重试);
113
+ // 读不到事件 保持旧行为(问)。读不到 ≠ 有用户新输入,把它当成
114
+ // 「不判断」会让老宿主上的路由**静默失效**——这正是
115
+ // lib/session-events.js 顶部记的那个坑,不能再踩一次。
116
+ const basis = lastRelevantKind(agent.session);
117
+ if (basis === "after-user-message") return proposed;
94
118
 
95
119
  // 已经在用便宜模型了,再往下沉没有意义。
96
120
  if (
@@ -109,6 +133,14 @@ export function mountRoutePoint(ctx, deps) {
109
133
  step: payload.step,
110
134
  current: { provider: proposed.provider, model: proposed.model },
111
135
  recent: digestRecent(agent.session, cfg.recentMessages, cfg.recentMessageChars),
136
+ // 闸3 凭什么放行这一步:`after-tool-result` = 上一条相关事件是工具
137
+ // 结果;读不到事件时不给这个字段(不编造依据)。
138
+ //
139
+ // 注意:route 规格的 build 目前只取 turn/step/current/recent,
140
+ // 所以它暂时不会出现在决策历史页上——留在这里是为了让「为什么问了
141
+ // 这一步」在请求里可核对(测试按它断言),规格一旦把它纳入 state
142
+ // 就自动生效,不必再改这里。
143
+ ...(basis === undefined ? {} : { basis }),
112
144
  },
113
145
  // session / turn / step 只给「决策历史」明细用:tab 按会话归属,
114
146
  // 轮次与步号让每行能定位到会话里的具体位置。它们不改任何裁决。
@@ -58,6 +58,45 @@ export function retrievalQuery(args) {
58
58
  }
59
59
  }
60
60
 
61
+ /**
62
+ * 时效性意图:出现这些词就说明用户要的是**此刻**的事实。
63
+ *
64
+ * 中文用子串匹配(没有词边界这回事),英文用 `\b` 防止 "known" 里的 "now"、
65
+ * "download" 里的 "load" 之类的假命中。
66
+ */
67
+ export const TIME_SENSITIVE_PATTERN =
68
+ /最新|今天|当前|目前|现在|版本|发布|价格|新闻|实时|\b(?:latest|today|current|currently|version|versions|release|released|price|prices|pricing|news|now)\b/i;
69
+
70
+ /** 查询里出现它就说明这次要抓的是一个**具体地址**。 */
71
+ const URL_PATTERN = /(?:https?:\/\/|www\.)/i;
72
+
73
+ /**
74
+ * 「这类检索绝不能交给模型决定跳过」的确定性判据。
75
+ *
76
+ * 为什么需要它:retrieval 点的 skip 在 enforce 下会变成 deny(真的把调用拦下来)。
77
+ * 模型的判断依据只有「recent 里是否已覆盖」和「再搜是否有新信息」,而下面三种
78
+ * 情况下这个依据**在原理上就不成立**:
79
+ *
80
+ * 1. web_fetch:调用方给的是一条具体 URL。要判断「这份文档已经在上下文里了」
81
+ * 需要内容指纹,我们没有;模型只能凭 URL 猜。
82
+ * 2. 查询里带 URL:同上——用户明确要抓这个地址,不是要「一般性的知识」。
83
+ * 3. 时效性关键词(最新/今天/版本/发布/价格……):这类问题**天然**需要重新
84
+ * 检索,哪怕上下文里提过一次——「上次的答案」正是它要更新的东西。
85
+ * `would_add` 问的是「再搜会不会有新信息」,对这一类几乎必然答「会」,
86
+ * 可一旦模型答错,代价是一次被判死的检索,而用户完全无法理解为什么。
87
+ *
88
+ * @param name - 工具名。
89
+ * @param query - `retrievalQuery()` 摘出来的查询文本。
90
+ * @returns 命中的保护类别;不命中返回 undefined(= 可以交给模型裁决)。
91
+ */
92
+ export function retrievalGuardReason(name, query) {
93
+ if (name === "web_fetch") return "explicit-url";
94
+ const text = typeof query === "string" ? query : "";
95
+ if (URL_PATTERN.test(text)) return "url-in-query";
96
+ if (TIME_SENSITIVE_PATTERN.test(text)) return "time-sensitive";
97
+ return undefined;
98
+ }
99
+
61
100
  /**
62
101
  * 挂载检索必要性 + 技能选择两个监听器。
63
102
  *
@@ -80,7 +119,7 @@ export function mountToolPoints(ctx, deps) {
80
119
  if (name === SKILL_TOOL && runner.participates("skillPick")) {
81
120
  try {
82
121
  const requested = typeof exec.arguments?.name === "string" ? exec.arguments.name : "";
83
- const candidates = await loadSkillCandidates(ctx, exec, cfg);
122
+ const candidates = await loadSkillCandidates(ctx, exec, cfg, requested);
84
123
  // 候选太少时挑选没有意义,直接放行——别为一次「只有一个选项」付费。
85
124
  if (candidates.length >= 2) {
86
125
  const result = await runner.ask(
@@ -97,6 +136,28 @@ export function mountToolPoints(ctx, deps) {
97
136
  if (runner.enforces("skillPick") && result.action?.kind === "redirect") {
98
137
  const suggest = result.action.suggest;
99
138
  if (typeof suggest === "string" && suggest.length > 0 && suggest !== requested) {
139
+ /**
140
+ * 修之前这里直接采用模型给的 suggest,不校验它是不是候选之一。
141
+ * 模型偶尔会答一个「听起来更对」的名字(甚至是被截断掉、根本没
142
+ * 进候选的那个),于是这次调用被 deny 并指去一个**不存在的技能**:
143
+ * 模型照着原因再试一次只会再错一次,一次本来正确的调用被拖成
144
+ * 两次失败。候选清单就在手里,白名单校验是零成本的。
145
+ *
146
+ * 不合法就按「没有更匹配的」处理(放行),并留一条记录——
147
+ * 否则这类模型漂移在用户侧完全不可见(他只会看到偶发的怪错误)。
148
+ */
149
+ if (!candidates.some((item) => item.name === suggest)) {
150
+ await telemetry.log({
151
+ ts: new Date().toISOString(),
152
+ point: "skillPick",
153
+ tag: "adapter/skillPick",
154
+ action: "degraded",
155
+ detail: "skill-suggest-unlisted",
156
+ latencyMs: 0,
157
+ cached: false,
158
+ });
159
+ return next();
160
+ }
100
161
  // 不改写参数(那会替模型做决定),而是拦下并告知更匹配的那个:
101
162
  // 模型看到原因后可以自己重发,避免「装错再换」付两遍正文 token。
102
163
  return {
@@ -117,6 +178,18 @@ export function mountToolPoints(ctx, deps) {
117
178
  if (runner.participates("retrieval") && isRetrievalTool(name, deps.retrievalTools())) {
118
179
  try {
119
180
  const query = retrievalQuery(exec.arguments);
181
+ /**
182
+ * 确定性保护:命中就**不问模型**,直接放行。
183
+ *
184
+ * 修之前这里无条件交给模型判断,模型说 skip 就 deny。于是一次抓取
185
+ * 还没读过的 URL、或一句「查一下最新版本」,都可能因为 recent 里恰好
186
+ * 提过相关字眼被判死——而且 enforce 下是真的拦下,模型连重试的机会
187
+ * 都没有。这不是省钱,是把「不确定」变成了「确定地不检索」。
188
+ *
189
+ * 这里不写遥测:它根本不是一次裁决(没有问、没有答),写进
190
+ * telemetry.jsonl 会让「服务端说了什么」的流水账里多出没有服务端的行。
191
+ */
192
+ if (retrievalGuardReason(name, query) !== undefined) return next();
120
193
  const result = await runner.ask(
121
194
  "retrieval",
122
195
  {
@@ -162,9 +235,10 @@ export function mountToolPoints(ctx, deps) {
162
235
  * @param ctx - 插件上下文(用于 `ctx.get('skills')`)。
163
236
  * @param exec - 待执行的工具调用(提供 agent scope 与 cwd)。
164
237
  * @param cfg - 当前配置。
238
+ * @param requested - 这次调用请求的技能名(保证它不会被截断掉)。
165
239
  * @returns 候选数组;取不到时返回空数组。
166
240
  */
167
- async function loadSkillCandidates(ctx, exec, cfg) {
241
+ async function loadSkillCandidates(ctx, exec, cfg, requested) {
168
242
  const skills = ctx.get?.("skills");
169
243
  if (skills === undefined || typeof skills.list !== "function") return [];
170
244
  let summaries;
@@ -179,8 +253,37 @@ async function loadSkillCandidates(ctx, exec, cfg) {
179
253
  }
180
254
  // 决策模型每次只有很小的一段 state;候选超过上限就截断,宁可少挑不可超长。
181
255
  const limit = Math.max(2, cfg.skillPickCandidates ?? 8);
182
- return summaries.slice(0, limit).map((summary) => ({
183
- name: String(summary.name ?? ""),
184
- description: String(summary.description ?? "").slice(0, 160),
185
- }));
256
+ /**
257
+ * 先规范化 + 去重。
258
+ *
259
+ * 去重不是洁癖:同名技能在 list 里出现两次时,state 会白花一行,
260
+ * 而且 choice 的 criteria 是按键名索引的对象——同名条目会互相覆盖,
261
+ * 模型看到两个候选、实际只有一份描述。
262
+ * 没有名字的条目直接丢掉:它既不能出现在 criteria 里,也不可能被选中。
263
+ */
264
+ const seen = new Set();
265
+ const unique = [];
266
+ for (const summary of summaries ?? []) {
267
+ const nameValue = String(summary?.name ?? "");
268
+ if (nameValue.length === 0 || seen.has(nameValue)) continue;
269
+ seen.add(nameValue);
270
+ unique.push({ name: nameValue, description: String(summary?.description ?? "").slice(0, 160) });
271
+ }
272
+ const head = unique.slice(0, limit);
273
+ /**
274
+ * 用户当前请求的那个技能必须留在候选里。
275
+ *
276
+ * 修之前是直接 `summaries.slice(0, limit)`,而 skills.list() 的顺序与本次
277
+ * 请求无关。技能多的环境里(超过上限)请求第 9 个技能时,它根本不在候选
278
+ * 里,模型只能从「别的技能」里挑一个——一次完全正确的调用被判成错误并改写,
279
+ * 这比不做技能选择坏得多(用户看到的是「我请求的技能被告知不存在」)。
280
+ *
281
+ * 替换最后一项而不是追加:候选上限是给决策模型 state 的长度预算,
282
+ * 追加会突破预算。替换掉的那一项排在最后,本来就最可能是被截断的边界项。
283
+ */
284
+ if (head.length > 0 && seen.has(requested) && !head.some((item) => item.name === requested)) {
285
+ const wanted = unique.find((item) => item.name === requested);
286
+ if (wanted !== undefined) head[head.length - 1] = wanted;
287
+ }
288
+ return head;
186
289
  }
@@ -18,6 +18,19 @@
18
18
  * @module dsh-decision-engine/adapters/translate
19
19
  */
20
20
 
21
+ import { createHash } from "node:crypto";
22
+
23
+ /**
24
+ * 本地裁决缓存的「问法版本号」。
25
+ *
26
+ * 缓存里存的是**裁决**(要不要翻译),而一条裁决只对产生它的那套 questions
27
+ * 成立。spec.js 里 cotTranslate 的问法一改(加一问、换措辞、换判定口径),
28
+ * 旧裁决立刻失效——可这个缓存是内存里的 Map,热更新代码时它会跨版本存活。
29
+ * 把版本号写进键里,改问法时手动 +1 是最小的保险:忘了 +1 的后果是「新问法
30
+ * 一直在用老答案」,而且从现象上完全看不出来(翻译时好时坏,与文本无关)。
31
+ */
32
+ const QUESTIONS_VERSION = 1;
33
+
21
34
  /** 判断一段文本里「值得翻译的正文」占比是否够高(本地预筛,免费的)。 */
22
35
  function looksLikeProse(text) {
23
36
  if (typeof text !== "string" || text.length === 0) return false;
@@ -34,6 +47,32 @@ function looksLikeProse(text) {
34
47
  return letters >= 4;
35
48
  }
36
49
 
50
+ /**
51
+ * 构造一条裁决的缓存键。
52
+ *
53
+ * 修之前键是 `${targetLanguage}|${text.length}|${text.slice(0, 64)}`:
54
+ *
55
+ * - 同长 + 前 64 字符相同、后面不同的两段文本会共用一条裁决。翻译文本
56
+ * 经常以同样的开场白起头("Here's my plan: ..."),撞键不是理论问题;
57
+ * - `sourceLanguage` 根本不在键里:同一段英文,一次标 en、一次标 zh,
58
+ * 第二次直接复用第一次的结论——可 source 正是「是否已是目标语言」那
59
+ * 一问的输入。
60
+ *
61
+ * 现在键 = 问法版本 + 源语言 + 目标语言 + **全文**的 sha256。为什么用摘要而
62
+ * 不是全文:这个 Map 最多留 256 条,键若存全文,等于把 256 段思维链全文钉在
63
+ * 内存里;摘要定长,缓存体积与文本长度无关。
64
+ *
65
+ * @param spec - 翻译规格(只读 `sourceLanguage` / `targetLanguage`)。
66
+ * @param text - 待翻译全文。
67
+ * @returns 缓存键。
68
+ */
69
+ function decisionKey(spec, text) {
70
+ const digest = createHash("sha256").update(text, "utf8").digest("hex");
71
+ const source = typeof spec?.sourceLanguage === "string" ? spec.sourceLanguage : "auto";
72
+ const target = typeof spec?.targetLanguage === "string" ? spec.targetLanguage : "auto";
73
+ return `${QUESTIONS_VERSION}|${source}|${target}|${digest}`;
74
+ }
75
+
37
76
  /**
38
77
  * 包住 translator.translate。
39
78
  *
@@ -70,9 +109,39 @@ export function mountCotTranslatePoint(ctx, deps) {
70
109
  if (!runner.participates("cotTranslate")) return original.call(this, spec, signal);
71
110
 
72
111
  try {
112
+ /**
113
+ * 本点此刻是否允许真正改变行为。一次执行里只取一次。
114
+ *
115
+ * 为什么先取而不是每处现问:下面有三处要用它(本地预筛、缓存命中、
116
+ * 裁决落地),每次都读配置的话,配置中途被改会得到「预筛按 enforce
117
+ * 跳过、落裁决按 observe 放行」这种自相矛盾的一次执行。
118
+ */
119
+ const enforced = runner.enforces("cotTranslate");
73
120
  const text = typeof spec?.text === "string" ? spec.text : "";
74
121
  // 本地预筛:明显不需要翻译的文本零成本跳过,不必问模型。
75
122
  if (!looksLikeProse(text)) {
123
+ /**
124
+ * 修之前这个 return 跑在 `enforces()` 之前:observe 模式下也直接返回
125
+ * 原文,于是「只观察」变成了「真的跳过翻译」——用户拿到的是没翻译的
126
+ * 原文,而遥测里这次却被记成一次 observe。observe 的契约是只记录、
127
+ * 不改变行为;违反它比不开启这个点更坏,因为观察期得出的数据会被
128
+ * 当成「没人开过这个点」的干净样本。
129
+ *
130
+ * 现在:只有 enforce 才允许这条路真的返回原文;observe 一律走原翻译器,
131
+ * 只留一条「本该跳过」的记录,这样观察数据里仍能看到它到底值不值得开。
132
+ */
133
+ if (!enforced) {
134
+ await telemetry.log({
135
+ ts: new Date().toISOString(),
136
+ point: "cotTranslate",
137
+ tag: "cotTranslate",
138
+ action: "skip",
139
+ detail: "decision-engine-cot: 本地预筛判定无正文;observe 模式仍走原翻译器(本该跳过)",
140
+ latencyMs: 0,
141
+ cached: false,
142
+ });
143
+ return original.call(this, spec, signal);
144
+ }
76
145
  await telemetry.log({
77
146
  ts: new Date().toISOString(),
78
147
  point: "cotTranslate",
@@ -85,11 +154,27 @@ export function mountCotTranslatePoint(ctx, deps) {
85
154
  return text;
86
155
  }
87
156
 
88
- // 缓存键:目标语言 + 文本摘要。同一段文本重复翻译(重放、重渲染)很常见。
89
- const key = `${spec.targetLanguage ?? "auto"}|${text.length}|${text.slice(0, 64)}`;
157
+ // 缓存键:问法版本 + 源/目标语言 + 全文摘要(见 decisionKey 的说明)。
158
+ const key = decisionKey(spec, text);
90
159
  let verdict = decisions.get(key);
91
160
  if (verdict === undefined) {
92
161
  const cfg = getConfig();
162
+ /**
163
+ * 会话引用:`spec.sessionId` 只有**付费** provider 才有
164
+ * (`AnonymousTranslationSpec` 没有这个字段)。因此这个点的会话归属是
165
+ * 「有则记、没有就算不可归属」:免费通道(google / bing)的决策不会
166
+ * 出现在任何会话的「决策历史」里。这是刻意的取舍,不是遗漏——为它
167
+ * 编造一个会话 id 会让用户看到一条不属于自己会话的记录,比缺失更糟
168
+ * (见 DEC-022)。
169
+ *
170
+ * 修之前这里传的是 `{ sessionId }`,而 engine.js 的 `ask()` 只从
171
+ * `extra.session` 取会话——字段名对不上,于是**付费通道的翻译决策也
172
+ * 永远归不到会话**:明细里的 sessionId 恒为空串,界面上永远不进任何
173
+ * 会话页。引擎的 `sessionIdOf()` 本来就接受 id 字符串,所以这里直接按
174
+ * 它认的字段名传,不新增第二个字段(两个名字收同一件事,迟早只改一个)。
175
+ */
176
+ const sessionRef =
177
+ typeof spec.sessionId === "string" && spec.sessionId.length > 0 ? spec.sessionId : undefined;
93
178
  const result = await runner.ask(
94
179
  "cotTranslate",
95
180
  {
@@ -98,15 +183,13 @@ export function mountCotTranslatePoint(ctx, deps) {
98
183
  sourceLanguage: spec.sourceLanguage,
99
184
  head: text.slice(0, 400),
100
185
  },
101
- // `spec.sessionId` 只有**付费** provider 才有(AnonymousTranslationSpec
102
- // 没有这个字段)。因此这个点的会话归属是「有则记、没有就算不可归属」:
103
- // 免费通道(google / bing)的决策不会出现在任何会话的「决策历史」里。
104
- // 这是刻意的取舍,不是遗漏——为它编造一个会话 id 会让用户看到一条
105
- // 不属于自己会话的记录,比缺失更糟(见 DEC-022)。
106
- { signal, sessionId: typeof spec.sessionId === "string" ? spec.sessionId : "" },
186
+ {
187
+ signal,
188
+ ...(sessionRef === undefined ? {} : { session: sessionRef }),
189
+ },
107
190
  );
108
191
  // 降级 / observe / 关闭:一律按「需要翻译」处理,走原方法。
109
- if (result.action === undefined || !runner.enforces("cotTranslate")) {
192
+ if (result.action === undefined || !enforced) {
110
193
  return original.call(this, spec, signal);
111
194
  }
112
195
  verdict = result.action.kind === "translate";
@@ -114,6 +197,8 @@ export function mountCotTranslatePoint(ctx, deps) {
114
197
  }
115
198
 
116
199
  if (verdict === true) return original.call(this, spec, signal);
200
+ // 缓存里可能存着本点 enforce 期的裁决:切到 observe 后不能再拿它改行为。
201
+ if (!enforced) return original.call(this, spec, signal);
117
202
 
118
203
  // 判为不需要翻译:返回原文。这与「已经是目标语言」的语义一致,
119
204
  // 调用方不会看到异常,用户看到的还是那段文本。
package/lib/engine.js CHANGED
@@ -101,7 +101,12 @@ export function createDecisionRunner(options) {
101
101
  *
102
102
  * @param id - 决策点 id(同时是遥测里的 `point` 字段)。
103
103
  * @param input - 传给 spec.build 的现场数据。
104
- * @param extra - 可选:signal / tool / degradeHint。
104
+ * @param extra - 可选:signal / tool / session。
105
+ * `session` 是**会话引用**,接受 Session 对象或 id 字符串 —— 思维链翻译点
106
+ * 只拿得到 `TranslationSpec.sessionId`,没有 Session 对象可传。它不参与
107
+ * 裁决,只用于把这条明细归属到会话。字段名就叫 `session`(不是
108
+ * `sessionId`):`sessionIdOf()` 与明细存储都从这里取,两个名字收同一件事
109
+ * 迟早只会改一个——那正是「付费通道的翻译决策归不到会话」那次的成因。
105
110
  *
106
111
  * 软缓存(同类复用)不在引擎层开启:决策点的裁决要么与完整 state 一一
107
112
  * 对应(精确键),要么干脆不复用。归一化签名会丢掉现场内容,任何
@@ -129,8 +129,29 @@ export async function buildTrustedContext({ agent, ctx, exec, host = {}, downstr
129
129
  const descendants = await subagents.listDescendants(rootId, exec.signal);
130
130
  const member = descendants.find((entry) => entry.kind === "child" && entry.id === targetId && entry.mode === "continuable");
131
131
  const verified = targetId === rootId || Boolean(member);
132
+ /**
133
+ * 「目标是不是本机常驻成员」。
134
+ *
135
+ * 修之前只认 `agents.get(targetId)`——而 `agents` 只登记**活着的** Agent。
136
+ * 于是给一个已空闲、但仍可接续的同一棵树的子代理发消息时:
137
+ * `agents.get()` 返回 undefined → `hostLocal=false` →
138
+ * `downstreamIndependentApproval="unknown"` → mandatory 判定
139
+ * `requires_approval`,用户被问「无法核验接收方权限」。
140
+ *
141
+ * 实测这会让**所有**发给空闲队友的消息都弹审批(甚至审批策略为 never
142
+ * 时被直接拒绝),而它们恰恰是宿主的 `subagents.sendMessage` 明确支持的
143
+ * 「同树、可接续」目标。
144
+ *
145
+ * `listDescendants()` 读的是宿主自己的会话目录:目标出现在这里、且是
146
+ * `continuable`,本身就是比「当前碰巧活着」更强的常驻证据。宿主在投递时
147
+ * 还会再校验一次发送者/目标身份与可恢复性(`subagents.sendMessage` 会
148
+ * 拒绝树外或不可恢复的目标),所以这里用目录成员补齐 hostLocal 不会放宽
149
+ * 任何权限边界:一次性 / 外部子代理仍不在 `verified` 集合里。
150
+ */
132
151
  const resident = targetId === rootId ? root : scopedService("agents")?.get?.(targetId);
133
- const hostLocal = (resident?.id ?? resident?.session?.id) === targetId;
152
+ const hostLocal = targetId === rootId
153
+ || Boolean(member)
154
+ || (resident?.id ?? resident?.session?.id) === targetId;
134
155
  return { id: targetId, membership: verified ? "same-tree" : "unknown", continuable: verified ? true : "unknown", hostLocal, permissions: "unknown", downstreamIndependentApproval: verified && hostLocal && downstreamPolicyEnforced === true ? true : "unknown" };
135
156
  });
136
157
  if (target) {
@@ -1,6 +1,6 @@
1
1
  const MESSAGES = {
2
2
  "host-tool-access-denied": ["宿主未授权此工具,已阻止调用", "The host has not authorized this tool"],
3
- "recipient-authority-unverified": ["无法核验接收方权限,需要用户确认", "The recipient's authority could not be verified"],
3
+ "recipient-authority-unverified": ["目标不是本机可持续接续的子代理,发消息需要你确认", "The recipient is not a resumable child on this host; sending requires your approval"],
4
4
  "high-impact-requires-explicit-approval": ["此操作涉及提权或破坏性变更,需要用户明确批准", "Privilege escalation or destructive changes require explicit approval"],
5
5
  "write-exceeds-read-only-permission": ["当前为只读权限,写入需要用户明确批准", "Writing exceeds the current read-only permission"],
6
6
  "write-outside-authorized-workspace": ["写入目标超出已授权工作区,需要用户明确批准", "Writing outside the authorized workspace requires explicit approval"],
package/lib/index.js CHANGED
@@ -269,6 +269,18 @@ const DEFAULTS = {
269
269
  compactDistilledFloor: 0.6,
270
270
  // 安全否决:仍需逐字原文时绝不裁剪(实测两类均 0.26,故门槛取高)。
271
271
  compactVerbatinFloor: 0.6,
272
+ /**
273
+ * 是否允许 compact 点用「只丢工具结果」替代摘要(默认 **false**)。
274
+ *
275
+ * 为什么默认关:`distilled` 只是模型对「结论是否已落进对话」的一个概率,
276
+ * 而 state 里只有「有几条超长结果」这个数字——那几条结果是什么、哪些结论
277
+ * 真的保住了,都没有证据。而 prune **不可逆**:中间段被换成标记之后,
278
+ * 后续轮次再也拿不回原文。相比之下 compaction-basic 的摘要至少把信息
279
+ * 保成一段话。所以默认一律 summarize;需要极致省钱的部署再手改
280
+ * config.yml 打开它——与 `decisionsMaxBytes` 同类的**运维旋钮**,
281
+ * 刻意不进 `/config` 白名单。
282
+ */
283
+ compactAllowPrune: false,
272
284
  // ---- 轮次延续(pointTurnStop)-----------------------------------------
273
285
  turnStopMaxSteers: 1,
274
286
  // 实测:已完成 → closed 0.74 / unfin 0.09 / value 0.09;
@@ -560,6 +572,12 @@ export const Config = z.object({
560
572
  .description("判定「工具结果的信息已落地」的门槛;达线才允许只裁剪"),
561
573
  compactVerbatinFloor: z.number().min(0).max(1).default(DEFAULTS.compactVerbatinFloor)
562
574
  .description("仍需逐字原文的门槛;达线一律改走摘要"),
575
+ compactAllowPrune: z.boolean().default(DEFAULTS.compactAllowPrune)
576
+ .description(
577
+ "允许 compact 点只裁剪超长工具结果、跳过摘要(默认 false,始终交给宿主摘要)。"
578
+ + "风险:裁剪不可逆,模型仅凭「结论已落地」的概率,误判会永久丢掉工具结果中间段。"
579
+ + "运维旋钮,不在设置页提供",
580
+ ),
563
581
  // ---- 轮次延续 --------------------------------------------------------
564
582
  turnStopMaxSteers: z.number().step(1).min(0).max(5).default(DEFAULTS.turnStopMaxSteers),
565
583
  turnStopClosedFloor: z.number().min(0).max(1).default(DEFAULTS.turnStopClosedFloor),
@@ -121,6 +121,14 @@ function evidence(parts) {
121
121
  .join(" ");
122
122
  }
123
123
 
124
+ /**
125
+ * 技能选择的「没有更匹配」保留选项。
126
+ *
127
+ * 用双下划线包起来是为了与真实技能名分处不同命名空间:技能名里出现
128
+ * `__none__` 的概率远低于 `none`,而旧值 `none` 在 decide 里仍按出口兼容。
129
+ */
130
+ const SKILL_PICK_NONE = "__none__";
131
+
124
132
  /**
125
133
  * 各决策点的规格。
126
134
  *
@@ -212,6 +220,19 @@ export const POINT_SPECS = {
212
220
  const state = {
213
221
  turn: input.turn,
214
222
  step: input.step,
223
+ /**
224
+ * 这段 incoming 是「谁说的」。
225
+ *
226
+ * 修之前没有这一项:incoming 是把 `payload.messages` 全拼起来的,
227
+ * 而插件自己注入的 `[decision-engine-*]` 提示也是 `role: 'user'`。
228
+ * 于是插件可能对着自己刚写下的「建议开 subagent」再判一次分派,
229
+ * 把一条注入提示滚成自我确认的任务。
230
+ *
231
+ * origin 让「决策历史」能回答「这次凭什么认为有新用户任务」:
232
+ * `user` = 宿主声明 `source.kind === 'user'`;`legacy` = 老宿主没有
233
+ * source 时按 `role: 'user'` 且无插件前缀兜底;`user+legacy` = 两者都有。
234
+ */
235
+ origin: input.origin ?? "unknown",
215
236
  incoming: input.incoming ?? "",
216
237
  recent: input.recent ?? "",
217
238
  };
@@ -349,6 +370,23 @@ export const POINT_SPECS = {
349
370
  return { kind: "summarize", reason: `decision-engine-compact: 仍需逐字原文,交给摘要压缩(${note})` };
350
371
  }
351
372
  if (parsed.distilled >= cfg.compactDistilledFloor) {
373
+ // 默认**绝不** prune:交给宿主摘要。
374
+ //
375
+ // 修之前:distilled 达线就直接 prune。但 prune 是不可逆的——中间段
376
+ // 一旦被换成标记,后续轮次再也拿不回原文;而 state 里只有「有几条
377
+ // 超长结果」这个数字,模型看不到它们是什么、哪些结论已经真正写进
378
+ // 对话。用一个 0.61 的概率授权不可逆删除,证据不够。
379
+ //
380
+ // 而 compaction-basic 的摘要至少把信息保成一段话。因此默认
381
+ // (compactAllowPrune=false)无论 distilled 多高都返回 summarize,
382
+ // 把「要不要摘要」留给宿主;只有运维显式打开 compactAllowPrune,
383
+ // 才允许这个点按门槛跳过摘要、只裁工具结果。
384
+ if (cfg.compactAllowPrune !== true) {
385
+ return {
386
+ kind: "summarize",
387
+ reason: `decision-engine-compact: 结论看似已落地,但未开启裁剪(compactAllowPrune=false),交给摘要压缩(${note})`,
388
+ };
389
+ }
352
390
  return { kind: "prune", reason: `decision-engine-compact: 结论已落地,只丢陈旧工具结果并跳过摘要(${note})` };
353
391
  }
354
392
  return { kind: "summarize", reason: `decision-engine-compact: 结论尚未落地,交给摘要压缩(${note})` };
@@ -375,6 +413,17 @@ export const POINT_SPECS = {
375
413
  build(input) {
376
414
  const state = {
377
415
  turn: input.turn,
416
+ /**
417
+ * 用户最初提的要求(已截断)。
418
+ *
419
+ * 修之前 state 里只有 `last`(助手最后一句话):模型判断「还有没有
420
+ * 没干完的活」时没有对照物——它只能从自己刚说的话里猜用户要什么,
421
+ * 而「未完成」恰恰是相对用户原始目标才成立的事实。
422
+ *
423
+ * 只取**第一条真实用户消息**:插件自己注入的 `[decision-engine-*]`
424
+ * 提示不算(否则目标会漂移到「继续把没做完的部分做完」上)。
425
+ */
426
+ goal: input.goal ?? "",
378
427
  last: input.last ?? "",
379
428
  pendingTools: input.pendingTools ?? 0,
380
429
  steered: input.steered ?? 0,
@@ -419,7 +468,9 @@ export const POINT_SPECS = {
419
468
  const value = readNoul(answers, "value");
420
469
  const closed = readNoul(answers, "closed");
421
470
  // awaiting_user 缺席**不算**形状不合格:旧端点可能不认识这个问题。
422
- // 缺它 = 判不出来 = 不据此改变行为(fail-open),而不是把整次裁决作废。
471
+ // 但缺席也**不再**等于「不改变行为」:边界判不出来时 decide 禁止续轮
472
+ // (见 decide 的「边界第二道」)。理由与 DEC-045 同一条——在「是否在
473
+ // 等用户」未知时续轮,就是在边界未知的情况下替用户把选择顶掉。
423
474
  const awaitingUser = readNoul(answers, "awaiting_user");
424
475
  if (unfinished === undefined || value === undefined || closed === undefined) return undefined;
425
476
  return { unfinished, value, closed, awaitingUser };
@@ -438,7 +489,7 @@ export const POINT_SPECS = {
438
489
  *
439
490
  * **但 unfin 之前还有一道边界(DEC-045)**:模型在等用户时,unfin 会很高
440
491
  * (「还没决定用哪个方案」既没干完、也不 closed)。这时续轮就是越权,
441
- * 所以边界判定排在最前面,且它的结果**只可能是让位**(`defer`)——
492
+ * 所以边界判定排在最前面,且它的结果**只可能是让位**(`defer` / `stop`)——
442
493
  * 本插件不会因为「在等用户」而替用户做任何决定,它只是不插手。
443
494
  */
444
495
  decide(parsed, cfg) {
@@ -464,6 +515,19 @@ export const POINT_SPECS = {
464
515
  reason: `decision-engine-turnstop: 这一轮在等用户决定,插件让位不介入(${note} await=${awaiting.toFixed(2)})`,
465
516
  };
466
517
  }
518
+ // 边界第二道:awaiting_user 缺席 = 边界未知 → 禁止续轮。
519
+ //
520
+ // 修之前:这里直接往下走 unfinished 判据。于是旧端点(不认识这一问)
521
+ // 在「你选 A 还是 B?」这类轮次上拿到 unfin 0.9 / value 0.8 → continue,
522
+ // 用一个小模型的判断把用户的选择权顶掉——正是 DEC-045 要防的事,只是
523
+ // 从「判错」换成了「判不出来还硬判」。现在缺这一问就保守收束:代价
524
+ // 只是少续一轮,与 defer 的取舍完全一致。
525
+ if (awaiting === undefined) {
526
+ return {
527
+ kind: "stop",
528
+ reason: `decision-engine-turnstop: 无法判断这一轮是否在等用户(awaiting_user 缺席),保守收束不续轮(${note})`,
529
+ };
530
+ }
467
531
  // 排在前面:确实还有活,且续一轮有增量价值 → 续。
468
532
  if (parsed.unfinished >= cfg.turnStopUnfinishedFloor && parsed.value >= cfg.turnStopValueFloor) {
469
533
  return { kind: "continue", reason: `decision-engine-turnstop: 仍有未完成的活,续一轮(${note})` };
@@ -540,15 +604,23 @@ export const POINT_SPECS = {
540
604
  /**
541
605
  * 判断该装哪个技能。装错再换等于付两遍技能正文的 token,因此把候选清单
542
606
  * 交给决策模型挑一个,比让主模型自己试错便宜。
607
+ *
608
+ * choice 里「没有更匹配的技能」用保留键 `__none__`:修之前它直接叫 `none`,
609
+ * 一旦候选里真有一个名叫 none 的技能,criteria 里两行同名、后者覆盖前者——
610
+ * 模型选 `none` 时无法区分「没有更匹配」与「选了那个技能」,decide 也把
611
+ * 任何 `none` 当成出口,那个技能永远拿不到 redirect(正确调用被判成「没选」)。
612
+ * 控制流选项必须与业务数据(技能名)分处不同命名空间。
543
613
  */
544
614
  skillPick: {
545
615
  build(input) {
546
616
  const candidates = input.candidates ?? [];
547
617
  const criteria = {};
548
618
  for (const item of candidates) criteria[item.name] = item.description || item.name;
549
- criteria.none = "No listed skill applies to the current request";
619
+ criteria[SKILL_PICK_NONE] = "No listed skill applies to the current request";
550
620
  const state = {
551
621
  requested: input.requested ?? "",
622
+ // 候选名字原样进 state:它们和 criteria 的键必须一致,否则模型
623
+ // 选出来的名字与后续的 suggest 校验对不上。
552
624
  candidates: candidates.map((item) => item.name),
553
625
  recent: input.recent ?? "",
554
626
  };
@@ -569,9 +641,18 @@ export const POINT_SPECS = {
569
641
  if (best === undefined) return undefined;
570
642
  return { best: best.choice, confidence: best.confidence };
571
643
  },
572
- decide(parsed, cfg) {
644
+ decide(parsed, cfg, input) {
573
645
  const note = evidence([["conf", parsed.confidence]]);
574
- if (parsed.best === "none") {
646
+ // 出口判定:
647
+ // - 新保留键 `__none__` 一律是出口;
648
+ // - 旧值 `none`(老端点 / 旧缓存)只在候选里没有同名技能时才按出口
649
+ // 解释——否则「名叫 none 的那个技能」就永远无法被建议。
650
+ const names = new Set(
651
+ (Array.isArray(input?.candidates) ? input.candidates : []).map((item) =>
652
+ typeof item === "string" ? item : item?.name,
653
+ ),
654
+ );
655
+ if (parsed.best === SKILL_PICK_NONE || (parsed.best === "none" && !names.has("none"))) {
575
656
  return { kind: "allow", reason: `decision-engine-skillpick: 没有更匹配的技能,放行(${note})` };
576
657
  }
577
658
  if (parsed.confidence < cfg.skillPickConfidenceFloor) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leonhardthust/dsh-decision-engine",
3
- "version": "1.10.0",
3
+ "version": "1.11.1",
4
4
  "description": "dsh-decision-engine: a cheap judgement layer for DeepSeek Harness — a small decision model answers structured questions before the main model acts, so calls can be allowed, escalated, rerouted to a cheaper model, or skipped. All eight decision points (tool authorization, model routing, delegation, compaction, turn continuation, retrieval, skill pick, COT translation) are implemented and individually switchable. Works against any service exposing a decision endpoint: built-in presets (system one, bamboonode, SiliconFlow, Silicon Flow) or a custom base URL.",
5
5
  "icon": "./icon.svg",
6
6
  "type": "module",