@leonhardthust/dsh-decision-engine 1.9.2 → 1.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -2
- package/README.zh.md +2 -2
- package/config.example.yml +70 -30
- package/docs/releasing.md +4 -4
- package/lib/adapters/agent.js +167 -9
- package/lib/adapters/route.js +42 -10
- package/lib/adapters/tools.js +109 -6
- package/lib/adapters/translate.js +94 -9
- package/lib/client.js +695 -227
- package/lib/decision-log.js +501 -0
- package/lib/decision-store.js +756 -97
- package/lib/engine.js +44 -31
- package/lib/guard/effects.js +14 -0
- package/lib/guard/listener.js +28 -3
- package/lib/guard/messages.js +35 -0
- package/lib/index.js +330 -15
- package/lib/outcomes.js +3 -1
- package/lib/points/spec.js +86 -5
- package/lib/providers.js +57 -8
- package/lib/tolerance.js +50 -40
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -32,10 +32,11 @@ main model acts.
|
|
|
32
32
|
**Providers**: system one · bamboonode · 硅基流动 SiliconFlow (CN / Global) ·
|
|
33
33
|
Perplexity · OpenRouter · custom base URL
|
|
34
34
|
|
|
35
|
-
**
|
|
35
|
+
**Safety level (how often to ask)**: Don't ask · Ask less · Default · Ask more
|
|
36
|
+
(none of the four changes permission-mandated approval or denial)
|
|
36
37
|
|
|
37
38
|
**UI**: config page with collapsible sections · Decisions tab · stats ·
|
|
38
|
-
readable approval card
|
|
39
|
+
readable approval card · sharded decision logs (by date / session / workspace) with retention
|
|
39
40
|
|
|
40
41
|
| Config page | Decisions tab |
|
|
41
42
|
|---|---|
|
package/README.zh.md
CHANGED
|
@@ -31,10 +31,10 @@
|
|
|
31
31
|
**决策服务**:system one · bamboonode · 硅基流动 SiliconFlow(国内 / 国际)·
|
|
32
32
|
Perplexity · OpenRouter · 自定义 base URL
|
|
33
33
|
|
|
34
|
-
|
|
34
|
+
**安全档位(询问频率)**:不问 · 少问 · 默认 · 多问(四档都不改变权限强制的人工批准与拒绝)
|
|
35
35
|
|
|
36
36
|
**界面**:配置页(四个可折叠分组)· 决策历史标签页 · 统计与对比 ·
|
|
37
|
-
审批卡正文可读化
|
|
37
|
+
审批卡正文可读化 · 决策日志分文件(按日期 / 会话 / 工作区)与自动清理
|
|
38
38
|
|
|
39
39
|
| 配置页 | 决策历史 |
|
|
40
40
|
|---|---|
|
package/config.example.yml
CHANGED
|
@@ -232,11 +232,14 @@ cooldownProbeMs: 10000
|
|
|
232
232
|
# cooldownMs: 60000 # 首轮冷却时长(上限为其 10 倍)
|
|
233
233
|
# failureThreshold: 3 # 连续失败多少次进入冷却
|
|
234
234
|
|
|
235
|
-
#
|
|
236
|
-
# never
|
|
237
|
-
# auto
|
|
238
|
-
# always
|
|
239
|
-
#
|
|
235
|
+
# 决策模型思考(startlux / System One 的 think_mode),三档:
|
|
236
|
+
# never 跳过思考——最省延迟(bamboonode 实测约 1.9s),默认值,热路径首选
|
|
237
|
+
# auto 由服务端决定——实测比 never 慢(约 7.2s),“按需”在实践中常常等于思考
|
|
238
|
+
# always 总是思考——每次约多等 5 秒;质量收益尚未实测
|
|
239
|
+
# 它**只影响本插件发给决策端点的请求**,不改宿主主模型的推理档位(那个在
|
|
240
|
+
# 「自动审查回退 › 推理档位」)。bamboonode/startlux 已实测支持;systemone、
|
|
241
|
+
# 两个 SiliconFlow 与 custom 未实测(会照常发送,可能被忽略、不报错);
|
|
242
|
+
# Perplexity / OpenRouter 的公开 API 没有这个字段。
|
|
240
243
|
thinkMode: never
|
|
241
244
|
|
|
242
245
|
# ============================================================================
|
|
@@ -254,7 +257,8 @@ pointAuthorize: auto
|
|
|
254
257
|
|
|
255
258
|
# ---- 2. 模型路由:机械步骤下沉到便宜模型 -----------------------------------
|
|
256
259
|
# 热路径。三道闸控量:每轮最多 routeMaxStepsPerTurn 步、路由未变不问、
|
|
257
|
-
#
|
|
260
|
+
# 最近一条是用户消息就不问(**含每轮第一步**——新任务开局没有「下一步是
|
|
261
|
+
# 机械活」的证据,不该一上来就下沉)。
|
|
258
262
|
# 必须同时配好 routeCheapProvider + routeCheapModel 才会真的换模型;
|
|
259
263
|
# 两者留空时即便打开此点也只会记录「本该换」。
|
|
260
264
|
pointRoute: off
|
|
@@ -266,7 +270,8 @@ routeCheapModel: ""
|
|
|
266
270
|
# routeRiskFloor: 0.3 # 风险达线一律保留强模型
|
|
267
271
|
|
|
268
272
|
# ---- 3. 子任务分派:接着做 / 开 subagent / 组 agent team -------------------
|
|
269
|
-
#
|
|
273
|
+
# 热路径。只在每轮第一步、且本步带**真正来自用户**的消息时判断。插件自己
|
|
274
|
+
# 注入的 [decision-engine-*] 提示不算用户任务(否则会对着自己的建议再判一次)。
|
|
270
275
|
# 生效时注入一条提示,不替模型决定——工具选择仍归模型。
|
|
271
276
|
pointDelegate: off
|
|
272
277
|
# delegateConfidenceFloor: 0.5 # 三选项均分 0.33,实测需 ≥0.5 才算有倾向
|
|
@@ -275,18 +280,23 @@ pointDelegate: off
|
|
|
275
280
|
|
|
276
281
|
# ---- 4. 上下文压缩:摘要 vs 只丢陈旧工具结果 -------------------------------
|
|
277
282
|
# 非热路径。只在实测 token 达触发线(compactThresholdRatio × 窗口)才问。
|
|
278
|
-
#
|
|
279
|
-
#
|
|
283
|
+
# 默认**不会**只裁剪:state 里只有「有几条超长结果」,看不到它们是什么、
|
|
284
|
+
# 哪些结论真的保住了,而裁剪不可逆。默认一律交给宿主的摘要压缩;只有
|
|
285
|
+
# compactAllowPrune: true 才允许「只丢陈旧工具结果、跳过摘要」。
|
|
280
286
|
pointCompact: off
|
|
281
287
|
# compactContextWindow: 0 # 0 = 用 compactTriggerTokens 绝对阈值
|
|
282
288
|
# compactThresholdRatio: 0.7 # 提前于 compaction-basic 的 0.8 介入
|
|
283
289
|
# compactTriggerTokens: 100000
|
|
284
290
|
# compactToolResultChars: 8192
|
|
285
|
-
# compactDistilledFloor: 0.6 # 结论已落地 →
|
|
291
|
+
# compactDistilledFloor: 0.6 # 结论已落地 → 才考虑只裁剪(仍需 compactAllowPrune)
|
|
286
292
|
# compactVerbatinFloor: 0.6 # 仍需逐字原文 → 一律摘要(安全否决优先)
|
|
293
|
+
# compactAllowPrune: false # 运维旋钮:允许不可逆裁剪,默认关;不在设置页
|
|
287
294
|
|
|
288
295
|
# ---- 5. 轮次延续:再来一轮有没有增量价值 -----------------------------------
|
|
289
296
|
# 非热路径。生效方式是用 agent.steer() 续一轮,每轮最多 turnStopMaxSteers 次。
|
|
297
|
+
# 判断依据包含:用户最初的要求、最近一条助手文本、**真实的未完成工具调用数**、
|
|
298
|
+
# 以及「是否在等用户」。最后一项缺席(旧端点不认识)时**保守收束不续轮**——
|
|
299
|
+
# 边界未知时续轮等于替用户把选择顶掉。
|
|
290
300
|
pointTurnStop: off
|
|
291
301
|
# turnStopMaxSteers: 1
|
|
292
302
|
# turnStopClosedFloor: 0.6
|
|
@@ -300,6 +310,9 @@ pointTurnStop: off
|
|
|
300
310
|
|
|
301
311
|
# ---- 6. 检索必要性:这条信息是不是已经知道 ---------------------------------
|
|
302
312
|
# 非热路径。只对检索类工具生效,其余工具零开销。
|
|
313
|
+
# 确定性保护(不问模型,直接放行):web_fetch、查询里带 URL、以及命中
|
|
314
|
+
# 时效性关键词(最新/今天/当前/版本/发布/价格/新闻/实时/latest/today/…)。
|
|
315
|
+
# 模型只用于「明显的重复检索」,不会单独拦下明确要求的实时查询。
|
|
303
316
|
pointRetrieval: off
|
|
304
317
|
# retrievalTools: [web_search, web_fetch, research, multi_search, advanced_search]
|
|
305
318
|
# retrievalCoveredFloor: 0.5 # 上下文已覆盖该事实
|
|
@@ -308,13 +321,16 @@ pointRetrieval: off
|
|
|
308
321
|
# ---- 7. 技能选择:该装哪个技能 ---------------------------------------------
|
|
309
322
|
# 非热路径。只对 skill 工具、且候选 ≥2 时判断。
|
|
310
323
|
# 生效时拦下不匹配的加载并指路,避免「装错再换」付两遍正文 token。
|
|
324
|
+
# 模型建议的技能名必须属于候选集,否则视为无效建议直接放行;当前请求的
|
|
325
|
+
# 技能即使排在候选上限之外也会被保留。
|
|
311
326
|
pointSkillPick: off
|
|
312
327
|
# skillPickCandidates: 8 # 送给模型挑选的候选上限(只带名称+描述)
|
|
313
328
|
# skillPickConfidenceFloor: 0.5
|
|
314
329
|
|
|
315
330
|
# ---- 8. 思维链翻译门槛:这条内容真的需要翻译吗 -----------------------------
|
|
316
|
-
# 热路径(每条消息)。本地先做免费预筛(去代码块/URL
|
|
317
|
-
#
|
|
331
|
+
# 热路径(每条消息)。本地先做免费预筛(去代码块/URL 后判断有无正文)。
|
|
332
|
+
# 只有 **enforce** 时预筛才会真的跳过;observe 模式下预筛只记录「本该跳过」,
|
|
333
|
+
# 原文照常交给翻译器——观察模式绝不改变行为。
|
|
318
334
|
pointCotTranslate: off
|
|
319
335
|
# cotProseFloor: 0.5
|
|
320
336
|
# cotSameLanguageFloor: 0.8
|
|
@@ -352,12 +368,20 @@ guardFallbackProvider: ""
|
|
|
352
368
|
guardFallbackModel: ""
|
|
353
369
|
guardFallbackReasoningEffort: ""
|
|
354
370
|
|
|
355
|
-
#
|
|
356
|
-
#
|
|
357
|
-
#
|
|
358
|
-
#
|
|
359
|
-
#
|
|
360
|
-
#
|
|
371
|
+
# 安全档位(tolerance):只调整**软性审查**多频繁地升级为询问。
|
|
372
|
+
# autonomous 不问 软性询问不弹窗、不按比例;权限强制的人工批准与拒绝照旧
|
|
373
|
+
# relaxed 少问 只按严重度尾部(校准后约最前 1%,校准前固定分 0.60);
|
|
374
|
+
# **替换**默认判断,因此同一次调用可能比默认多问也可能少问
|
|
375
|
+
# default 默认 沿用决策模型自己的判断(中/高风险、疑似注入、
|
|
376
|
+
# 不可逆且与任务无关会问)
|
|
377
|
+
# cautious 多问 默认判断之上再叠加严重度靠前一批(校准后约 25%,
|
|
378
|
+
# 校准前固定分 0.30),逐调用一定是默认的超集
|
|
379
|
+
#
|
|
380
|
+
# 四档都不改变权限边界:mandatory deny 全档不变;requires_approval 必须拿到
|
|
381
|
+
# 本次调用的宿主 allowed-once,没有审批通道时拒绝,模型不能代批。
|
|
382
|
+
# autonomous 也不是「关掉审查」:在宿主 auto 审查或本档下,软性 ask 会交给
|
|
383
|
+
# 上面的 guardFallback 裁决(失败即拒绝),因此它可能比 default 更严。
|
|
384
|
+
# severity 是排序分数,分位数就绪不代表危险概率已校准;冷启动比校准后更安静。
|
|
361
385
|
# tolerance: default
|
|
362
386
|
|
|
363
387
|
# ============================================================================
|
|
@@ -385,31 +409,47 @@ guardFallbackReasoningEffort: ""
|
|
|
385
409
|
# model、裁决、耗时、token,展开还能看到发给决策模型的现场(state)、
|
|
386
410
|
# 问题(questions)与它回来的回答(answers)。
|
|
387
411
|
#
|
|
388
|
-
# 数据只落在本机 $DSH_HOME/dsh-decision-engine/
|
|
389
|
-
#
|
|
412
|
+
# 数据只落在本机 $DSH_HOME/dsh-decision-engine/logs/,**不经过任何网络请求**,
|
|
413
|
+
# 也**不进**上面的匿名统计。
|
|
390
414
|
#
|
|
391
|
-
#
|
|
415
|
+
# 关掉之后**完全不写任何日志**(不采集内容、不占磁盘,标签页也随之消失)
|
|
392
416
|
# ——不是「写了藏起来」。明细里可能有会话正文与命令片段,用户说不记录
|
|
393
417
|
# 就该真的不落盘。
|
|
394
418
|
#
|
|
395
419
|
# decisionsTabEnabled: true # 默认开启;**这是唯一的开关**(DEC-026 / DEC-029):
|
|
396
420
|
# 开 = 记录明细,且**一定记录决策内容**;
|
|
397
|
-
# 关 =
|
|
421
|
+
# 关 = 完全不写日志(见上),
|
|
398
422
|
# 没有「只存元数据」的中间档。
|
|
399
423
|
#
|
|
400
|
-
#
|
|
401
|
-
#
|
|
402
|
-
#
|
|
424
|
+
# 日志怎么分文件(默认 date)。四选一:
|
|
425
|
+
# date logs/date/2026-11-15.log 一天一个文件(默认;日期跟随本机时区)
|
|
426
|
+
# session logs/session/<会话名>__<id 前 8>.log 一个会话一个文件
|
|
427
|
+
# workspace logs/workspace/<工作区名>.log 同一工作区的会话写在一起
|
|
428
|
+
# single logs/decisions.jsonl 全部追加到一个文件(升级前的行为)
|
|
429
|
+
# 文件名会包含会话名或工作区名(先脱敏再截断)。
|
|
430
|
+
# decisionsSplitMode: date
|
|
431
|
+
# 日期分隔符(只在 date 模式生效):- / _ / .
|
|
432
|
+
# decisionsDateSeparator: "-"
|
|
403
433
|
#
|
|
404
|
-
#
|
|
405
|
-
#
|
|
434
|
+
# 老旧日志自动删除:启动时与每 6 小时清理一次。
|
|
435
|
+
# 保留天数(0 = 不按天删,只受总量上限约束);绝不删今天与正在写的分片。
|
|
436
|
+
# decisionsRetentionDays: 30
|
|
406
437
|
#
|
|
407
|
-
#
|
|
438
|
+
# 下面几项是**高级档位**,配置页上没有对应的控件:需要时手改本文件。
|
|
408
439
|
#
|
|
409
|
-
#
|
|
410
|
-
#
|
|
440
|
+
# logs/ 目录的总量上限(字节,默认 64 MB);按 mtime 从最旧的删起。
|
|
441
|
+
# decisionsTotalMaxBytes: 67108864
|
|
411
442
|
#
|
|
443
|
+
# 单个分片的体积上限(字节)。到顶轮转成 <分片>.1(只留一代),
|
|
444
|
+
# 因此磁盘占用最多约两倍单文件上限。
|
|
412
445
|
# decisionsMaxBytes: 8388608 # 8 MB
|
|
446
|
+
#
|
|
447
|
+
# 单条内容的上限(字符)。超出时依次裁剪:工具参数预览 → 最近对话摘要 →
|
|
448
|
+
# 整个 state;只有连「问题 + 回答」都装不下时才退回只留元数据。
|
|
449
|
+
# decisionsMaxContentChars: 4000
|
|
450
|
+
#
|
|
451
|
+
# 升级时旧 decisions.jsonl(与 .1)会被一次性迁移进 logs/date/ 并改名
|
|
452
|
+
# .migrated 保留;迁移失败时旧文件仍可读,下次启动重试。
|
|
413
453
|
|
|
414
454
|
# ============================================================================
|
|
415
455
|
# 审批卡正文可读化(DEC-043)——审批卡上那块命令怎么显示
|
package/docs/releasing.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# npm 发布
|
|
2
2
|
|
|
3
|
-
发布目标:`@leonhardthust/dsh-decision-engine`,当前版本 `1.
|
|
3
|
+
发布目标:`@leonhardthust/dsh-decision-engine`,当前版本 `1.11.0`,公开包。
|
|
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.
|
|
12
|
+
- 当前 `1.11.0` 包含第二轮代码审计修复:`cotTranslate` observe 语义与缓存键、`skillPick` 候选校验、`retrieval` 时效性保护、`turnStop` 证据与缺省边界、`delegate` 来源过滤、`compact` 默认不做不可逆裁剪、`route` 机械闸收紧。设计记录见 `DECISIONS.md` 的 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.
|
|
61
|
+
npm publish ./release-artifacts/leonhardthust-dsh-decision-engine-1.11.0.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.
|
|
69
|
+
DSH 插件管理中使用 `@leonhardthust/dsh-decision-engine` 或指定 `@leonhardthust/dsh-decision-engine@1.11.0`。安装和启用是另外的用户动作,发布工作流不会修改本机 DSH profile。
|
package/lib/adapters/agent.js
CHANGED
|
@@ -86,19 +86,21 @@ export function mountAgentPoints(ctx, deps) {
|
|
|
86
86
|
// ---- 子任务分派 ------------------------------------------------
|
|
87
87
|
// 只在「每轮第一步 + 这一步确实带着新的用户输入」时判断:
|
|
88
88
|
// 分派决策只在这个时刻可能改变,后续步骤再问必然是 inline。
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
*
|
package/lib/adapters/route.js
CHANGED
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
* 一个任务的「要不要强模型」在开头就定了,逐步重问是浪费。
|
|
9
9
|
* 闸2 路由闸 提议的 config 与上一次实际使用的不同才问。同一 provider/model
|
|
10
10
|
* 连续多步时,裁决已经生效,不必再问。
|
|
11
|
-
* 闸3 机械闸
|
|
12
|
-
*
|
|
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
|
-
/**
|
|
50
|
-
|
|
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
|
|
54
|
-
if (
|
|
55
|
-
if (
|
|
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
|
|
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
|
|
93
|
-
|
|
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
|
// 轮次与步号让每行能定位到会话里的具体位置。它们不改任何裁决。
|