@leonhardthust/dsh-decision-engine 1.13.0 → 1.15.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/config.example.yml +4 -1
- package/docs/audits/2026-11-15-approval-log-audit.md +329 -0
- package/docs/releasing.md +4 -4
- package/lib/client.js +44 -6
- package/lib/guard/listener.js +64 -30
- package/lib/guard/messages.js +17 -7
- package/lib/index.js +37 -7
- package/lib/stats.js +128 -60
- package/lib/telemetry.js +19 -1
- package/package.json +1 -1
package/config.example.yml
CHANGED
|
@@ -407,6 +407,8 @@ guardFallbackReasoningEffort: ""
|
|
|
407
407
|
# 本次调用的宿主 allowed-once,没有审批通道时拒绝,模型不能代批。
|
|
408
408
|
# autonomous 也不是「关掉审查」:在宿主 auto 审查或本档下,软性 ask 会交给
|
|
409
409
|
# 上面的 guardFallback 裁决(失败即拒绝),因此它可能比 default 更严。
|
|
410
|
+
# 审批通道为 never 时:mandatory=safe 的调用直接交宿主沙箱;其它 ask 交给
|
|
411
|
+
# guardFallback(未配置/失败仍拒绝)。强制 requires_approval / deny 不变。
|
|
410
412
|
# severity 是排序分数,分位数就绪不代表危险概率已校准;冷启动比校准后更安静。
|
|
411
413
|
# tolerance: default
|
|
412
414
|
|
|
@@ -424,7 +426,8 @@ guardFallbackReasoningEffort: ""
|
|
|
424
426
|
#
|
|
425
427
|
# off 完全关闭:一个请求都不发
|
|
426
428
|
# install 仅首次运行上报一次「装了」,之后不再联网
|
|
427
|
-
# daily
|
|
429
|
+
# daily 每天上报一次**上一完整自然日**的决策数(默认;跨日由 6 小时一次
|
|
430
|
+
# 的检查补报,长驻进程不会漏掉)
|
|
428
431
|
#
|
|
429
432
|
# telemetryLevel: daily
|
|
430
433
|
# telemetryEndpoint: "" # 留空用内置端点
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
# 本地审批日志审计:分版本问题清单与解决方案
|
|
2
|
+
|
|
3
|
+
> 审计对象:本机 `~/.dsh/dsh-decision-engine/` 下的授权(`point=authorize`)日志与遥测。
|
|
4
|
+
> 方法:只读分析 + 代码交叉验证;不改插件源码、不改配置、不推送。
|
|
5
|
+
> 数据快照:2026-11-15 17:06–17:21(明细持续追加,数字按快照固定)。
|
|
6
|
+
> 分析脚本与两份原始分析记录:`tmp-audit/`(未入库)。
|
|
7
|
+
|
|
8
|
+
## 0. 结论速览
|
|
9
|
+
|
|
10
|
+
1. **审批日志本身没有版本字段**,所以本次按「npm 发布时间 + 本机安装清单 + 日志 schema 断点」推断版本窗口;1.14.0 已发布(17:14)但本机仍装 1.13.0(16:09 安装),1.14.0 无运行数据。
|
|
11
|
+
2. **1.9.0–1.9.1 是误拒重灾区**:拒绝率 15.3% → 23.2%,主因分别是「unknown 一律按需审批」和「模型答案形状不符且无回退」;1.9.2 加回退后降到 2.8%。
|
|
12
|
+
3. **1.9.x 曾把 27 次 ask 丢进「没有审批通道」的会话,100% 被宿主直接拒绝**(edit 26、write 1);1.11.2 修复后不再出现。
|
|
13
|
+
4. **当前 1.13.0 的主要问题不再是「问太多」,而是「直接拒」**:0 次 ask、25 次 deny,其中 **10 次发生在插件自己判定 `mandatory=safe` 的 write/edit 上**;审计期间(17:09–17:18)现场就发生 14 次 `model-material-risk` 拒绝,重试后又能通过。
|
|
14
|
+
5. **判定不稳定 + 重试风暴**:160 次拦截中 100 次(62.5%)在 120 秒内重试同一工具,累计 533 次;其中 31 次在 120 秒内被重新放行——同一调用「先拒后放」。
|
|
15
|
+
6. **观测口径问题仍然存在**:`reasonCode` 被写成含中文阈值的长句(61 种取值)、`hostFinalAction` 从未回填(100% unknown)、遥测一次调用写两条记录且无 `mode`、观察模式明细写的是「拟执行动作」、缓存命中 0(设计使然)。
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## 1. 数据与版本口径
|
|
20
|
+
|
|
21
|
+
### 1.1 数据源
|
|
22
|
+
|
|
23
|
+
| 文件 | 大小 | 内容 | 本次用途 |
|
|
24
|
+
|---|---|---|---|
|
|
25
|
+
| `logs/2026-11-1{3,4,5}.log` | 约 16 MB | 决策明细(按日期分片,含 `mode`/`scores`/`content`) | 主数据:authorize 6 175 条(17:06 快照;17:24 已增至 6 552) |
|
|
26
|
+
| `telemetry.jsonl` | 约 28 MB | 遥测(v1 无 audit;v2 带 `guard-audit-v1`) | 宿主结果对照:13 556 行(v2 去重后 3 893 次调用) |
|
|
27
|
+
| `stats.jsonl` | 约 3 MB | 裁决统计(写盘口径) | 交叉校验:8 041 行 |
|
|
28
|
+
| `decisions.jsonl*.migrated` | 约 14.6 MB | 迁移前的旧单文件备份 | 仅确认「logs 已包含旧记录」,未重复计数 |
|
|
29
|
+
|
|
30
|
+
合计扫描:明细 6 873 条记录(其中 authorize 6 175 条,17:06 快照;17:24 已增至 6 908 / 6 552)、遥测 13 556 行、统计 8 041 行;解析失败 0 行。日志仍在追加,计数会小幅上漂,结论不受影响。
|
|
31
|
+
|
|
32
|
+
### 1.2 为什么必须分版本,以及版本是怎么判定的
|
|
33
|
+
|
|
34
|
+
**直接证据(可复核)**
|
|
35
|
+
|
|
36
|
+
- 日志记录**没有** `pluginVersion` / `commit` 字段,无法从日志直接读出版本。
|
|
37
|
+
- 本机安装清单:`~/.dsh/profiles/desktop/package.json` 锁定 `@leonhardthust/dsh-decision-engine@1.13.0`,文件 mtime 为 **11-15 16:09:06**;安装目录内 `package.json.version = 1.13.0`。
|
|
38
|
+
- npm 发布时间(`npm view ... time`,已换算 UTC+8):
|
|
39
|
+
|
|
40
|
+
| 版本 | 发布时间(+08) | 版本 | 发布时间(+08) |
|
|
41
|
+
|---|---|---|---|
|
|
42
|
+
| 1.9.0 | 11-14 22:13 | 1.11.1 | 11-15 14:38 |
|
|
43
|
+
| 1.9.1 | 11-15 00:39 | 1.11.2 | 11-15 15:22 |
|
|
44
|
+
| 1.9.2 | 11-15 10:01 | 1.12.0 | 11-15 15:42 |
|
|
45
|
+
| 1.10.0 | 11-15 13:40 | 1.13.0 | 11-15 16:07 |
|
|
46
|
+
| 1.11.0 | 11-15 14:02 | 1.14.0 | 11-15 17:14(未安装) |
|
|
47
|
+
|
|
48
|
+
**间接证据(日志 schema 断点,用于识别「发布间隔里的本地构建」)**
|
|
49
|
+
|
|
50
|
+
- 11-14 16:41 起,明细 `scores` 首次出现 `decisionSource / mandatory / confidenceKind / reviewMode`,早于 1.9.0 发布 → 该窗口运行的是**未发布的本地构建**(记为「1.9 预览」)。
|
|
51
|
+
- 11-15 13:16 起出现按日期分片(1.10.0 的「决策日志分目录」);同一时期 `tolerance` 文案由「属最前的 1%」变为「落在严重度尾部」、并引入稳定码 `tolerance-ask`。
|
|
52
|
+
- 1.13.0 窗口内 `guardFallback` 行为与安装时间吻合(16:09 安装后立即出现 `fallback-model-deny` 记录)。
|
|
53
|
+
|
|
54
|
+
因此:**版本窗口 = 发布/安装时间**;发布与下一次发布之间若出现 schema 断点,如实标注为「本地构建」。这满足「无法直接划分时按发布时间划分」的口径,但**不能把窗口边界当成精确的代码切换点**(见 §6)。
|
|
55
|
+
|
|
56
|
+
### 1.3 版本时间线与规模
|
|
57
|
+
|
|
58
|
+
| 版本窗口(+08) | authorize 记录 | 主要配置态 | 关键观测 |
|
|
59
|
+
|---|---:|---|---|
|
|
60
|
+
| v1.8.x(≤11-14 16:41) | 647 | enforce;tolerance=relaxed | ask 1.5%;0 deny;p50 167ms |
|
|
61
|
+
| v1.9 预览(11-14 16:41–22:13) | 1 800 | enforce;tolerance 混合 | ask 7.9%(142 次),协调工具占 78 次;0 deny |
|
|
62
|
+
| v1.9.0(22:13–00:39) | 189 | enforce | **deny 15.3%(29 次)**;ask 17.5% |
|
|
63
|
+
| v1.9.1(00:39–10:01) | 168 | enforce | **deny 23.2%(39 次)**;模型失败 21 次 |
|
|
64
|
+
| v1.9.2(10:01–13:40) | 1 772 | observe 1 560 / enforce 212 | 回退 611 次;deny 63(enforce 内 2.8%);p50 1.7s |
|
|
65
|
+
| v1.10.0(13:40–14:02) | 201 | 全 observe | 回退 72;拟 deny 7;p50 2.5s |
|
|
66
|
+
| v1.11.0(14:02–14:38) | 345 | enforce 189 / observe 156 | deny 21;recipient ask 5 |
|
|
67
|
+
| v1.11.1(14:38–15:22) | 331 | enforce;policy=never | **ask 0**;deny 6;接收方误判 4 |
|
|
68
|
+
| v1.11.2(15:22–15:42) | 298 | enforce;never | ask 0;deny 2;回退 25 |
|
|
69
|
+
| v1.12.0(15:42–16:07) | 288 | enforce;never | ask 0;deny 3;max 20.5s |
|
|
70
|
+
| v1.13.0(16:07–17:21) | 417 | enforce;never;sandbox=danger-full-access | **ask 0;deny 25**;其中 10 条 `mandatory=safe` |
|
|
71
|
+
| v1.14.0 | 0 | 已发布未安装 | — |
|
|
72
|
+
|
|
73
|
+
> 除 1.9.2 外,后半段每个版本窗口只有 200–400 条记录(10–40 分钟),**比例噪声大**;下表凡引用比例都同时给出计数。
|
|
74
|
+
|
|
75
|
+
### 1.4 口径陷阱(否则会读错日志)
|
|
76
|
+
|
|
77
|
+
1. **明细与遥测是两套口径**:明细写 `action = action.kind`(observe 时仍写 `deny/ask`),遥测写 `pluginReturnedAction`(observe 时写 `defer_to_host`)。同一次调用在明细里像「被拦」,在遥测里只是「交给宿主」。明细中 `observe=>deny 74`、`observe=>ask 36` 属于**观察模式的拟执行动作**。
|
|
78
|
+
2. **遥测 v1/v2 不可混算**:v1(5 428 条)无 `audit`,无法对照宿主结果且 `deny=0`;v2(7 779 条 = 3 893 次调用)每条调用写两条记录(决策 + 结果回填),无 `mode`。按记录直接统计会把 ask/deny 虚高一倍。
|
|
79
|
+
3. **`hostFinalAction` 从未回填**(100% unknown),`effectOutcome` 没有 `succeeded`,只有 `reported-success/reported-error`;不能说「宿主本会允许」有直接证据,只能给间接证据(§3.1)。
|
|
80
|
+
4. **`.migrated` 是重复备份**:与 `logs/` 逐 id 重叠 5 213/5 214;`logs/` 已完整,无需再读备份,但备份本身没被清理。
|
|
81
|
+
5. **明细 `seq` 不是全局唯一**:会话恢复/插件重启后从 1 重新计数,`sessionId+seq` 有 233 个重复键,但 `id` 唯一(不是重复写盘)。
|
|
82
|
+
|
|
83
|
+
---
|
|
84
|
+
|
|
85
|
+
## 2. 分版本结论
|
|
86
|
+
|
|
87
|
+
### v1.8.x(≤11-14 16:41,647 条)
|
|
88
|
+
|
|
89
|
+
- **没有 fail-closed 拒绝**:0 deny、0 回退、0 模型失败拒绝;ask 仅 1.5%(10 次)。
|
|
90
|
+
- **但已存在协调工具误问**:`send_message` 6 次、`plugin_manager`/`subagent_*` 各 1 次;ask 文本仍是旧版「中风险调用,需人工确认(risk=…)」。
|
|
91
|
+
- **审计字段缺失是版本演进**,不是分支漏填:该窗口记录没有 `decisionSource/mandatory/confidenceKind/reasonCode`,`scores` 是旧形状(`{risk_p, irrev, task, inj, tolerance:{…}}`)。
|
|
92
|
+
- **结论**:此阶段「宽进」但观测能力弱;任何跨版本聚合若把这段与新版混算,`(none)` 会占满分母。
|
|
93
|
+
|
|
94
|
+
### v1.9 预览(11-14 16:41–22:13,1 800 条,未发布本地构建)
|
|
95
|
+
|
|
96
|
+
- **问询量最大**:ask 142 次(7.9%),其中协调类工具 78 次(`pwsh` 32、`edit` 27、`team_task_update` 21、`send_message` 20、`team_task_get` 12、`list_agents` 5、`wait_agent` 4…)。
|
|
97
|
+
- **误报类型已经成型**:`send_message` 因「疑似提示注入」(inj=0.81–0.86)被升级;`subagent_fast` 的提示词本身被判注入;`team_task_get/list_agents` 被判「中风险」。
|
|
98
|
+
- **仍未产生 deny**:安全层次(mandatory/回退)尚未接入,ask 是唯一升级手段。
|
|
99
|
+
- **结论**:这是「审批噪声」问题的起点;后续 1.11.1 只修了 recipient 一类,**注入/中风险路径并未根治**(见 §3.3)。
|
|
100
|
+
|
|
101
|
+
### v1.9.0(11-14 22:13–00:39,189 条)
|
|
102
|
+
|
|
103
|
+
- **拒绝率 15.3%(29/189)**,几乎全部是 `pwsh`(23 次),拒绝原因(当时尚无 `reasonCode` 字段,取 `detail`)为 `unknown-high-impact-effect-requires-approval`(26 次)。
|
|
104
|
+
- 这是安全改造的第一次「过度收紧」:**本地静态规则判不出副作用的命令一律按「需要审批」处理**,而审批通道是 `never`,于是直接拒绝。
|
|
105
|
+
- 同时 ask 17.5%(33 次),说明同一批会话里「问」与「拒」并存,用户无法预测。
|
|
106
|
+
- **结论(已修复)**:1.9.1 起把这类情况改为 `unknown + 模型复核`(`policy.js:26` 现状),不再默认要求审批。
|
|
107
|
+
|
|
108
|
+
### v1.9.1(11-15 00:39–10:01,168 条)
|
|
109
|
+
|
|
110
|
+
- **拒绝率升到 23.2%(39/168)**,拒绝原因(`detail`,同一窗口内多数记录尚无 `reasonCode`)为 `unresolved-effect-needs-model-review` 26 次 + `advisory-failed-unknown-effect` 12 次。
|
|
111
|
+
- 触发链:决策模型返回 **`answer-shape-mismatch`**(该窗口 21 次模型失败、总 24 次 shape 失败中绝大多数集中在此),而当时**没有可用的第二模型回退**,于是 fail-closed → deny。
|
|
112
|
+
- 失败工具:`pwsh` 21、`edit` 12;`edit` 的 12 次连续拒绝在 06:31–06:33(本地)集中出现,属于一次模型解析故障放大成一组硬拒绝。
|
|
113
|
+
- 同时出现**只读 MCP 误拒**:`mcp__blender__get_addon_status`(2)、`get_scene_info`(1)、`mcp__siyuan__system`(1)。
|
|
114
|
+
- **结论(1.9.2 修复)**:该窗口是「模型不可用 = 工具不可用」的典型;1.9.2 恢复回退模型后拒绝率降到 2.8%。
|
|
115
|
+
|
|
116
|
+
### v1.9.2(11-15 10:01–13:40,1 772 条;enforce 仅 212)
|
|
117
|
+
|
|
118
|
+
- 回退模型开始承担裁决:**回退 611 次(占该窗口记录 34%)**,其中 45 次回退后拒绝;deny 63(enforce 内 2.8%)。
|
|
119
|
+
- 回退把「模型失败 → 拒绝」改成「第二个模型审 → 放行/拒绝」;代价是延迟:**p50 1 705ms、p95 5 142ms、max 14 916ms**。
|
|
120
|
+
- 伴随两类误伤:`send_message` 以 `recipient-authority-unverified` 被 ask 18 次/deny 13 次;`mcp__blender__*` 仍有误拒。
|
|
121
|
+
- **结论**:安全可用性明显改善,但把「误拒」换成了「慢」与「协调工具误判」。
|
|
122
|
+
|
|
123
|
+
### v1.10.0(11-15 13:40–14:02,201 条,全 observe)
|
|
124
|
+
|
|
125
|
+
- 日志分片 + 安全档位重构上线;该窗口全为观察模式,拟 deny 7 次(`update_goal` 2、`pwsh` 4、`team_task_create` 1)。
|
|
126
|
+
- **`update_goal` 被回退模型拒绝**是此时出现的新误伤类型:目标维护属于内部状态,不应被当作危险操作。
|
|
127
|
+
- **结论**:observe 下的「拟拒绝」被写进明细,与真实拒绝不可区分(§3.5),从这一版起更明显。
|
|
128
|
+
|
|
129
|
+
### v1.11.0(14:02–14:38,345 条)
|
|
130
|
+
|
|
131
|
+
- 混合模式;deny 21。`recipient-authority-unverified` 仍 4 次,`tolerance` note 6 次,`model-material-risk` 1 次。
|
|
132
|
+
- **结论**:修复前的过渡态;协调工具误判仍在。
|
|
133
|
+
|
|
134
|
+
### v1.11.1(14:38–15:22,331 条)
|
|
135
|
+
|
|
136
|
+
- **`recipient-authority-unverified` 的 ask 从 18/7/5 次降为 0**(`90e5689`「空闲队友消息不再被误判为接收方权限未核验」)。
|
|
137
|
+
- 但仍有 4 次 `send_message` 被 **deny**(同原因码),说明该修复只覆盖「ask 路径」,**mandatory 规则本身仍会把未验证接收方判成需要审批**;在 `never` 下直接 deny。
|
|
138
|
+
- ask 总数 0,deny 6(1.8%)。
|
|
139
|
+
- **结论**:协调工具的「问」消失了,「拒」还在(4 次);1.13.0 仍有 1 次 `team_task_create` 因档位被拒。
|
|
140
|
+
|
|
141
|
+
### v1.11.2(15:22–15:42,298 条)
|
|
142
|
+
|
|
143
|
+
- **`never` 下软性 ask 交给回退审查**(`1b972e9`):ask 0、deny 2(0.7%)。
|
|
144
|
+
- 「27 次问了也没人答」的问题到此消失(全部 27 次发生在 11-14 22:38–11-15 00:56,即 1.9.0/1.9.1 窗口)。
|
|
145
|
+
- **结论(已修复)**:审批不可用时的「无意义 ask」被消除;但**非软性 ask(模型 high / 注入达线 / mandatory unknown)仍直接 deny**,这正是 1.13.0 的现场问题(§3.1)。
|
|
146
|
+
|
|
147
|
+
### v1.12.0(15:42–16:07,288 条)
|
|
148
|
+
|
|
149
|
+
- ask 0、deny 3、回退 14;出现 **20 480ms** 的单次授权(全量最大值),属于 primary 长尾 + 回退叠加。
|
|
150
|
+
- **结论**:拒绝率保持低位,但延迟上限没有跨阶段预算(最坏可到 ~60s)。
|
|
151
|
+
|
|
152
|
+
### v1.13.0(当前安装,16:07–17:21,417 条)
|
|
153
|
+
|
|
154
|
+
- **ask 0、deny 25(约 6%)**;`approvalPolicy=never`、`approvalAvailable=false`、`sandboxMode=danger-full-access` 占 100%。
|
|
155
|
+
- 拒绝构成(按原因 × mandatory × 工具):
|
|
156
|
+
|
|
157
|
+
| 原因 | mandatory | 工具 | 次数 |
|
|
158
|
+
|---|---|---|---:|
|
|
159
|
+
| `model-material-risk` | unknown | pwsh | 10 |
|
|
160
|
+
| `model-material-risk` | safe | write | 3 |
|
|
161
|
+
| `model-material-risk` | safe | edit | 3 |
|
|
162
|
+
| `fallback-model-deny` | safe | edit | 3 |
|
|
163
|
+
| `tolerance=relaxed(严重度…)` | unknown | ask_user_question / pwsh / team_task_create / update_goal | 4 |
|
|
164
|
+
| `write-outside-authorized-workspace` | requires_approval | write | 1 |
|
|
165
|
+
| `model-injection-suspect` | safe | edit | 1 |
|
|
166
|
+
|
|
167
|
+
- **10/25 的拒绝落在插件自己判定 `mandatory=safe` 的 write/edit 上**:本地规则已认为在工作区内、有沙箱边界,但模型的 `medium/high` 意见把它升成 ask,`never` 下再变成 deny(`tolerance.js:405-417` 不对 safe 短路;`listener.js:237-263` 只在 softAsk 时回退)。
|
|
168
|
+
- **现场观测(17:09–17:18,非统计口径)**:本次审计的两个只读分析子代理被连续拒绝 14 次(`model-material-risk`,工具 pwsh/write/edit,其中 write/edit 的 mandatory=safe),重试后相同操作又能通过;其中 1 次 `write` 到 `%TEMP%` 被 `write-outside-authorized-workspace` 拒绝(属预期 fail-closed,但提示了「工作区外一律拒」)。
|
|
169
|
+
- 回退几乎不触发(4 次):`automaticReview` 需要 `permissionPreset=auto` 或 `tolerance=autonomous`,当前配置 `tolerance=relaxed`,所以**没有第二模型兜底**,模型一票即拒。
|
|
170
|
+
- **结论(仍存在)**:1.13.0 从「问太多」转成「直接拒」,且拒的是本地已判安全的写操作;用户/代理看到的是工具报错而不是可批准的卡片。
|
|
171
|
+
|
|
172
|
+
### v1.14.0(17:14 发布,未安装)
|
|
173
|
+
|
|
174
|
+
- 无运行数据;本报告不覆盖。安装后再观察「统计审计修复」是否影响本结论(本次未见其代码路径参与授权裁决)。
|
|
175
|
+
|
|
176
|
+
---
|
|
177
|
+
|
|
178
|
+
## 3. 问题归纳(跨版本主题)
|
|
179
|
+
|
|
180
|
+
### 3.1 P0|审批不可用时,模型风险直接变成硬拒绝(1.13.0 仍存在)
|
|
181
|
+
|
|
182
|
+
- 机制:`approvalPolicy=never` → `approvalAvailable=false`(`lib/guard/context.js:88-93`);模型给出 ask 时,只有 `softAsk`(`mandatory!=unknown` 且 `choice!=high` 且注入未达线 且 `choice=medium` 或档位改过)才走回退,其余 `kind:"deny"`(`lib/guard/listener.js:237-263`);回退失败也 deny(`lib/guard/fallback.js:133`)。
|
|
183
|
+
- 放大项:`tolerance` 对 `mandatory=safe` 不做短路(`lib/tolerance.js:405-417`),所以**本地已判定安全的 write/edit 仍会被模型意见升成 ask → deny**。
|
|
184
|
+
- 证据:1.13.0 的 25 次 deny 中 10 次 `mandatory=safe`;审计期间 14 次现场拒绝;1.9.0/1.9.1 的历史高拒绝率是同一机制的前身(当时更粗)。
|
|
185
|
+
- 影响:合法的文件修改被中止,代理进入重试;在 headless/自动审批环境(本机 `never`)没有人工兜底,等于「模型犹豫 = 任务失败」。
|
|
186
|
+
|
|
187
|
+
### 3.2 P0|判定不稳定:同一操作「先拒后放」(全版本)
|
|
188
|
+
|
|
189
|
+
- 160 次拦截中 100 次(62.5%)在 120s 内重试同一工具,累计 533 次后续调用(`edit` 380 次);
|
|
190
|
+
- 其中 **31 次在 120s 内被重新放行为 `defer_to_host`**(例如 `deny→edit` 7、`deny→pwsh` 7、`ask→edit` 11);
|
|
191
|
+
- 说明裁决对措辞/上下文敏感,**不可稳定复现**;重试既浪费时间又制造「绕过」观感。
|
|
192
|
+
|
|
193
|
+
### 3.3 P1|协调/只读工具被升级或拒绝(历史为主,当前仍有残留)
|
|
194
|
+
|
|
195
|
+
| 版本 | 现象 | 计数 | 现状 |
|
|
196
|
+
|---|---|---|---|
|
|
197
|
+
| 1.8.x–1.9 预览 | `send_message`/`team_task_*`/`list_agents` 被问 | 78 + 142 次 ask 中的大部分 | 1.11.1 后 ask=0 |
|
|
198
|
+
| 1.9.2–1.11.0 | `send_message` 接收方未验证 → ask/deny | ask 18/7/5,deny 13/…/4 | 1.11.1 修 ask;deny 残留 4 |
|
|
199
|
+
| 1.9.0–1.9.2 | 只读 MCP(blender get_*、siyuan system)被拒 | 8 次 | 1.9.2 后 0 |
|
|
200
|
+
| 1.13.0 | `team_task_create`/`update_goal` 因档位被拒 | 各 1 | 仍存在 |
|
|
201
|
+
| 1.13.0 | `edit` 因 `model-injection-suspect` 被拒(mandatory=safe) | 1 | 仍存在 |
|
|
202
|
+
|
|
203
|
+
根因:`effects.js` 白名单只覆盖少数工具;`mcp__*` 一律 `external_network + highImpact`;`internal_message` 需要「同树 + 可接续 + 下游独立审批」三者皆真才算 safe。对 Agent Teams 的协作工具,这等于常态性 unknown。
|
|
204
|
+
|
|
205
|
+
### 3.4 P1|延迟与成本:回退模型是主要成本项(全版本)
|
|
206
|
+
|
|
207
|
+
- 回退占 v2 调用的 **21.9%(854/3 893)**;回退 p50 2 316ms、p90 4 618ms、p99 12 160ms、max 14 679ms;primary p50 仅 188ms。
|
|
208
|
+
- 1.9.2 窗口 p50 1.7s、1.10.0 p50 2.5s,都是回退主导;1.13.0 回退降到 4 次后 p50 回到 177ms。
|
|
209
|
+
- 代码上最坏可串行 ~60s:primary 重试(默认 1 次)+ 端点回退(404)+ 回退模型各带 20s 预算,且**没有跨阶段 deadline**(`lib/index.js:2065-2073,2096-2112`;`lib/guard/fallback.js:87-92`)。全量观测 max 20 480ms。
|
|
210
|
+
- 缓存命中 **0**:精确缓存键含「完整 state + questions」(`lib/index.js:1973-1979`),授权 state 又含参数哈希、参数预览与最近对话摘要(`lib/guard/listener.js:88,177,189`);软缓存按安全评审要求对授权禁用(DEC-005)。因此授权每条都真打模型,属**设计后果**,不是故障;但它意味着「省 token」对授权点不成立。
|
|
211
|
+
|
|
212
|
+
### 3.5 P1|观测口径(当前版本仍存在)
|
|
213
|
+
|
|
214
|
+
| 问题 | 证据 | 影响 |
|
|
215
|
+
|---|---|---|
|
|
216
|
+
| `reasonCode` 被写成中文长句 | 61 种取值;`tolerance=relaxed(严重度 0.680 ≥ 0.600…)` 54 条;1.13.0 仍有 4 条 deny | 机器聚合被拆碎、英文界面显示中文、跨版本对账困难 |
|
|
217
|
+
| 明细 observe 写拟执行动作 | `observe=>deny 74`、`observe=>ask 36`;页面无 mode 列 | 事后读日志会误判「观察模式拦了人」 |
|
|
218
|
+
| 两套 action 语义 | 遥测 `defer_to_host` vs 明细 `action.kind`;`outcomes.js` 矩阵没有 `defer_to_host` | 同一调用一边是「拦截」一边是「未裁决」;统计系统性偏差 |
|
|
219
|
+
| 遥测一次调用两条记录 | v2 3 893 次调用 → 7 779 条;按记录统计 ask/deny 翻倍 | 报表虚高 |
|
|
220
|
+
| `hostFinalAction` 从未回填 | 110 次 deny 全部 unknown | 「插件拒了但宿主本会放行」无法证伪,只能间接推断 |
|
|
221
|
+
| 审计自身写失败 | `auditWriteFailures>0` 234 条;7 次调用只落半条 | 少量丢数(<0.2%),但无告警 |
|
|
222
|
+
| 无版本字段 | 0/6 175 条记录带版本 | 本次只能按发布时间推断(§1.2) |
|
|
223
|
+
| `stats.jsonl` 的 `point=unknown` | 160 条(11-14 14:21–14:47,26 分钟窗口) | 统计页出现「未知」桶,授权率分母偏小;随版本修复,但读取端未兜底 |
|
|
224
|
+
| `.migrated` 备份 | 14.6 MB,与 logs 重叠 5 213/5 214 | 占空间;保留策略未覆盖 |
|
|
225
|
+
| `seq` 非唯一 | 233 个 `sessionId+seq` 重复键 | 用 seq 定位记录会错,须用 `id` |
|
|
226
|
+
|
|
227
|
+
### 3.6 正向结论(避免只报坏消息)
|
|
228
|
+
|
|
229
|
+
- **没有「插件拒绝但工具照常执行」的证据**:110 次 deny 的 `effectOutcome` 100% 为 `reported-error`;ask 被拒 27 次 → error,放行 23 次 → success,100% 对应。
|
|
230
|
+
- **本地快路径有效**:1 546 次 `local_policy` 决策不请求模型(延迟 0);全量明细解析失败 0 行。
|
|
231
|
+
- **fail-closed 方向正确**:守卫二次校验只能收紧不能放宽(`lib/guard/listener.js:44-59`),未发现「模型/回退把宿主 deny 洗成 allow」的记录。
|
|
232
|
+
- **治理手段生效**:1.11.1 的接收方修复、1.11.2 的 never 回退、1.9.2 的 MCP 修复都在日志里可验证(对应 ask/deny 归零)。
|
|
233
|
+
|
|
234
|
+
---
|
|
235
|
+
|
|
236
|
+
## 4. 解决方案(按优先级)
|
|
237
|
+
|
|
238
|
+
> 每条给出「动作 → 代码位置 → 验收指标」。P0 针对 1.13.0 现场,P1 针对观测与成本,P2 针对治理。
|
|
239
|
+
|
|
240
|
+
### P0-1 让「本地已判安全」的写操作不再被模型一票否决
|
|
241
|
+
|
|
242
|
+
- **动作**:在 `never`/无审批通道且配置了回退时,把**非 soft 的 ask 也交给 `runFallback()`**;至少对 `mandatory.status === "safe"` 的 `write_workspace`(工作区内、sandbox 已约束)直接 `defer_to_host`,把最终边界留给宿主沙箱。仅当回退未配置/失败时才 deny。
|
|
243
|
+
- **位置**:`lib/guard/listener.js:237-263`(softAsk 分支)、`lib/tolerance.js:405-417`(safe 短路)。
|
|
244
|
+
- **验收**:1.13.0 现场 25 次 deny 中 10 次 `mandatory=safe` 归零;新增回归 `never + safe + model(high) → delegate/fallback`;不允许出现「safe 且 deny」。
|
|
245
|
+
|
|
246
|
+
### P0-2 抑制「先拒后放」与重试风暴
|
|
247
|
+
|
|
248
|
+
- **动作**:对 `(sessionId, tool, argsHash)` 建立短窗口(如 120s)判定记忆:同一调用重复到达时复用首次结论并标注 `reused=true`;同时在 deny 错误里返回「已审查过,请勿重试」的结构化原因,避免代理换措辞重试。
|
|
249
|
+
- **位置**:`lib/guard/listener.js` 入口(`exec.token`/argsSummary 已有);`decision-cache.js` 可复用精确层。
|
|
250
|
+
- **验收**:拦截后 120s 内同工具重试率从 62.5% 降到 <20%;「先拒后放」从 31 次降到 0(同参同 session)。
|
|
251
|
+
|
|
252
|
+
### P0-3 `reasonCode` 稳定化
|
|
253
|
+
|
|
254
|
+
- **动作**:`guardOutcomeMessage` 兜底改为稳定枚举(如 `tolerance-note` / `tolerance-ask` / `tolerance-relaxed`),档位 note 只进 `humanReason`/`detail`;`tolerance.js` 不再把 note 塞进 `reason`。
|
|
255
|
+
- **位置**:`lib/guard/messages.js:52-66`、`lib/guard/listener.js:210,268`、`lib/tolerance.js:450/459/474`。
|
|
256
|
+
- **验收**:`reasonCode` 唯一值从 61 降到 <20;英文 UI 的 reason 列无中文;按 `reasonCode` 聚合的日趋势连续。
|
|
257
|
+
|
|
258
|
+
### P0-4 审计完整性:回填宿主最终动作 + 版本标记
|
|
259
|
+
|
|
260
|
+
- **动作**:`joinHostOutcome` 已有 `hostFinalAction` 参数但调用方从未提供(`lib/guard/audit.js:69-71,80-83`);在 `tools/result` / guard 放行路径补 `hostFinalAction`(allow/deny/error)与 `pluginVersion`(+commit)字段,三处输出(明细/遥测/stats)统一。
|
|
261
|
+
- **验收**:`hostFinalAction=unknown` 占比从 100% 降到 <5%;每条记录 100% 带 `pluginVersion`;`auditWriteFailures` 从 234 降到 0 并告警。
|
|
262
|
+
|
|
263
|
+
### P1-1 回退成本与总预算
|
|
264
|
+
|
|
265
|
+
- **动作**:仅对 `mandatory=unknown`、primary failed、`external_network` 触发回退(当前条件偏宽);给整次授权一个跨阶段 deadline(建议 10s 硬上限,回退扣减已用预算);可对 primary/fallback 做 hedge(并发取先到者)。
|
|
266
|
+
- **位置**:`lib/guard/listener.js:229-234`、`lib/index.js:2065-2073,2096-2112`、`lib/guard/fallback.js:87-92`。
|
|
267
|
+
- **验收**:回退触发率从 21.9% 降到 <10%;授权 p99 从 7.3s 降到 <2s;最坏耗时 <10s。
|
|
268
|
+
|
|
269
|
+
### P1-2 统一两套日志口径
|
|
270
|
+
|
|
271
|
+
- **动作**:遥测补 `mode` 与 `effectiveAction`(或 `enforced:boolean`);明细 observe 写 `wouldAction`;`outcomes.js` 矩阵加入 `defer_to_host` 的明确桶;遥测一条调用只保留一条(或加 `phase` 并在读取端按 `call_id` 去重)。
|
|
272
|
+
- **验收**:同一调用的明细/遥测/统计三处 action 可一一对应;报表 ask/deny 不再翻倍;页面能区分 observe 与 enforce。
|
|
273
|
+
|
|
274
|
+
### P1-3 缓存:要么明确禁用,要么做可用白名单
|
|
275
|
+
|
|
276
|
+
- **动作**:保留「授权禁用软缓存」的安全结论(DEC-005),但在配置页/统计页明示「授权不适用缓存」;若要省 token,另建**显式**白名单缓存:键 `(tool, argsHash, contextVersion, questionsVersion)`,仅 `read_local` 与 `mandatory=safe` 的写操作,TTL ≤ 10min,命中需可撤销。
|
|
277
|
+
- **验收**:命中率可测且仅限白名单;未白名单调用命中率保持 0;不得出现「参数不同却复用」。
|
|
278
|
+
|
|
279
|
+
### P1-4 协调工具与 MCP 只读分级
|
|
280
|
+
|
|
281
|
+
- **动作**:`send_message/team_task_*/wait_agent/list_subagent_models` 在 `effects.js` 里按「宿主已强制」判 `internal_state`/`internal_message`(保留 recipient 校验,但不要 highImpact);`mcp__*` 按工具注解/名称(`get_*`、`list_*`)降级为 unknown 低风险,至少不因「MCP」本身判 high。
|
|
282
|
+
- **验收**:协调类工具 ask/deny 从历史 100+ 次降到 0(1.13.0 仅 1 条 `team_task_create`);只读 MCP 不再被拒。
|
|
283
|
+
|
|
284
|
+
### P2-1 数据治理
|
|
285
|
+
|
|
286
|
+
- **动作**:迁移完成后删除/归档 `.migrated`(14.6MB);给 `telemetry.jsonl` 加保留策略(当前 28MB 无上限);统一 `logs/`、`telemetry`、`stats` 的保留与配额,并在配置页展示占用。
|
|
287
|
+
- **验收**:`~/.dsh/dsh-decision-engine` 总占用可解释;备份不与主数据重复。
|
|
288
|
+
|
|
289
|
+
### P2-2 统计读取端兜底
|
|
290
|
+
|
|
291
|
+
- **动作**:`stats-store`/页面把缺 `point` 的记录归入显式 `unknown` 桶并标注版本窗口,不进入授权率分母;读取端按 `call_id` 去重。
|
|
292
|
+
- **验收**:统计页「unknown」不再静默污染;1.9.2 前窗口在图表上可区分。
|
|
293
|
+
|
|
294
|
+
---
|
|
295
|
+
|
|
296
|
+
## 5. 复现与证据
|
|
297
|
+
|
|
298
|
+
```powershell
|
|
299
|
+
# 1) 版本发布时间(直接证据)
|
|
300
|
+
npm view @leonhardthust/dsh-decision-engine time --json
|
|
301
|
+
|
|
302
|
+
# 2) 本机安装版本与时间
|
|
303
|
+
Get-Content "$HOME\.dsh\profiles\desktop\package.json" | Select-String 'decision-engine'
|
|
304
|
+
(Get-Item "$HOME\.dsh\profiles\desktop\package.json").LastWriteTime
|
|
305
|
+
|
|
306
|
+
# 3) 明细聚合(本报告 §1–§2 数字)
|
|
307
|
+
node tmp-audit/agg.mjs > tmp-audit/agg.json
|
|
308
|
+
node tmp-audit/deep.mjs > tmp-audit/deep.json
|
|
309
|
+
node tmp-audit/versions2.mjs > tmp-audit/versions2.json
|
|
310
|
+
|
|
311
|
+
# 4) 遥测对照(§3.1/§3.4/§3.5)
|
|
312
|
+
node tmp-audit/analyze-telemetry.mjs "$HOME\.dsh\dsh-decision-engine\telemetry.jsonl" 13556 > tmp-audit/analysis-output.txt
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
- 代码级根因与行号:`tmp-audit/findings-code.md`
|
|
316
|
+
- 遥测宿主结果对照:`tmp-audit/findings-telemetry.md`
|
|
317
|
+
- 明细原始数据:`~/.dsh/dsh-decision-engine/logs/*.log`
|
|
318
|
+
|
|
319
|
+
---
|
|
320
|
+
|
|
321
|
+
## 6. 局限与未验证
|
|
322
|
+
|
|
323
|
+
1. **版本边界是推断**:日志无版本字段;发布/安装时间只能给出窗口,窗口内的本地构建(如 11-14 16:41 的 schema 断点)无法精确归属。要根治,先加 `pluginVersion`(P0-4)。
|
|
324
|
+
2. **配置与版本混淆**:1.9.2 后大量窗口是 observe 或 `never`,拒绝率变化同时受「版本修复」与「配置切换」影响;本报告只在同一窗口内比较,跨窗口只作趋势。
|
|
325
|
+
3. **没有人工 ground truth**:无法判定某次拒绝「本应放行」,只能给出「mandatory 已判 safe 却被拒」「同参重试又通过」「只读工具被拒」等强间接证据。
|
|
326
|
+
4. **`hostFinalAction` 缺失**:无法直接证明「宿主本会允许」;`effectOutcome=reported-error` 也无法区分「插件拦截」与「工具自身报错」(对 deny 语义影响很小)。
|
|
327
|
+
5. **遥测在 telemetryLevel=off 期间不写**:0 条不代表 0 次拦截。
|
|
328
|
+
6. **数据持续增长**:本报告数字为 17:06–17:21 快照;明细与遥测仍在追加,比例会小幅漂移。
|
|
329
|
+
7. **未覆盖**:11-12 的早期记录只在 `stats.jsonl` 中留有统计(明细已迁移为 `.migrated`);`goal_summary`/`args_summary` 仅存 sha256,无法还原参数内容(隐私设计预期)。
|
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.15.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.15.0` 包含 DEC-076:审批通道为 `never` 时,`mandatory=safe` 调用交宿主沙箱、非 safe 的 ask 交回退裁决(不再被模型一票否决);同会话同参 120 秒内复用首次裁决;`reasonCode` 稳定枚举化(档位 note 只进 humanReason)。`1.14.0` 的 DEC-075、`1.13.0` 的 DEC-074、`1.12.0` 的 DEC-072/073、`1.11.2` 的 never 回退、`1.11.1` 的 DEC-070、`1.11.0` 的审计修复、`1.10.0` 的五项改动依次见 DEC-062 ~ DEC-075。
|
|
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.15.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.15.0`。安装和启用是另外的用户动作,发布工作流不会修改本机 DSH profile。
|
package/lib/client.js
CHANGED
|
@@ -146,7 +146,7 @@ window.__ModuleLoader__.load({
|
|
|
146
146
|
// —— 匿名使用统计(DEC-020)——
|
|
147
147
|
"匿名使用统计": "Anonymous usage stats",
|
|
148
148
|
"每天上报一次:匿名安装 id、版本、平台与当日决策数": "Reports once a day: anonymous install id, version, platform, and today's decision count",
|
|
149
|
-
"每天一次,只用于让作者判断插件还有多少人在用、值不值得继续维护:随机安装 ID
|
|
149
|
+
"每天一次,只用于让作者判断插件还有多少人在用、值不值得继续维护:随机安装 ID、插件版本、平台/架构、宿主版本、上一完整自然日的决策次数。不含会话内容、文件路径、密钥、模型名或身份信息。": "Once a day, used only so the author can tell how many people still use the plugin and whether it is worth maintaining: a random install ID, plugin version, platform/arch, host version, and the previous complete day's decision count. No conversation content, file paths, keys, model names, or identity information.",
|
|
150
150
|
"已关闭:任何情况下都不联网": "Off: never touches the network",
|
|
151
151
|
"完全关闭:不发送任何请求。": "Fully off: no request is ever sent.",
|
|
152
152
|
"仅首次运行上报一次「安装了」,之后不再联网": "Reports \"installed\" once on first run, then never again",
|
|
@@ -282,6 +282,11 @@ window.__ModuleLoader__.load({
|
|
|
282
282
|
"节省比例(观察)": "Savings ratio (observe)",
|
|
283
283
|
// —— 页面标题与说明 ——
|
|
284
284
|
"用量统计与对比": "Usage & comparison",
|
|
285
|
+
"估算节省率": "Estimated savings rate",
|
|
286
|
+
"尝试 / 回答 / 降级": "Attempts / answered / degraded",
|
|
287
|
+
"决策请求估算节省率(观察)": "Estimated decision-request savings rate (observe)",
|
|
288
|
+
"口径:决策请求层面的估算(缓存去重 + 输入处理),不含主 Agent、子代理、工具调用与上下文压缩的成本变化;完整 Agent 净节省需要同任务配对 A/B,本页不提供。": "Scope: an estimate at the decision-request layer (cache dedupe + input processing). It excludes cost changes in the main agent, subagents, tool calls and context compaction; full agent net savings require a paired A/B and are not shown here.",
|
|
289
|
+
"安全口径:拦截率只按 authorize 点统计(本区间拒绝 {n} 次);其它点的 reject {m} 次属于流程优化,未计入。": "Safety scope: the block rate counts only the authorize point ({n} rejections here). The {m} rejections from other points are workflow optimizations and are not counted.",
|
|
285
290
|
"重新读取": "Reload",
|
|
286
291
|
"按 provider × model 独立统计;区间为左闭右开,相邻范围不会重复计入。": "Counted separately per provider × model; ranges are half-open, so adjacent ranges never double-count.",
|
|
287
292
|
"节省量只统计「观察」模式。": "Savings count only \"observe\" mode.",
|
|
@@ -5506,7 +5511,7 @@ window.__ModuleLoader__.load({
|
|
|
5506
5511
|
{ key: "savedOutputTokens", label: "省 output" },
|
|
5507
5512
|
{ key: "observeDurationMs", label: "实际耗时(观察)", actual: true },
|
|
5508
5513
|
{ key: "savedDurationMs", label: "省耗时" },
|
|
5509
|
-
{ key: "savedRatio", label: "
|
|
5514
|
+
{ key: "savedRatio", label: "估算节省率" },
|
|
5510
5515
|
];
|
|
5511
5516
|
|
|
5512
5517
|
/**
|
|
@@ -5529,10 +5534,19 @@ window.__ModuleLoader__.load({
|
|
|
5529
5534
|
totals === null
|
|
5530
5535
|
? []
|
|
5531
5536
|
: [
|
|
5532
|
-
{ label: "
|
|
5537
|
+
{ label: "尝试 / 回答 / 降级", value: `${groupDigits(totals.attempts ?? totals.requests)} / ${groupDigits(totals.requests)} / ${groupDigits(totals.degraded)}` },
|
|
5533
5538
|
{ label: "命中缓存", value: groupDigits(totals.cached) },
|
|
5534
5539
|
{ label: "input token", value: groupDigits(totals.inputTokens) },
|
|
5535
|
-
{
|
|
5540
|
+
{
|
|
5541
|
+
label: "output token",
|
|
5542
|
+
// 未知值不进入总量(详见 lib/stats.js):这里必须把未计量条数
|
|
5543
|
+
// 说出来,否则读者会把「已知部分」当成全部。
|
|
5544
|
+
value:
|
|
5545
|
+
groupDigits(totals.outputTokens) +
|
|
5546
|
+
((totals.outputTokensUnmeasured ?? 0) > 0
|
|
5547
|
+
? `(${groupDigits(totals.outputTokensUnmeasured)} 条未计量)`
|
|
5548
|
+
: ""),
|
|
5549
|
+
},
|
|
5536
5550
|
{ label: "总耗时", value: formatMs(totals.durationMs) },
|
|
5537
5551
|
// 写清「真实请求」:缓存命中的耗时是 0,混进分母会让这个
|
|
5538
5552
|
// 数字随命中率变好看,读的人以为请求变快了。
|
|
@@ -5552,7 +5566,7 @@ window.__ModuleLoader__.load({
|
|
|
5552
5566
|
saved: true,
|
|
5553
5567
|
},
|
|
5554
5568
|
{ label: "省时间(观察)", value: formatMs(totals.savedDurationMs), saved: true },
|
|
5555
|
-
{ label: "
|
|
5569
|
+
{ label: "决策请求估算节省率(观察)", value: formatRatio(totals.savedRatio), saved: true },
|
|
5556
5570
|
];
|
|
5557
5571
|
|
|
5558
5572
|
return h(
|
|
@@ -5610,6 +5624,20 @@ window.__ModuleLoader__.load({
|
|
|
5610
5624
|
h("strong", null, "节省量只统计「观察」模式。"),
|
|
5611
5625
|
" 观察模式下决策模型只记录、不改变行为,于是同一批工作既有普通 LLM 的实际消耗,也有决策模型「本该怎么跑」的结果——两者比的是同一批活,这个对比才成立。「生效」模式下普通 LLM 路径并没有真的发生,「省了多少」只能是事后估算,因此不计入。",
|
|
5612
5626
|
),
|
|
5627
|
+
// 口径边界(审计 P1):这是「决策请求层面」的估算,不是 Agent
|
|
5628
|
+
// 端到端净节省。不写清楚,读者会把 80% 当成「整个任务省了 80%」。
|
|
5629
|
+
h(
|
|
5630
|
+
"div",
|
|
5631
|
+
{ style: { marginTop: "4px" } },
|
|
5632
|
+
"口径:决策请求层面的估算(缓存去重 + 输入处理),不含主 Agent、子代理、工具调用与上下文压缩的成本变化;完整 Agent 净节省需要同任务配对 A/B,本页不提供。",
|
|
5633
|
+
),
|
|
5634
|
+
Number(stats?.estimatedBaselineMissing ?? 0) > 0
|
|
5635
|
+
? h(
|
|
5636
|
+
"div",
|
|
5637
|
+
{ style: { marginTop: "4px" } },
|
|
5638
|
+
`另有 ${groupDigits(stats.estimatedBaselineMissing)} 条历史缓存命中缺少基线,未计入节省(不再用同区间均值插补,避免时间泄漏与结果不可加)。`,
|
|
5639
|
+
)
|
|
5640
|
+
: null,
|
|
5613
5641
|
savingsUnavailable
|
|
5614
5642
|
? h(
|
|
5615
5643
|
"div",
|
|
@@ -6273,6 +6301,16 @@ window.__ModuleLoader__.load({
|
|
|
6273
6301
|
{ style: { marginTop: "6px" } },
|
|
6274
6302
|
"基线 = 不启用本插件的缓存与上下文压缩时,同样的工作本应消耗的 token 与时间。节省量相对基线计算(比例的分母是基线);没有可归因的优化时节省量为 0。平均耗时按**真实请求**计算,不含缓存命中(那一次根本没发请求)。思考模式(think_mode)是用户配置而非插件优化,刻意不计入节省。",
|
|
6275
6303
|
),
|
|
6304
|
+
stats.safety !== undefined
|
|
6305
|
+
? h(
|
|
6306
|
+
"div",
|
|
6307
|
+
{ style: { marginTop: "6px" } },
|
|
6308
|
+
t("安全口径:拦截率只按 authorize 点统计(本区间拒绝 {n} 次);其它点的 reject {m} 次属于流程优化,未计入。", {
|
|
6309
|
+
n: groupDigits(stats.safety.reject ?? 0),
|
|
6310
|
+
m: groupDigits(stats.safety.otherPointRejects ?? 0),
|
|
6311
|
+
}),
|
|
6312
|
+
)
|
|
6313
|
+
: null,
|
|
6276
6314
|
/**
|
|
6277
6315
|
* 模式分账:让用户一眼看出「节省为 0」的原因。
|
|
6278
6316
|
*
|
|
@@ -8779,7 +8817,7 @@ window.__ModuleLoader__.load({
|
|
|
8779
8817
|
// 与下拉的兜底保持一致:undefined 时下拉显示 daily,说明也必须说 daily,
|
|
8780
8818
|
// 否则首帧会出现「选中的是 daily、解释却写仅首次」的自相矛盾。
|
|
8781
8819
|
(draft.telemetryLevel ?? "daily") === "daily"
|
|
8782
|
-
? t("每天一次,只用于让作者判断插件还有多少人在用、值不值得继续维护:随机安装 ID
|
|
8820
|
+
? t("每天一次,只用于让作者判断插件还有多少人在用、值不值得继续维护:随机安装 ID、插件版本、平台/架构、宿主版本、上一完整自然日的决策次数。不含会话内容、文件路径、密钥、模型名或身份信息。")
|
|
8783
8821
|
: draft.telemetryLevel === "off"
|
|
8784
8822
|
? t("完全关闭:不发送任何请求。")
|
|
8785
8823
|
: t("仅在第一次运行时上报一次「安装了」(字段同上),之后不再联网。"),
|
package/lib/guard/listener.js
CHANGED
|
@@ -25,11 +25,10 @@ const idleEvaluation = () => ({ status: "not_requested", provider: null, model:
|
|
|
25
25
|
/**
|
|
26
26
|
* 是否走「自动审查」:把软性询问交给回退审查者,而不是直接拒绝。
|
|
27
27
|
*
|
|
28
|
-
* 这里刻意**不**把 `approvalPolicy === "never"`
|
|
28
|
+
* 这里刻意**不**把 `approvalPolicy === "never"` 算进来:这个开关同时
|
|
29
29
|
* 决定「模型失效 / 答案畸形 / 审查异常」是否走回退,而那条链路必须保持
|
|
30
|
-
* fail-closed(失败不能变成一次放行)。never
|
|
31
|
-
*
|
|
32
|
-
* 之外的分支单独交给 `guardFallback` 处理。
|
|
30
|
+
* fail-closed(失败不能变成一次放行)。never 下「模型给出有效意见但结论
|
|
31
|
+
* 是 ask」的情况在下面的分支单独处理:safe 交宿主沙箱,其余交回退裁决。
|
|
33
32
|
*/
|
|
34
33
|
const automaticReview = (context, config) =>
|
|
35
34
|
context?.permissionPreset === "auto"
|
|
@@ -39,6 +38,19 @@ export function mountGuard(ctx, deps) {
|
|
|
39
38
|
const { getConfig, reloadOwnConfig, effectivePointModes, client, toleranceCalibrator, digestRecent, previewArguments, logDecision, recordGuardDetail } = deps;
|
|
40
39
|
const pending = new Map();
|
|
41
40
|
const mandatoryByToken = new Map();
|
|
41
|
+
/**
|
|
42
|
+
* 短窗判定记忆(审计 P0-2)。
|
|
43
|
+
*
|
|
44
|
+
* 无审批通道时,同一 `(session, tool, argsHash)` 的裁决对措辞/上下文很
|
|
45
|
+
* 敏感,重试常出现「先拒后放」。这里把同一调用在 120 秒内的首次结论
|
|
46
|
+
* 复用到后续重试:同参数同会话得到同一个答案,代理不会因为换措辞而
|
|
47
|
+
* 意外通过,也不会白烧一次模型调用。
|
|
48
|
+
*
|
|
49
|
+
* 只在 `approvalAvailable === false` 时启用:一旦有真人/审批通道,
|
|
50
|
+
* 第二次调用可能带着新的批准,不能被旧结论覆盖。
|
|
51
|
+
*/
|
|
52
|
+
const verdictMemo = new Map();
|
|
53
|
+
const VERDICT_MEMO_MS = 120000;
|
|
42
54
|
let auditWriteFailures = 0;
|
|
43
55
|
const toolService = ctx.get?.("tools");
|
|
44
56
|
if (typeof toolService?.guard === "function") {
|
|
@@ -126,12 +138,12 @@ export function mountGuard(ctx, deps) {
|
|
|
126
138
|
degradedReason: degradedReason ?? null, model: judge.model,
|
|
127
139
|
questionsVersion: questions ? GUARD_QUESTIONS_VERSION : "not_requested",
|
|
128
140
|
});
|
|
129
|
-
const trace = { ...audit, judge, modelEvaluation, decisionSource, reasonCode: action.reasonCode, humanReason: action.reason, callId: exec.callId ?? "unknown", sessionId: session?.id ?? "unknown", pluginReturnedAction, baseAction: baseAction?.kind ?? action.kind, tolerance: { ...audit.tolerance, level: config?.tolerance ?? "default" }, fallback: fallback ?? null, degradedReason: degradedReason ?? null };
|
|
141
|
+
const trace = { ...audit, judge, modelEvaluation, decisionSource, reasonCode: action.reasonCode, humanReason: action.humanReason ?? action.reason, reused: action.reused === true, callId: exec.callId ?? "unknown", sessionId: session?.id ?? "unknown", pluginReturnedAction, baseAction: baseAction?.kind ?? action.kind, tolerance: { ...audit.tolerance, level: config?.tolerance ?? "default" }, fallback: fallback ?? null, degradedReason: degradedReason ?? null };
|
|
130
142
|
pending.set(keyOf(exec), trace);
|
|
131
143
|
if (pending.size > 2048) pending.delete(pending.keys().next().value);
|
|
132
144
|
await emit(trace);
|
|
133
145
|
if (result?.statsFacts !== undefined) {
|
|
134
|
-
try { client.recordDecision({ facts: result.statsFacts, action: action.kind, detail: action.reason, degraded: Boolean(degradedReason) }); }
|
|
146
|
+
try { client.recordDecision({ facts: result.statsFacts, action: action.kind, detail: action.humanReason ?? action.reason, degraded: Boolean(degradedReason) }); }
|
|
135
147
|
catch { ctx.logger?.warn?.("decision-engine: guard statistics unavailable"); }
|
|
136
148
|
}
|
|
137
149
|
const reviewMode = { automatic: automaticReview(context, config), permissionPreset: context?.permissionPreset ?? "unknown", approvalPolicy: context?.approvalPolicy ?? "unknown", approvalAvailable: context?.approvalAvailable ?? "unknown", sandboxMode: context?.sandboxMode ?? "unknown", serviceSources: context?.serviceSources ?? {} };
|
|
@@ -141,7 +153,7 @@ export function mountGuard(ctx, deps) {
|
|
|
141
153
|
confidenceKind: advisory?.risk?.confidenceKind ?? "unavailable", p_high: advisory?.risk?.probabilities?.high ?? null,
|
|
142
154
|
p_non_low: advisory?.risk?.probabilities ? advisory.risk.probabilities.high + advisory.risk.probabilities.medium : null,
|
|
143
155
|
risk_severity: tolerated?.severity ?? null, calibrated_p_unsafe: null, mandatory: mandatory?.status ?? "unknown",
|
|
144
|
-
tolerance: tolerated ?? null, decisionSource, reasonCode: action.reasonCode, modelEvaluation,
|
|
156
|
+
tolerance: tolerated ?? null, decisionSource, reasonCode: action.reasonCode, reused: action.reused === true, modelEvaluation,
|
|
145
157
|
};
|
|
146
158
|
const detailResult = requested.length ? {
|
|
147
159
|
provider: judge.provider ?? "", model: judge.model ?? "", latencyMs,
|
|
@@ -154,7 +166,7 @@ export function mountGuard(ctx, deps) {
|
|
|
154
166
|
recordGuardDetail({ session, pointMode, exec,
|
|
155
167
|
state: { summary: hashSummary(state), contextVersion: context?.contextVersion ?? "unavailable", effect: effect?.kind ?? "unclassified", mandatory: mandatory?.status ?? "unknown", modelEvaluation, decisionSource },
|
|
156
168
|
questions: { status: questions ? primary.status : "not_requested", summary: hashSummary(questions ?? {}), version: questions ? GUARD_QUESTIONS_VERSION : "not_requested" },
|
|
157
|
-
result: detailResult, action: action.kind, detail: action.reason, degraded: Boolean(degradedReason), scores,
|
|
169
|
+
result: detailResult, action: action.kind, detail: action.humanReason ?? action.reason, degraded: Boolean(degradedReason), scores,
|
|
158
170
|
});
|
|
159
171
|
} catch { ctx.logger?.warn?.("decision-engine: guard detail persistence failed"); }
|
|
160
172
|
if (observing || action.kind === "delegate") return next();
|
|
@@ -186,6 +198,15 @@ export function mountGuard(ctx, deps) {
|
|
|
186
198
|
if (enforcingPolicy && mandatory.status === "requires_approval") return finish({ kind: mandatory.approvalAvailable === true ? "ask" : "deny", reason: mandatory.reasonCodes[0] });
|
|
187
199
|
if (mandatory.status === "safe" && ["read_local", "internal_message", "internal_state"].includes(effect.kind)) return finish({ kind: "delegate", reason: "verified-local-operation-defer-to-host" });
|
|
188
200
|
|
|
201
|
+
// 短窗判定记忆(见 verdictMemo 的说明):同会话、同工具、同参数在
|
|
202
|
+
// 120 秒内复用首次结论。放在本地快路径之后、模型调用之前——既省掉
|
|
203
|
+
// 重复模型请求,也让「先拒后放」不再发生。
|
|
204
|
+
const memoKey = `${session?.id ?? "unknown"}\u0000${exec.name}\u0000${hashSummary(exec.arguments).sha256}`;
|
|
205
|
+
const memoized = context.approvalAvailable === false ? verdictMemo.get(memoKey) : undefined;
|
|
206
|
+
if (memoized !== undefined && Date.now() - memoized.at <= VERDICT_MEMO_MS) {
|
|
207
|
+
return finish({ kind: memoized.kind, reason: memoized.reason, reasonCode: memoized.reasonCode, humanReason: memoized.humanReason, reused: true }, memoized.degradedReason);
|
|
208
|
+
}
|
|
209
|
+
|
|
189
210
|
state = redactGuardData({ ...state, args_preview: previewArguments(redactGuardData(exec.arguments), config.argsHeadChars, config.argsTailChars), recent: digestRecent(session, config.recentMessages, config.recentMessageChars), recentAuthority: "untrusted-context-only", trustedContext: { originalUserGoal: context.originalUserGoal ?? "unknown", goalSource: context.goalSource } });
|
|
190
211
|
const sliceBudget = Math.floor(Math.max(500, Number(config.maxStateChars) || 6000) / 5);
|
|
191
212
|
state.recent = String(state.recent ?? "").slice(0, sliceBudget);
|
|
@@ -207,7 +228,15 @@ export function mountGuard(ctx, deps) {
|
|
|
207
228
|
if (advisory?.status === "valid") toleranceCalibrator.observe(result.provider ?? "", result.model, severity);
|
|
208
229
|
const cutoffInfo = toleranceCutoff({ level: config.tolerance, calibrator: toleranceCalibrator, provider: result?.provider ?? "", model: result?.model });
|
|
209
230
|
tolerated = resolveTolerance({ level: config.tolerance, legacyKind: baseAction.kind, severity, cutoff: cutoffInfo.cutoff, mandatory: enforcingPolicy ? mandatory : undefined, approvalAvailable: context.approvalAvailable });
|
|
210
|
-
|
|
231
|
+
// 档位改变默认判断时,`reason` 必须是**稳定枚举**:此前直接把档位 note
|
|
232
|
+
// (`tolerance=relaxed(严重度 0.680 ≥ 0.600…)`)当 reason,导致
|
|
233
|
+
// reasonCode 出现 61 种中文长句,机器聚合被拆碎、英文界面显示中文。
|
|
234
|
+
// note 保留进 humanReason,供人读与审计核对。
|
|
235
|
+
let action = tolerated.kind === baseAction.kind
|
|
236
|
+
? baseAction
|
|
237
|
+
: tolerated.kind === "ask"
|
|
238
|
+
? { kind: "ask", reason: "tolerance-ask", reasonCode: "tolerance-ask", humanReason: tolerated.note }
|
|
239
|
+
: { kind: "delegate", reason: "tolerance-delegate", reasonCode: "tolerance-delegate", humanReason: tolerated.note };
|
|
211
240
|
decisionSource = "primary_model";
|
|
212
241
|
// 为什么「本地未知副作用」可以只凭主模型意见收尾(去掉冗余的第二次复核):
|
|
213
242
|
//
|
|
@@ -238,34 +267,39 @@ export function mountGuard(ctx, deps) {
|
|
|
238
267
|
/**
|
|
239
268
|
* 审批通道不存在(DSH 的 `never` = 每个 ask 确定性 rejected)。
|
|
240
269
|
*
|
|
241
|
-
*
|
|
242
|
-
* `
|
|
270
|
+
* 1.13.0 现场问题(审计 P0-1):只有「软性 ask」才走回退,模型对
|
|
271
|
+
* `mandatory=safe` 的工作区写给出 high/注入判断时被直接 deny——
|
|
272
|
+
* 本地静态规则已经确认了边界,却让一条模型意见把合法写操作中止。
|
|
273
|
+
*
|
|
274
|
+
* 现在:
|
|
275
|
+
* - `mandatory=safe`:交宿主沙箱(本地规则已判定工作区内安全);
|
|
276
|
+
* - 其它(unknown / 非 safe):交给用户配置的回退审查者,
|
|
277
|
+
* 由它做唯一裁决;模型不可用或回退失败仍然 fail-closed。
|
|
243
278
|
*
|
|
244
|
-
*
|
|
245
|
-
*
|
|
246
|
-
* 「软性询问」,而是一条未知边界(现有情景测试专门钉住
|
|
247
|
-
* `manual unknown + never` 不得伪造批准);
|
|
248
|
-
* - `risk.choice === "high"` / 注入达线:高危与注入嫌疑不接受
|
|
249
|
-
* 「再问一个模型」的软化;
|
|
250
|
-
* - 模型失效 / 答案畸形:没有可复核的有效意见(走上面的失败分支)。
|
|
279
|
+
* 强制约束(requires_approval / deny)在更早的位置已经返回,
|
|
280
|
+
* 不会被这里绕过。
|
|
251
281
|
*/
|
|
252
|
-
|
|
253
|
-
?
|
|
254
|
-
:
|
|
255
|
-
const softAsk = advisory?.status === "valid"
|
|
256
|
-
&& mandatory.status !== "unknown"
|
|
257
|
-
&& advisory.risk.choice !== "high"
|
|
258
|
-
&& !(Number.isFinite(advisory.injectionP) && advisory.injectionP >= injectionThreshold)
|
|
259
|
-
&& (advisory.risk.choice === "medium" || tolerated?.changed === true);
|
|
260
|
-
action = softAsk
|
|
261
|
-
? await runFallback()
|
|
262
|
-
: { ...action, kind: "deny" };
|
|
282
|
+
action = mandatory.status === "safe"
|
|
283
|
+
? { kind: "delegate", reason: "safe-local-operation-host-sandbox" }
|
|
284
|
+
: await runFallback();
|
|
263
285
|
}
|
|
264
286
|
// 档位把「默认放行」升级成「询问」时,`action.reason` 是档位 note(含
|
|
265
287
|
// `severity=` 与阈值小数)——那是审计口径,不能当审批卡标题。这里换成
|
|
266
288
|
// 固定双语文案;note 仍进 `humanReason` / 遥测 detail,事后核对仍能看出
|
|
267
289
|
// 是哪一档、什么分数。放在回退之后:回退给出的裁决有自己的文案。
|
|
268
|
-
if (tolerated.changed && action.kind === "ask") action = toleranceAskAction(action);
|
|
290
|
+
if (tolerated.changed && action.kind === "ask" && action.reasonCode === undefined) action = toleranceAskAction(action);
|
|
291
|
+
// 记录本次裁决,供 120 秒内的同调用重试复用(仅无审批通道时生效)。
|
|
292
|
+
if (context.approvalAvailable === false && ["delegate", "deny", "ask"].includes(action.kind)) {
|
|
293
|
+
verdictMemo.set(memoKey, {
|
|
294
|
+
at: Date.now(),
|
|
295
|
+
kind: action.kind,
|
|
296
|
+
reason: action.reason,
|
|
297
|
+
reasonCode: action.reasonCode ?? "unclassified",
|
|
298
|
+
humanReason: action.humanReason ?? action.reason,
|
|
299
|
+
degradedReason,
|
|
300
|
+
});
|
|
301
|
+
if (verdictMemo.size > 512) verdictMemo.delete(verdictMemo.keys().next().value);
|
|
302
|
+
}
|
|
269
303
|
return finish(action, degradedReason);
|
|
270
304
|
} catch {
|
|
271
305
|
ctx.logger?.warn?.("decision-engine: guard failed; explicit permission constraints remain protected");
|
package/lib/guard/messages.js
CHANGED
|
@@ -5,6 +5,10 @@ const MESSAGES = {
|
|
|
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"],
|
|
7
7
|
"verified-local-operation-defer-to-host": ["本地规则检查通过,未调用模型;仍需遵守宿主权限", "Local checks passed without a model call; host permissions still apply"],
|
|
8
|
+
"safe-local-operation-host-sandbox": ["本地规则已判定工作区内安全操作,交宿主沙箱执行", "Local checks found a safe in-workspace operation; the host sandbox still applies"],
|
|
9
|
+
"tolerance-delegate": ["安全档位放行此次调用(仅软审查范围)", "The safety level allowed this call within soft review"],
|
|
10
|
+
"tolerance-deny": ["安全档位在无审批通道时阻止了此次调用", "The safety level denied this call because no approval channel exists"],
|
|
11
|
+
"unclassified": ["未分类的裁决原因,详见审计明细", "Unclassified decision reason; see the audit detail"],
|
|
8
12
|
"model-material-risk": ["决策模型提示可能有风险,需要进一步审查", "The decision model flagged potential risk for further review"],
|
|
9
13
|
"model-injection-suspect": ["决策模型发现疑似提示注入,需要进一步审查", "The decision model flagged suspected prompt injection"],
|
|
10
14
|
"model-irreversible-unrelated": ["调用可能不可恢复或偏离任务,需要进一步审查", "Potential irreversible or unrelated effects need further review"],
|
|
@@ -46,21 +50,27 @@ const MESSAGES = {
|
|
|
46
50
|
*/
|
|
47
51
|
export function toleranceAskAction(action) {
|
|
48
52
|
const [zh, en] = MESSAGES["tolerance-ask"];
|
|
49
|
-
|
|
53
|
+
// `humanReason` 保留档位 note(含 severity / 阈值),`reasonCode` 只给稳定
|
|
54
|
+
// 枚举值。审计聚合按 reasonCode,人读按 humanReason——两者不能互换。
|
|
55
|
+
return { ...action, reasonCode: "tolerance-ask", displayReason: { zh, en }, humanReason: action.reason };
|
|
50
56
|
}
|
|
51
57
|
|
|
52
58
|
export function guardOutcomeMessage(action, mandatory) {
|
|
53
|
-
// 已显式带 `reasonCode`
|
|
54
|
-
// 是权威口径,`reason` 是调用方给的审计原文,这里只补/保留
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
// 那条契约,标题必须说日常语言。
|
|
59
|
+
// 已显式带 `reasonCode` 的裁决(tolerance 触发的 ask / delegate / deny):
|
|
60
|
+
// `reasonCode` 是权威口径,`reason` 是调用方给的审计原文,这里只补/保留
|
|
61
|
+
// `displayReason`。若走下面的通用分支,`reason` 会被文案表覆盖(档位 note
|
|
62
|
+
// 丢失),`displayReason` 又会被填成与 `reason` 逐字相同的旧口径。
|
|
58
63
|
const explicit = action.reasonCode !== undefined && Object.hasOwn(MESSAGES, action.reasonCode) ? MESSAGES[action.reasonCode] : undefined;
|
|
59
64
|
if (explicit) {
|
|
60
65
|
return { ...action, ...(action.kind === "ask" ? { displayReason: action.displayReason ?? { zh: explicit[0], en: explicit[1] } } : {}) };
|
|
61
66
|
}
|
|
62
67
|
const code = action.reason ?? mandatory?.reasonCodes?.[0] ?? action.kind;
|
|
63
68
|
const message = MESSAGES[code];
|
|
64
|
-
if (!message)
|
|
69
|
+
if (!message) {
|
|
70
|
+
// 兜底:`reason` 不是已知枚举(例如档位 note、模型文案)。绝不能把它
|
|
71
|
+
// 直接当 reasonCode——那会把 61 种中文长句灌进机器聚合字段,英文界面
|
|
72
|
+
// 也会显示中文。稳定值统一为 `unclassified`,原文进 humanReason。
|
|
73
|
+
return { ...action, reasonCode: "unclassified", humanReason: action.humanReason ?? code, ...(action.kind === "ask" ? { displayReason: { zh: MESSAGES.unclassified[0], en: MESSAGES.unclassified[1] } } : {}) };
|
|
74
|
+
}
|
|
65
75
|
return { ...action, reason: message[0], reasonCode: code, ...(action.kind === "ask" ? { displayReason: { zh: message[0], en: message[1] } } : {}) };
|
|
66
76
|
}
|
package/lib/index.js
CHANGED
|
@@ -2221,9 +2221,11 @@ class StartluxClient {
|
|
|
2221
2221
|
const apiInputTokens = typeof usage.input_tokens === "number" ? usage.input_tokens : undefined;
|
|
2222
2222
|
const apiOutputTokens = typeof usage.output_tokens === "number" ? usage.output_tokens : undefined;
|
|
2223
2223
|
const inputSource = apiInputTokens === undefined ? "estimated" : "api";
|
|
2224
|
-
|
|
2224
|
+
// output token 没有估算器:缺了就是**未知**,不能写成 0 还标 estimated。
|
|
2225
|
+
// 写成 0 会把「不知道」伪装成「测得 0」,总量被系统性低估(审计 P0-1)。
|
|
2226
|
+
const outputSource = apiOutputTokens === undefined ? "missing" : "api";
|
|
2225
2227
|
const inputTokens = apiInputTokens ?? estimateRequestTokens(bodyText);
|
|
2226
|
-
const outputTokens = apiOutputTokens ??
|
|
2228
|
+
const outputTokens = apiOutputTokens ?? null;
|
|
2227
2229
|
const result = {
|
|
2228
2230
|
answers,
|
|
2229
2231
|
model: typeof body.model === "string" ? body.model : model || "(server-default)",
|
|
@@ -2242,10 +2244,11 @@ class StartluxClient {
|
|
|
2242
2244
|
outputTokens: apiOutputTokens,
|
|
2243
2245
|
inputSource,
|
|
2244
2246
|
outputSource,
|
|
2245
|
-
// 统计用的 token
|
|
2246
|
-
//
|
|
2247
|
+
// 统计用的 token 数(含估算)。input 有估算器;output 没有——缺了
|
|
2248
|
+
// 就是 null + missing,聚合时不进入总量,只计「未计量条数」。
|
|
2247
2249
|
statInputTokens: inputTokens,
|
|
2248
2250
|
statOutputTokens: outputTokens,
|
|
2251
|
+
outputTokensMissing: apiOutputTokens === undefined,
|
|
2249
2252
|
cached: false,
|
|
2250
2253
|
};
|
|
2251
2254
|
|
|
@@ -2280,6 +2283,7 @@ class StartluxClient {
|
|
|
2280
2283
|
mode: call.pointMode ?? "unknown",
|
|
2281
2284
|
inputTokens,
|
|
2282
2285
|
outputTokens,
|
|
2286
|
+
outputTokensMissing: apiOutputTokens === undefined,
|
|
2283
2287
|
durationMs: result.latencyMs,
|
|
2284
2288
|
inputSource,
|
|
2285
2289
|
outputSource,
|
|
@@ -2600,12 +2604,27 @@ export function apply(ctx, config) {
|
|
|
2600
2604
|
const level = cfg.telemetryLevel ?? DEFAULTS.telemetryLevel;
|
|
2601
2605
|
// off 时连读取都省掉:这是「关掉就是真的不联网」的执行点之一。
|
|
2602
2606
|
if (level === "off") return;
|
|
2607
|
+
/**
|
|
2608
|
+
* 统计**上一完整自然日**,而不是「今天到目前为止」。
|
|
2609
|
+
*
|
|
2610
|
+
* 审计 P0-3:旧实现取启动时的「今天」计数,上午启动时只看到当天前几次
|
|
2611
|
+
* 决策,随后全天几百次都不会再更新;服务端按收到日期归档,于是这一天
|
|
2612
|
+
* 的 decisions 被系统性低估。改为日终口径:本次上报的是昨天 00:00 到
|
|
2613
|
+
* 今天 00:00 的完整计数,并用 `day` 明确告诉服务端它属于哪一天。
|
|
2614
|
+
*/
|
|
2615
|
+
const now = new Date();
|
|
2616
|
+
const end = new Date(now);
|
|
2617
|
+
end.setHours(0, 0, 0, 0);
|
|
2618
|
+
const start = new Date(end);
|
|
2619
|
+
start.setDate(start.getDate() - 1);
|
|
2620
|
+
const pad = (value) => String(value).padStart(2, "0");
|
|
2621
|
+
const localStamp = (date) =>
|
|
2622
|
+
`${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
|
2623
|
+
const day = `${start.getFullYear()}-${pad(start.getMonth() + 1)}-${pad(start.getDate())}`;
|
|
2603
2624
|
let decisions = 0;
|
|
2604
2625
|
try {
|
|
2605
|
-
// 只统计「今天」的决策数:心跳的语义是「今天用没用、用多少」。
|
|
2606
|
-
// 聚合失败就当 0(不上报该字段),不影响上报本身。
|
|
2607
2626
|
const rows = await statsStore.read();
|
|
2608
|
-
const range = resolveRange({ preset: "
|
|
2627
|
+
const range = resolveRange({ preset: "custom", start: localStamp(start), end: localStamp(end) });
|
|
2609
2628
|
if (range.valid === true) {
|
|
2610
2629
|
const result = aggregate(rows, range);
|
|
2611
2630
|
// 字段名是 `requests`(见 stats.js 的 emptyBucket)。这里曾写成
|
|
@@ -2629,6 +2648,7 @@ export function apply(ctx, config) {
|
|
|
2629
2648
|
// 宿主版本拿不到就留空,buildPayload 会整字段省略(不猜、不伪造)。
|
|
2630
2649
|
hostVersion: "",
|
|
2631
2650
|
decisions,
|
|
2651
|
+
day,
|
|
2632
2652
|
});
|
|
2633
2653
|
};
|
|
2634
2654
|
// 不 await:首次运行会真的发一次网络请求,绝不能拖住插件启动。
|
|
@@ -4178,6 +4198,16 @@ export function apply(ctx, config) {
|
|
|
4178
4198
|
.catch((err) => {
|
|
4179
4199
|
ctx.logger?.warn?.(`dsh-decision-engine: 匿名统计跳过(不影响插件):${String(err?.message ?? err)}`);
|
|
4180
4200
|
});
|
|
4201
|
+
// 跨日补报(审计 P0-3):长驻进程不会重新启动,只在启动时上报就永远
|
|
4202
|
+
// 只有第一天的心跳。每 6 小时检查一次;`decideKind` 用「今天是否已上报」
|
|
4203
|
+
// 去重,所以同一天不会重复发,跨日后会补报刚结束的那一天。
|
|
4204
|
+
const telemetryTimer = setInterval(() => {
|
|
4205
|
+
void reloadOwnConfig()
|
|
4206
|
+
.catch(() => {})
|
|
4207
|
+
.then(() => startTelemetry())
|
|
4208
|
+
.catch(() => {});
|
|
4209
|
+
}, 6 * 60 * 60 * 1000);
|
|
4210
|
+
telemetryTimer.unref?.();
|
|
4181
4211
|
|
|
4182
4212
|
}
|
|
4183
4213
|
|
package/lib/stats.js
CHANGED
|
@@ -74,6 +74,14 @@ export const TOKEN_SOURCES = {
|
|
|
74
74
|
estimated: "estimated",
|
|
75
75
|
/** 拿不到任何值(0)。 */
|
|
76
76
|
unknown: "unknown",
|
|
77
|
+
/**
|
|
78
|
+
* 服务端明确没有返回这个计量值。
|
|
79
|
+
*
|
|
80
|
+
* 与 `unknown` 的区别:`unknown` 是「老记录没带来源字段」,`missing` 是
|
|
81
|
+
* 「这次请求确实没有 output token 可用」。两者都不能当成 0 参与总量,
|
|
82
|
+
* 但 `missing` 需要单独计数以便报告覆盖率。
|
|
83
|
+
*/
|
|
84
|
+
missing: "missing",
|
|
77
85
|
};
|
|
78
86
|
|
|
79
87
|
/**
|
|
@@ -149,7 +157,8 @@ export function buildRecord(options = {}) {
|
|
|
149
157
|
const num = (value) => (typeof value === "number" && Number.isFinite(value) ? value : 0);
|
|
150
158
|
const source = (value) =>
|
|
151
159
|
TOKEN_SOURCES[value] === TOKEN_SOURCES.api ||
|
|
152
|
-
TOKEN_SOURCES[value] === TOKEN_SOURCES.estimated
|
|
160
|
+
TOKEN_SOURCES[value] === TOKEN_SOURCES.estimated ||
|
|
161
|
+
TOKEN_SOURCES[value] === TOKEN_SOURCES.missing
|
|
153
162
|
? value
|
|
154
163
|
: TOKEN_SOURCES.unknown;
|
|
155
164
|
const inputTokens = num(options.inputTokens);
|
|
@@ -187,6 +196,15 @@ export function buildRecord(options = {}) {
|
|
|
187
196
|
baseDurationMs,
|
|
188
197
|
inputSource: source(options.inputSource),
|
|
189
198
|
outputSource: source(options.outputSource),
|
|
199
|
+
/**
|
|
200
|
+
* output token 未知。
|
|
201
|
+
*
|
|
202
|
+
* 此前 `outputTokens` 缺失时被写成 0、来源却标 estimated——等于把一个
|
|
203
|
+
* 「不知道」伪装成「测得 0」,总量被系统性低估。现在显式带一个标记,
|
|
204
|
+
* 聚合时**不把未知值加进已知总量**,只累计未计量条数,界面再报覆盖率。
|
|
205
|
+
*/
|
|
206
|
+
outputTokensMissing:
|
|
207
|
+
options.outputTokensMissing === true || options.outputSource === "missing",
|
|
190
208
|
};
|
|
191
209
|
}
|
|
192
210
|
|
|
@@ -414,6 +432,10 @@ function normalizeRecord(raw) {
|
|
|
414
432
|
baseDurationMs: num(raw?.baseDurationMs),
|
|
415
433
|
inputSource: typeof raw?.inputSource === "string" ? raw.inputSource : "unknown",
|
|
416
434
|
outputSource: typeof raw?.outputSource === "string" ? raw.outputSource : "unknown",
|
|
435
|
+
outputTokensMissing:
|
|
436
|
+
raw?.outputTokensMissing === true || raw?.outputSource === "missing",
|
|
437
|
+
/** 缓存命中的基线是否来自历史插补(审计 P1:不再插补,只标记)。 */
|
|
438
|
+
baselineEstimated: raw?.baselineEstimated === true,
|
|
417
439
|
// 预拼的「provider\0model」组合键:aggregate 过去每条记录拼一次、
|
|
418
440
|
// 基线补齐再拼一次,两次结果必然相同。放在归一化时算一次即可。
|
|
419
441
|
// 前缀 `\u0000` 保证它不会与被当作记录字段读取的键撞名。
|
|
@@ -454,6 +476,10 @@ export function isInRange(record, range) {
|
|
|
454
476
|
* @param record - 已规范化的记录。
|
|
455
477
|
*/
|
|
456
478
|
function accumulate(bucket, record) {
|
|
479
|
+
// 「尝试」= 发出去(或准备发出去)的每一次,含降级;「请求」= 拿到回答的
|
|
480
|
+
// 那部分。两者分开后,趋势图与汇总表可以用同一个分母对账:
|
|
481
|
+
// attempts = requests + degraded
|
|
482
|
+
bucket.attempts += 1;
|
|
457
483
|
if (record.degraded === true) {
|
|
458
484
|
bucket.degraded += 1;
|
|
459
485
|
bucket.degradedDurationMs += record.durationMs;
|
|
@@ -462,7 +488,12 @@ function accumulate(bucket, record) {
|
|
|
462
488
|
bucket.requests += 1;
|
|
463
489
|
if (record.cached === true) bucket.cached += 1;
|
|
464
490
|
bucket.inputTokens += record.inputTokens;
|
|
465
|
-
|
|
491
|
+
if (record.outputTokensMissing === true) {
|
|
492
|
+
// 未知值不进入已知总量,只计数;界面据此报「N 条未计量」。
|
|
493
|
+
bucket.outputTokensUnmeasured += 1;
|
|
494
|
+
} else {
|
|
495
|
+
bucket.outputTokens += record.outputTokens;
|
|
496
|
+
}
|
|
466
497
|
bucket.durationMs += record.durationMs;
|
|
467
498
|
/**
|
|
468
499
|
* 节省量只认**观察模式**的记录。
|
|
@@ -483,10 +514,13 @@ function accumulate(bucket, record) {
|
|
|
483
514
|
if (!contributesSavings(record.mode)) return;
|
|
484
515
|
bucket.observeRequests += 1;
|
|
485
516
|
bucket.observeInputTokens += record.inputTokens;
|
|
486
|
-
|
|
517
|
+
// output 未知时基线也无从复核:只跳过 output 这一对,不把 0 当实测。
|
|
518
|
+
if (record.outputTokensMissing !== true) {
|
|
519
|
+
bucket.observeOutputTokens += record.outputTokens;
|
|
520
|
+
bucket.observeBaseOutputTokens += record.baseOutputTokens;
|
|
521
|
+
}
|
|
487
522
|
bucket.observeDurationMs += record.durationMs;
|
|
488
523
|
bucket.observeBaseInputTokens += record.baseInputTokens;
|
|
489
|
-
bucket.observeBaseOutputTokens += record.baseOutputTokens;
|
|
490
524
|
bucket.observeBaseDurationMs += record.baseDurationMs;
|
|
491
525
|
}
|
|
492
526
|
|
|
@@ -510,6 +544,8 @@ function resetBucket(bucket) {
|
|
|
510
544
|
*/
|
|
511
545
|
function emptyBucket() {
|
|
512
546
|
return {
|
|
547
|
+
/** 尝试次数 = requests + degraded;对账用。 */
|
|
548
|
+
attempts: 0,
|
|
513
549
|
requests: 0,
|
|
514
550
|
cached: 0,
|
|
515
551
|
degraded: 0,
|
|
@@ -517,7 +553,11 @@ function emptyBucket() {
|
|
|
517
553
|
degradedDurationMs: 0,
|
|
518
554
|
inputTokens: 0,
|
|
519
555
|
outputTokens: 0,
|
|
556
|
+
/** output token 未知的记录数(不进入 outputTokens,见 accumulate)。 */
|
|
557
|
+
outputTokensUnmeasured: 0,
|
|
520
558
|
durationMs: 0,
|
|
559
|
+
/** 缓存命中却缺基线、无法估计节省的记录数(不插补,只计数)。 */
|
|
560
|
+
estimatedBaselineMissing: 0,
|
|
521
561
|
/**
|
|
522
562
|
* 观察模式专属的累加桶。
|
|
523
563
|
*
|
|
@@ -602,7 +642,10 @@ function finalizeBucket(bucket) {
|
|
|
602
642
|
* 这段过程,所以它不该出现在分母里。
|
|
603
643
|
*/
|
|
604
644
|
const realRequests = Math.max(0, bucket.requests - bucket.cached);
|
|
645
|
+
/** 有 output 计量的真实请求:平均 output 的分母不能把未知值当 0。 */
|
|
646
|
+
const measuredRequests = Math.max(0, realRequests - bucket.outputTokensUnmeasured);
|
|
605
647
|
return {
|
|
648
|
+
attempts: bucket.attempts,
|
|
606
649
|
requests: bucket.requests,
|
|
607
650
|
realRequests,
|
|
608
651
|
cached: bucket.cached,
|
|
@@ -611,6 +654,12 @@ function finalizeBucket(bucket) {
|
|
|
611
654
|
degradedDurationMs: bucket.degradedDurationMs,
|
|
612
655
|
inputTokens: bucket.inputTokens,
|
|
613
656
|
outputTokens: bucket.outputTokens,
|
|
657
|
+
/** output token 未能计量的记录数;>0 时总量是「已知部分」。 */
|
|
658
|
+
outputTokensUnmeasured: bucket.outputTokensUnmeasured,
|
|
659
|
+
/** 有 output 计量的真实请求数(= realRequests - 未计量)。 */
|
|
660
|
+
measuredRequests,
|
|
661
|
+
/** 缓存命中的历史记录里缺基线、无法估计节省的条数。 */
|
|
662
|
+
estimatedBaselineMissing: bucket.estimatedBaselineMissing,
|
|
614
663
|
totalTokens: bucket.inputTokens + bucket.outputTokens,
|
|
615
664
|
durationMs: bucket.durationMs,
|
|
616
665
|
/**
|
|
@@ -635,7 +684,14 @@ function finalizeBucket(bucket) {
|
|
|
635
684
|
savedTimeRatio,
|
|
636
685
|
avgDurationMs: realRequests === 0 ? 0 : Math.round(bucket.durationMs / realRequests),
|
|
637
686
|
avgInputTokens: realRequests === 0 ? 0 : Math.round(bucket.inputTokens / realRequests),
|
|
638
|
-
avgOutputTokens:
|
|
687
|
+
avgOutputTokens: measuredRequests === 0 ? 0 : Math.round(bucket.outputTokens / measuredRequests),
|
|
688
|
+
/**
|
|
689
|
+
* 节省量的**口径**。`decision_request` = 只看决策请求这一层:
|
|
690
|
+
* 缓存去重 + 输入处理,不含主 Agent / 子代理 / 工具开销。
|
|
691
|
+
* 完整 Agent 净节省只能靠配对 A/B(尚未实现),界面文案必须按这个
|
|
692
|
+
* 口径解释,不能把它说成「总体省了多少」。
|
|
693
|
+
*/
|
|
694
|
+
savedRatioScope: "decision_request",
|
|
639
695
|
};
|
|
640
696
|
}
|
|
641
697
|
|
|
@@ -948,10 +1004,20 @@ export function buildSeries(records, range, now = new Date()) {
|
|
|
948
1004
|
buckets.forEach((key, index) => indexOf.set(key, index));
|
|
949
1005
|
|
|
950
1006
|
const series = {
|
|
1007
|
+
/** 尝试次数(含降级);与 totals.attempts 同口径。 */
|
|
1008
|
+
attempts: new Array(buckets.length).fill(0),
|
|
1009
|
+
/** 拿到回答的请求数(不含降级);与 totals.requests 同口径。 */
|
|
951
1010
|
requests: new Array(buckets.length).fill(0),
|
|
1011
|
+
/** 降级次数;与 totals.degraded 同口径。 */
|
|
1012
|
+
degraded: new Array(buckets.length).fill(0),
|
|
952
1013
|
inputTokens: new Array(buckets.length).fill(0),
|
|
953
1014
|
outputTokens: new Array(buckets.length).fill(0),
|
|
1015
|
+
/** 该桶里 output token 未知的记录数(不进入 outputTokens)。 */
|
|
1016
|
+
outputTokensUnmeasured: new Array(buckets.length).fill(0),
|
|
1017
|
+
/** 已回答请求的耗时(不含降级);与 totals.durationMs 同口径。 */
|
|
954
1018
|
durationMs: new Array(buckets.length).fill(0),
|
|
1019
|
+
/** 降级累计等待耗时;与 totals.degradedDurationMs 同口径。 */
|
|
1020
|
+
degradedDurationMs: new Array(buckets.length).fill(0),
|
|
955
1021
|
allow: new Array(buckets.length).fill(0),
|
|
956
1022
|
ask: new Array(buckets.length).fill(0),
|
|
957
1023
|
reject: new Array(buckets.length).fill(0),
|
|
@@ -964,11 +1030,21 @@ export function buildSeries(records, range, now = new Date()) {
|
|
|
964
1030
|
// 落在区间外的记录(理论上已被筛掉)直接跳过:宁少一个点,
|
|
965
1031
|
// 也不要把它的值塞进错误的桶里——那会让图与表对不上。
|
|
966
1032
|
if (index === undefined) continue;
|
|
1033
|
+
series.attempts[index] += 1;
|
|
1034
|
+
if (record.degraded === true) {
|
|
1035
|
+
// 与 accumulate 完全同口径:降级只进 attempts / degraded,
|
|
1036
|
+
// 不进 requests / token / 回答耗时,否则「分桶之和」又对不上汇总。
|
|
1037
|
+
series.degraded[index] += 1;
|
|
1038
|
+
series.degradedDurationMs[index] += record.durationMs;
|
|
1039
|
+
series.none[index] += 1;
|
|
1040
|
+
continue;
|
|
1041
|
+
}
|
|
967
1042
|
series.requests[index] += 1;
|
|
968
1043
|
series.inputTokens[index] += record.inputTokens;
|
|
969
|
-
series.
|
|
1044
|
+
if (record.outputTokensMissing === true) series.outputTokensUnmeasured[index] += 1;
|
|
1045
|
+
else series.outputTokens[index] += record.outputTokens;
|
|
970
1046
|
series.durationMs[index] += record.durationMs;
|
|
971
|
-
const hasVerdict = record.action.length > 0
|
|
1047
|
+
const hasVerdict = record.action.length > 0;
|
|
972
1048
|
const outcome = hasVerdict ? outcomeOf(record.point, record.action) : "none";
|
|
973
1049
|
series[outcome === "none" ? "none" : outcome][index] += 1;
|
|
974
1050
|
}
|
|
@@ -1115,59 +1191,27 @@ export function aggregate(records, range) {
|
|
|
1115
1191
|
* 而历史记录里缓存命中的占比往往不低(实测 telemetry.jsonl 里 678/2029
|
|
1116
1192
|
* 条是缓存命中),只补耗时会让节省量看起来偏小。
|
|
1117
1193
|
*/
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
* token 数),但**耗时**基线老遥测确实没有。若按「任一基线存在就跳过」
|
|
1140
|
-
* 判断,这批记录的耗时节省会永远是 0——而缓存命中省下的时间恰恰是
|
|
1141
|
-
* 用户最想看到的数字。
|
|
1142
|
-
*/
|
|
1143
|
-
if (record.baseInputTokens === 0) {
|
|
1144
|
-
record.baseInputTokens = Math.round(sample.input / sample.count);
|
|
1145
|
-
needsRecount = true;
|
|
1146
|
-
}
|
|
1147
|
-
if (record.baseOutputTokens === 0) {
|
|
1148
|
-
record.baseOutputTokens = Math.round(sample.output / sample.count);
|
|
1149
|
-
needsRecount = true;
|
|
1150
|
-
}
|
|
1151
|
-
if (record.baseDurationMs === 0) {
|
|
1152
|
-
record.baseDurationMs = Math.round(sample.duration / sample.count);
|
|
1153
|
-
needsRecount = true;
|
|
1154
|
-
}
|
|
1155
|
-
}
|
|
1156
|
-
|
|
1157
|
-
// 基线补齐后要重新累加一遍:上面的 accumulate 发生在补齐之前,
|
|
1158
|
-
// 补出来的基线不会自动进桶。重新累加的代价是一次 O(n) 遍历,
|
|
1159
|
-
// 与统计本身相比可忽略。
|
|
1160
|
-
if (needsRecount) {
|
|
1161
|
-
for (const bucket of [totals, ...byCombo.values(), ...byProvider.values(), ...byPoint.values()]) {
|
|
1162
|
-
resetBucket(bucket);
|
|
1163
|
-
}
|
|
1164
|
-
for (const record of scoped) {
|
|
1165
|
-
accumulate(totals, record);
|
|
1166
|
-
accumulate(byCombo.get(record.comboKey), record);
|
|
1167
|
-
accumulate(byProvider.get(record.provider), record);
|
|
1168
|
-
accumulate(byPoint.get(record.point), record);
|
|
1169
|
-
}
|
|
1170
|
-
}
|
|
1194
|
+
/**
|
|
1195
|
+
* 历史缓存命中的基线缺失——**不再用查询区间的均值插补**。
|
|
1196
|
+
*
|
|
1197
|
+
* 均值插补有三个确认的问题:
|
|
1198
|
+
* 1. 同一模型同时处理很短与很长的请求,按模型分组的均值不保证
|
|
1199
|
+
* 与被插补请求同分布;
|
|
1200
|
+
* 2. 较早的命中可以借用同窗口内较晚发生的请求作基线(时间泄漏);
|
|
1201
|
+
* 3. 同一记录在不同查询窗口会得到不同基线,节省量不可加
|
|
1202
|
+
* (S(A∪B) ≠ S(A)+S(B))。
|
|
1203
|
+
*
|
|
1204
|
+
* 现在只计数并上报:节省量只用「写入时已持久化」的精确基线(新记录
|
|
1205
|
+
* 携带 baseInput/baseDuration),缺基线的历史命中不计入节省,但
|
|
1206
|
+
* `estimatedBaselineMissing` 让界面明确告诉用户有多少条无法估计。
|
|
1207
|
+
*/
|
|
1208
|
+
const estimatedBaselineMissing = scoped.filter(
|
|
1209
|
+
(record) =>
|
|
1210
|
+
record.cached === true
|
|
1211
|
+
&& record.baseInputTokens === 0
|
|
1212
|
+
&& record.baseOutputTokens === 0
|
|
1213
|
+
&& record.baseDurationMs === 0,
|
|
1214
|
+
).length;
|
|
1171
1215
|
|
|
1172
1216
|
const rows = [...byCombo.entries()].map(([comboKey, bucket]) => {
|
|
1173
1217
|
const [provider, model] = comboKey.split("\u0000");
|
|
@@ -1185,6 +1229,26 @@ export function aggregate(records, range) {
|
|
|
1185
1229
|
point,
|
|
1186
1230
|
...bucket,
|
|
1187
1231
|
}));
|
|
1232
|
+
/**
|
|
1233
|
+
* 安全口径(审计 P1):`authorize` 的 reject 才是「安全拒绝」。
|
|
1234
|
+
*
|
|
1235
|
+
* retrieval.skip / skillPick.redirect / route.switch / compact.prune 都是
|
|
1236
|
+
* 流程优化,它们也会产生 reject/allow,但与「有没有放行一次危险调用」
|
|
1237
|
+
* 不是同一件事。把所有点的 reject 合成一个「拦截率」,启用越多优化
|
|
1238
|
+
* 拦截率越高,而安全策略可能一点没变。这里单独给出 authorize 的分账,
|
|
1239
|
+
* 其它点的 reject 只计数、不进入安全拦截率。
|
|
1240
|
+
*/
|
|
1241
|
+
const authorizeOutcome = outcomeByPoint.get("authorize") ?? emptyOutcomeBucket();
|
|
1242
|
+
const safety = {
|
|
1243
|
+
point: "authorize",
|
|
1244
|
+
allow: authorizeOutcome.allow,
|
|
1245
|
+
ask: authorizeOutcome.ask,
|
|
1246
|
+
reject: authorizeOutcome.reject,
|
|
1247
|
+
/** 其它决策点产生的 reject(流程优化,不计入安全拦截率)。 */
|
|
1248
|
+
otherPointRejects: Math.max(0, outcomeTotals.reject - authorizeOutcome.reject),
|
|
1249
|
+
note:
|
|
1250
|
+
"安全拦截率只统计 authorize 点;retrieval / skillPick / route / compact 的 reject 属于流程优化,不计入。",
|
|
1251
|
+
};
|
|
1188
1252
|
/**
|
|
1189
1253
|
* 裁决总览:三格 + 未裁决 + 未分类明细。
|
|
1190
1254
|
*
|
|
@@ -1244,6 +1308,10 @@ export function aggregate(records, range) {
|
|
|
1244
1308
|
*/
|
|
1245
1309
|
series,
|
|
1246
1310
|
recordCount: scoped.length,
|
|
1311
|
+
/** 缺基线、无法估计节省的历史缓存命中条数(不插补,见上)。 */
|
|
1312
|
+
estimatedBaselineMissing,
|
|
1313
|
+
/** 安全分账:只有 authorize 点算安全拦截(见上)。 */
|
|
1314
|
+
safety,
|
|
1247
1315
|
/**
|
|
1248
1316
|
* 节省量的**来源说明**。
|
|
1249
1317
|
*
|
package/lib/telemetry.js
CHANGED
|
@@ -172,6 +172,17 @@ function shortField(value, max = 24) {
|
|
|
172
172
|
return String(value ?? "").trim().replace(/[^0-9A-Za-z._+-]/g, "").slice(0, max);
|
|
173
173
|
}
|
|
174
174
|
|
|
175
|
+
/**
|
|
176
|
+
* 规范化上报的日期(`YYYY-MM-DD`)。
|
|
177
|
+
*
|
|
178
|
+
* @param value - 原始值。
|
|
179
|
+
* @returns 合法日期串;非法返回空串(字段省略,服务端按收到时间归档)。
|
|
180
|
+
*/
|
|
181
|
+
export function normalizeDay(value) {
|
|
182
|
+
const text = String(value ?? "").trim();
|
|
183
|
+
return /^\d{4}-\d{2}-\d{2}$/.test(text) ? text : "";
|
|
184
|
+
}
|
|
185
|
+
|
|
175
186
|
/**
|
|
176
187
|
* 插件标识(npm 包名的合法形态):小写,字母数字与 `-` / `.` / `_`,最长 48。
|
|
177
188
|
*
|
|
@@ -201,7 +212,8 @@ export function normalizePluginId(value) {
|
|
|
201
212
|
* @param input.platform - `process.platform`。
|
|
202
213
|
* @param input.arch - `process.arch`。
|
|
203
214
|
* @param input.hostVersion - 宿主版本(拿不到时为空串)。
|
|
204
|
-
* @param input.decisions -
|
|
215
|
+
* @param input.decisions - 上一完整自然日的决策数(0 表示没有,不上报该字段)。
|
|
216
|
+
* @param input.day - 这组计数属于哪一天(`YYYY-MM-DD`,可选);服务端据此归档。
|
|
205
217
|
* @returns 可直接 `JSON.stringify` 的对象。
|
|
206
218
|
*/
|
|
207
219
|
export function buildPayload(input = {}) {
|
|
@@ -218,6 +230,11 @@ export function buildPayload(input = {}) {
|
|
|
218
230
|
};
|
|
219
231
|
const host = normalizeVersion(input.hostVersion);
|
|
220
232
|
if (host.length > 0) payload.host = host;
|
|
233
|
+
// 客户端声明的「这组决策数属于哪一天」。日终/次日上报上一完整自然日时,
|
|
234
|
+
// 服务端必须按这个日期落库,而不是按收到请求的日期——否则昨天的计数会
|
|
235
|
+
// 被记到今天(审计 P0-3)。非法值不带,服务端退回收到时间。
|
|
236
|
+
const day = normalizeDay(input.day);
|
|
237
|
+
if (day.length > 0) payload.day = day;
|
|
221
238
|
const decisions = Number(input.decisions);
|
|
222
239
|
if (Number.isFinite(decisions) && decisions > 0) payload.d = Math.min(Math.trunc(decisions), 100000);
|
|
223
240
|
return payload;
|
|
@@ -386,6 +403,7 @@ export class TelemetryReporter {
|
|
|
386
403
|
arch: input.arch,
|
|
387
404
|
hostVersion: input.hostVersion,
|
|
388
405
|
decisions: input.decisions,
|
|
406
|
+
day: input.day,
|
|
389
407
|
});
|
|
390
408
|
return { state: next, payload, today, kind };
|
|
391
409
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@leonhardthust/dsh-decision-engine",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.15.0",
|
|
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",
|