dsh-decision-engine 0.0.0-stage → 0.5.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 zuosus
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,486 @@
1
- # Temporary Holding Version
1
+ # dsh-decision-engine
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ 给 DeepSeek Harness 的 System-1 决策守卫:在每次工具调用前问模型四个问题
4
+ (风险、可逆性、任务相关性、注入嫌疑),据此放行、询问或拦截。默认 `shadow`:
5
+ 只记录判断,绝不打断你干活。
6
+
7
+ **它支持所有 system-one 风格 API**:内置 `system one` 与
8
+ `bamboonode` 两个预设,也可以填任意 `custom` base URL(自建 llama.cpp / vLLM、
9
+ 企业网关、第三方中转)。请求路径、鉴权方式与响应结构与 system-one 协议保持一致,
10
+ 换 provider 不影响各决策点的解析逻辑。
11
+
12
+ > **本项目原名 `dsh-startlux-decision`**(更早叫 `dsh-startlux-guard`)。
13
+ > 0.4.0 起更名为 `dsh-decision-engine` 并加入 provider 抽象。
14
+ >
15
+ > **0.5.0 起配置文件名从 `startlux.yml` 改为 `config.yml`**——本插件已不是
16
+ > 只服务 startlux 一家,名字里再挂某个服务商只会误导。旧文件会被**自动改名
17
+ > 迁移**,升级不需要重新填 key;迁移失败时仍会回退读取旧文件名。
18
+
19
+ ## 安装
20
+
21
+ ### 从 npm 安装(推荐)
22
+
23
+ ```sh
24
+ dsh plugin add dsh-decision-engine
25
+ ```
26
+
27
+ 或手动装进 profile:
28
+
29
+ ```sh
30
+ cd ~/.dsh/profiles/desktop
31
+ pnpm add dsh-decision-engine
32
+ ```
33
+
34
+ 然后在 profile 的 `package.json` → `dsh.profile.bundles` 里加入
35
+ `"dsh-decision-engine"`,重启 DSH。
36
+
37
+ ### 本地开发
38
+
39
+ ```sh
40
+ git clone https://github.com/zuosus/dsh-decision-engine.git
41
+ cd dsh-decision-engine
42
+ # 复制到 profile 的 node_modules,或 pnpm link
43
+ ```
44
+
45
+ 仓库里带了迁移脚本,不用手改(默认只预览,`--write` 才写入并自动备份):
46
+
47
+ ```sh
48
+ node scripts/migrate-profile.mjs # 预览
49
+ node scripts/migrate-profile.mjs --write # 写入 + 备份 .bak
50
+ cd ~/.dsh/profiles/desktop && pnpm install
51
+ ```
52
+
53
+ 然后重启 DSH。**配置不用重填**:插件在启动时会做一次自动迁移,
54
+ 把旧目录里的配置与遥测拷到新目录——
55
+
56
+ | | 路径 |
57
+ |---|---|
58
+ | 数据目录(迁移目标) | `~/.dsh/dsh-decision-engine/` |
59
+
60
+ 迁移规则:
61
+
62
+ - **只搬一次**:新目录已有 `config.yml` 就不再动,绝不覆盖你后来改的值。
63
+ - **配置改名**:新目录里的旧名 `startlux.yml` 会被 `rename` 成 `config.yml`
64
+ (rename 失败则拷贝);跨目录迁移是**拷贝**,源文件保留,出问题可对照。
65
+ - **只搬两个文件**:配置文件与 `telemetry.jsonl`。目录里其它东西
66
+ (笔记、备份)一概不碰。
67
+ - **权限 600**:配置里可能有明文 key,迁移后仍保持只有你可读。
68
+
69
+ ## 配置
70
+
71
+ ### 插件配置页
72
+
73
+ **插件 → 已安装 → dsh-decision-engine → 点进该插件**,配置区(「包含的组件」
74
+ **上方**)会出现 Decision Engine 卡片,布局与内置 `dsh-rewind-plugin` 一致:
75
+
76
+ - **服务提供方**下拉:`system one` / `bamboonode` / `custom`,下面一行显示当前生效端点
77
+ - 两个开关:`启用守卫`、`真正拦截`
78
+ - **思考模式**下拉:`never` / `auto` / `always`(见下文「省 token 的手段」里的取舍)
79
+ - **模型**下拉(来自当前 provider 的 `/v1/models`,含「自动(服务端默认)」)
80
+ - 一个 **API Key 输入框** + `保存` 按钮
81
+ - 选 `custom` 时展开四个字段:`Base URL`(必填)、`端点风格`、`附加请求头`、`密钥变量名`
82
+ - 一个 `测试端点` 按钮(回显 provider、实际 POST 的完整地址、HTTP 码、请求 id 与响应体)
83
+ - **决策模型参与点**列表:每个决策点一行,右侧是 `关闭 / 观察 / 生效` 三态
84
+ - `自身 token 预算` 输入框,以及实时的缓存命中率与每个点的 token / 延迟统计
85
+
86
+ 改完点`保存`即写入 `config.yml`,**不需要重启**:监听器常驻注册,provider 与
87
+ 模式在每次调用时现读;预算与缓存参数同样是取值函数而非启动期快照。
88
+
89
+ ## Provider:接任意 system-one 风格服务
90
+
91
+ 请求形状对所有 provider 完全一致(这也是 system-one 协议本身):
92
+
93
+ ```http
94
+ POST <base>/decisions 或 POST <base>/systemone
95
+ authorization: Bearer <key>
96
+ content-type: application/json
97
+
98
+ { "model": "...", "state": {...}, "questions": {...}, "think_mode": "never" }
99
+ ```
100
+
101
+ 响应只需含 `answers`(`model` / `usage` 可选)。因此 provider 只决定三件事:
102
+ **往哪发、用谁的 key、默认哪个模型**。
103
+
104
+ ### 三个选项
105
+
106
+ | provider | 默认地址 | 端点 | 密钥名 | 默认模型 |
107
+ |---|---|---|---|---|
108
+ | `system one` | `https://api.typesafe.ai/v1` | `/v1/systemone` | `TYPESAFE_API_KEY` | 服务端默认(`jev-latest`) |
109
+ | `bamboonode` | `https://api.bamboonode.dev/v1` | `/v1/decisions` | `BAMBOO_API_KEY` | 服务端默认(`startlux-decision-27b-autothink`) |
110
+ | `custom` | 用户填写(必填) | 按 `端点风格` 拼 | `customApiKeyEnv`(缺省 `DECISION_ENGINE_CUSTOM_KEY`) | `customModel` |
111
+
112
+ **每个 provider 有各自的 key 名,各自保存各自的 key。** 三者互不覆盖,
113
+ 可以同时配好三套凭据;切换 provider 不会串用别人的 key。
114
+
115
+ 不写 `provider` 时等于 `bamboonode`,**老配置行为完全不变**。
116
+
117
+ 模型字段按 provider **分开存**,切换 provider 不会把 A 的模型名发给 B:
118
+
119
+ | provider | 配置键 | 默认 |
120
+ |---|---|---|
121
+ | `bamboonode` | `model` | 与历史一致(留空即服务端默认) |
122
+ | `system one` | `systemoneModel` | 留空 = 服务端默认(`jev-latest`) |
123
+ | `custom` | `customModel` | 留空 = 服务端默认 |
124
+
125
+ ### custom:填写方式与限制
126
+
127
+ 在设置界面选 `custom` 后需要填:
128
+
129
+ | 字段 | 必填 | 说明 |
130
+ |---|---|---|
131
+ | `Base URL` | **是** | 必须是 `http://` 或 `https://` 地址。不支持其它协议(`ftp:` 等会被拒绝),不带 scheme 也会被拒绝——自定义地址必须显式,避免猜错协议。不能含空格、换行或查询串。 |
132
+ | `端点风格` | 否 | `auto`(默认)/ `decisions` / `systemone`。`auto` 在地址已带 `/decisions` 或 `/systemone` 时原样使用。 |
133
+ | `附加请求头` | 否 | 格式 `名称: 值`,多项用分号分隔,如 `X-Tenant: team-a; X-Trace: dsh`。`authorization` / `content-type` / `host` 由插件固定,写了也会被忽略。 |
134
+ | `密钥变量名` | 否 | 填了就从该环境变量或 `~/.dsh/.credentials.yaml` 的 `refs:` 段取 key;也可以直接在 API Key 输入框粘贴。 |
135
+ | `模型` | 否 | 留空 = 由服务端选默认;实测留空比发空串安全(发 `"model": ""` 会被 400)。 |
136
+
137
+ **地址拼接规则**(`末尾斜杠`一律先去掉,因此 `.../v1/` 与 `.../v1` 等价):
138
+
139
+ | 你填的 base URL | 端点风格 | 实际 POST |
140
+ |---|---|---|
141
+ | `https://x.dev` | decisions | `https://x.dev/v1/decisions` |
142
+ | `https://x.dev/v1` | decisions | `https://x.dev/v1/decisions` |
143
+ | `https://x.dev/v1/decisions` | decisions | `https://x.dev/v1/decisions`(不重复) |
144
+ | `https://x.dev/api/v1` | decisions | `https://x.dev/api/v1/decisions` |
145
+ | `https://x.dev/v1` | systemone | `https://x.dev/v1/systemone` |
146
+ | `https://x.dev/v1/systemone` | decisions | `https://x.dev/v1/systemone`(已带端点即原样) |
147
+
148
+ 拼接是幂等的:对结果再拼一次不会变成 `/v1/v1/decisions` 或 `//decisions`。
149
+
150
+ ### 持久化与切换行为
151
+
152
+ - 所有 provider 的配置**并列**存在 `config.yml` 里,互不覆盖:
153
+ `provider` 记录当前选择,`baseUrl`/`model` 属于内置预设(bamboonode 的
154
+ `baseUrl` 保留老的覆盖语义),`customBaseUrl`/`customModel`/`customApiKey`/
155
+ `customApiKeyEnv`/`customHeaders`/`customEndpointStyle` 属于 custom。
156
+ - 切换 provider 只改 `provider` 一个键:**其它 provider 已保存的地址、模型、密钥
157
+ 原样留着**,切回来即恢复。
158
+ - 保存后再次打开配置页按服务端返回的状态回显(不缓存、不推断),custom 的字段
159
+ 除密钥明文外全部回显。
160
+ - 密钥优先级:该 provider 的显式 key(`systemoneApiKey` / `bamboonodeApiKey` /
161
+ `customApiKey`)> 密钥存储位置上对应的键 > 环境变量。
162
+ - 旧的单一 `apiKey` 字段只对 `bamboonode` 生效(历史兼容)。
163
+
164
+ ### 密钥保存位置
165
+
166
+ 密钥可以放在两个地方,默认 `credentials`:
167
+
168
+ | `keyStorage` | 写到哪里 | 适合 |
169
+ |---|---|---|
170
+ | `credentials`(默认) | `~/.dsh/.credentials.yaml` 的 `refs:` 段 | 与其他服务(DeepSeek / ModelScope …)统一托管,插件目录不留明文 |
171
+ | `config` | 插件配置目录下的 `config.yml` | 希望配置自包含、能随目录整体备份或迁移;注意是**明文** |
172
+
173
+ 在设置界面「密钥保存位置」下拉切换,保存后立即按新位置写入;已有的 key 不会被
174
+ 自动搬家(避免"看不到的地方被改动"),需要时重新粘贴一次即可。
175
+
176
+ 写入 `credentials` 的行为约束:
177
+
178
+ - **只改 `refs:` 段**,`records:`(DSH 的授权记录,含嵌套结构)与注释逐字保留;
179
+ - 键名就是该 provider 自己的名字,所以三套 key 能同时存在、互不覆盖;
180
+ - 传空值等于**删除**该键(清空即撤销,不留空壳行);
181
+ - 文件权限固定 `600`;
182
+ - 写入失败会整体报错,不会出现"配置说存了、实际没存"。
183
+
184
+ 密钥优先级:显式 key 字段 > 所选存储位置里的对应键 > 环境变量。
185
+
186
+ ### 错误与降级
187
+
188
+ | 情形 | 表现 |
189
+ |---|---|
190
+ | base URL 非法 / 为空(custom) | 保存直接被拒(HTTP 400 + 具体原因,如「必须以 http:// 或 https:// 开头」),**不会写进 yml** |
191
+ | 密钥缺失 | 不发请求,`no-key` 降级(记录里带该 provider 的变量名) |
192
+ | 鉴权失败(401/403) | `auth-failed` 降级,提示检查该 provider 的 key 是否有效、是否与地址匹配 |
193
+ | 地址错(404/400) | `http-404` / `http-400` 降级,记录里带 `provider`、`endpoint` 与响应体 |
194
+ | provider 配置不可用 | `invalid-base-url` 降级,不打到猜出来的地址上 |
195
+ | 网络失败 / 超时 | `network-error` / `timeout` 降级,随后按阈值进入冷却 |
196
+
197
+ 降级一律 fail-open:放行原行为,并写一条 `telemetry.jsonl`。
198
+ 「测试端点」按钮会把完整诊断(provider、实际端点、HTTP 码、请求 id、响应体)直接显示出来。
199
+
200
+ ## 决策点(参与哪些环节)
201
+
202
+ 这个插件的定位不是「工具调用风险闸门」,而是**一个便宜的判断层**:用一次
203
+ 约 1.9k token 的小模型调用,去替代或阻止更贵的 LLM 开销。因此判断必须克制——
204
+ **只在「替代更贵的调用」或「阻止更大的开销」处介入,绝不为了「多一道检查」而介入**。
205
+
206
+ 八个决策点**全部已实装**,由你决定让决策模型进入哪些环节。
207
+
208
+ | # | 决策点 | 挂载位置 | 触发闸(为什么不是每次都问) | 生效方式 | 省 token 机制 |
209
+ |---|---|---|---|---|---|
210
+ | 1 | 工具授权 | `tools/pre-execute` | 只读工具 / auto 预设 / 排除名单直接放行 | `ask` / `deny` | 替代 auto-review 的满上下文审查 |
211
+ | 2 | 模型路由 | `agent/request` | 每轮仅前 N 步 + 路由未变不问 + 新用户输入不问 | 替换 `provider`/`model` | 机械步骤下沉到便宜模型 |
212
+ | 3 | 子任务分派 | `agent/pre-step` | 仅每轮第一步且带新用户输入 | 注入一条提示 | 少开一个 subagent = 省一整个上下文 |
213
+ | 4 | 上下文压缩 | `agent/pre-step` → `ctx.compaction` | 仅实测 token 达触发线 ×70% | 先跑确定性裁剪,让摘要被跳过 | 省掉一次摘要调用 |
214
+ | 5 | 轮次延续 | `agent/turn-stopping` | 同一轮最多续一次 | `agent.steer()` | 避免空转一整轮 |
215
+ | 6 | 检索必要性 | `tools/pre-execute` | 仅检索类工具;其余工具零开销 | `deny` | 省搜索与结果 token |
216
+ | 7 | 技能选择 | `tools/pre-execute` | 仅 `skill` 工具且候选 ≥2 | `deny` + 指路 | 省误加载的技能正文 |
217
+ | 8 | 思维链翻译门槛 | 包住 `translator.translate()` | 本地预筛 + 同文本缓存 | 跳过并返回原文 | 按条省翻译调用 |
218
+
219
+ ### 三态的含义
220
+
221
+ | 模式 | 行为 |
222
+ |---|---|
223
+ | `关闭` | **连请求都不发**。省 token,也省那约 2 秒延迟 |
224
+ | `观察` | 照常判断并记录「本该怎样」,但不改变任何行为 |
225
+ | `生效` | 按判断执行该点的处置 |
226
+
227
+ 新接入的决策点都应当先跑 `观察`,确认与人工判断一致后再切 `生效`。
228
+
229
+ ### 阈值是实测校准的,不是拍脑袋的
230
+
231
+ 每个决策点的阈值都用真实端点跑过对照样本(「该判 A」与「该判 B」成对输入),
232
+ 只有两类输入真的分得开才留着。校准过程发现并修掉了三个问题,值得记下来:
233
+
234
+ 1. **`choice` 的 `confidence` 是被选中项的概率**。三选项平分时天然只有 0.33,
235
+ 所以分派点的门槛不能设 0.6,否则永远判不出 `advise`。现在按「显著高于均分」
236
+ 取 0.5。
237
+ 2. **question 的问法决定一切**。原来问「这个信息是否已知道」,两类输入给出
238
+ 0.30 / 0.12 —— 完全没有区分力。改问「recent 里是否**逐字覆盖**了这个事实,
239
+ 只依据那段文本回答」之后变成 0.92 / 0.01。
240
+ 3. **判据顺序会改变结果**。轮次延续若先看 `closed`,「明确未完成」那组
241
+ 因为 `closed=0.28` 不达线就会掉进默认 `stop`——而它恰恰该续轮。
242
+ 现在先看 `unfinished`。
243
+
244
+ 校准后的实测对照(`scripts/verify-endpoint.mjs` + 成对样本):
245
+
246
+ | 决策点 | 输入 A | 判定 | 输入 B | 判定 |
247
+ |---|---|---|---|---|
248
+ | 模型路由 | 机械步骤 | `switch` | 新架构设计 | `keep` |
249
+ | 子任务分派 | 三个包并行补测试 | `advise` | 变量改名 | `inline` |
250
+ | 上下文压缩 | 结论已落地 | `prune` | 对话承载状态 | `summarize` |
251
+ | 轮次延续 | 任务已完成 | `stop` | 明确还有三步 | `continue` |
252
+ | 检索必要性 | 上下文已覆盖 | `skip` | 完全没提过 | `allow` |
253
+ | 思维链翻译 | 英文→中文 | `translate` | 中文→中文 | `skip` |
254
+
255
+ ### 为什么不是「所有 hook 都开」
256
+
257
+ 决策模型每次约 **1.9k token、p50 约 0.5 秒**。`agent/pre-step` 与 `agent/request`
258
+ 都是**每步**触发,两个都开且不加闸,等于每步多等 1 秒以上。所以热路径上的
259
+ 决策点(注册表里标了 `hotPath`,配置页显示「热路径」角标)都带明确的触发闸,
260
+ 不能无条件调用。
261
+
262
+ 同时有两道护栏兜底:
263
+
264
+ - **自身预算**(`budgetTokens`,默认每小时 40 万):超限后所有决策点自动退回
265
+ 启发式并放行。省钱的东西不能反过来成为主要消耗方。
266
+ - **双层缓存**:见下节。
267
+
268
+ ## 模型选择
269
+
270
+ 配置页的「模型」是**下拉框**,由服务商实时提供,另有「自动(服务端默认)」选项。
271
+
272
+ **默认就是「自动」**(`config.yml` 里 `model:` 留空):插件**不发送该字段**,
273
+ 服务端用它当前的默认决策模型。实测省略 `model` 返回 200,响应里回填实际使用的模型名:
274
+
275
+ ```json
276
+ {"answers":{...},"model":"startlux-decision-27b-autothink","usage":{...}}
277
+ ```
278
+
279
+ 这样即便服务商将来更换模型名,留空配置也不会因此 400。
280
+
281
+ > 注意:`config.yml` 里的 `model:` **必须写出来但留空**(`model:` 后面不跟值)。
282
+ > 解析器把显式空值保留为 `""`;若整行删掉,键会缺失并回落到内置的硬编码模型名,
283
+ > 「自动」就不生效了。
284
+
285
+ **列表为何要过滤**:bamboonode 的 `/v1/models` 返回全部 20 个模型,其中多数是
286
+ chat 模型,拿去打 `/v1/decisions` 会 400 `model ... is not served by this plugin`。
287
+ 因此对 `bamboonode` 按 `supported_endpoint_types` 只保留声明了
288
+ `StartLux Decision` / `StartLux SystemOne` 的模型。实测 `typesafe/jev-1.14` 虽声明
289
+ `jev-decisions`,仍被该端点拒绝,因此也排除。
290
+
291
+ 对 `system one` / `custom`:自建服务的 `/v1/models` 往往不带
292
+ `supported_endpoint_types`,继续按类型过滤会得到空列表(用户会以为服务没有可用
293
+ 模型)。因此这两种 provider 改为「有类型就按类型过滤,没类型就全收」,下拉里也
294
+ 仍可手填。
295
+
296
+ ## 超时与自动回退
297
+
298
+ 每次请求都带**硬超时**(默认 8000ms),超时不是错误,而是降级放行:
299
+
300
+ ```
301
+ AbortSignal.timeout(budget) → 超时 → degrade("timeout") → 钩子 next() 放行
302
+ ```
303
+
304
+ 三层保护,保证守卫永远不会拖住工具执行:
305
+
306
+ 1. **单次超时**:`timeoutMs`(默认 8000ms,可在配置页调整,最小 1000ms)。
307
+ 实测该端点 p50≈0.7s、冷启动 p95≈6.4s,8s 覆盖冷启动又不至于让用户干等。
308
+ 2. **连续失败冷却**:连续 `failureThreshold`(默认 3)次失败后进入
309
+ `cooldownMs`(默认 60s)冷却,冷却期内**直接放行、0ms 返回**,
310
+ 不再对着故障端点反复消耗。
311
+ 3. **调用方取消**:若上层传入 `AbortSignal` 且被中止,立即降级返回
312
+ `caller-aborted`。
313
+
314
+ 实测(指向不可路由地址,`timeoutMs: 1500`):
315
+
316
+ ```
317
+ 第1次: 1504ms → 降级 第4次: 0ms → 降级 ← 冷却生效
318
+ 第2次: 1502ms → 降级 第5次: 0ms → 降级
319
+ 第3次: 1502ms → 降级(触发冷却)
320
+ ```
321
+
322
+ **没有配置 key 时根本不发请求**,直接 `no-key` 降级,零延迟。
323
+
324
+ ## 失败降级(fail-open)
325
+
326
+ 插件的设计原则是**任何不确定都退回原生行为**,绝不因为守卫自身故障而卡住工具调用。
327
+ 以下情形全部 `next()` 放行,并在 `telemetry.jsonl` 记一条 `action=degraded`:
328
+
329
+ | 情形 | `detail` |
330
+ | --- | --- |
331
+ | 插件未启用(`enabled: false`) | 不进入判断,直接放行 |
332
+ | 没有 API key | `no-key` |
333
+ | 命中失败冷却 | `cooldown` |
334
+ | 端点 4xx / 5xx | `http-404` / `http-500` 等(附 `endpoint`、`body`) |
335
+ | 网络不可达 | `network-error` |
336
+ | 超时 | `timeout`(附 `budgetMs`) |
337
+ | 返回非 JSON / 非对象 | `invalid-json` / `invalid-response` |
338
+ | 缺 `answers` 字段 | `missing-answers` |
339
+ | provider 配置不可用(如 custom 的 base URL 非法) | `invalid-base-url`(附 `provider` 与原因) |
340
+ | 鉴权被拒(401/403) | `auth-failed`(附 `provider`、`status`、该 provider 的密钥变量名) |
341
+ | 答案形状不符(类型错、缺字段、`choice` 非法、概率越界/NaN) | `answer-shape-mismatch` |
342
+ | 守卫内部抛异常 | 捕获后放行,仅 `logger.warn` |
343
+ | 只读工具 / auto 权限预设 / 被排除工具 | 不判断,直接放行 |
344
+
345
+ 默认 `mode: shadow` 时,即使判断出 `deny` 也只记录不执行。
346
+
347
+ ## 省 token 的设计
348
+
349
+ - **双层缓存**:
350
+ - `精确`:完整 `state` + 问题集一致即复用,绝对安全。
351
+ - `同类复用`:键是「工具名 + 命令族 + 风险标记」的归一化签名,
352
+ **只有低风险放行的裁决**才进这一层;`ask` / `deny` 永不复用,
353
+ 否则会把一次危险判断错误地扩散到同类操作。
354
+
355
+ 这一层是必要的:旧实现只用精确键,而键里含 `args_preview`,
356
+ 于是**实测 309 次调用命中 0 次**——等于缓存完全没工作。
357
+ 现在 4 条同类 `node -e '...'` 变体只需 1 次真实请求(命中率 60%),
358
+ 而 `rm -rf` 不会复用任何缓存裁决。
359
+ - **`thinkMode` 默认 `never`**:实测主要省**延迟**(约 1.9s vs 7.2s);输入 token 基本不变
360
+ (该端点 `output_tokens` 恒为 0,思考开销未计入计量)。三档可在配置页「思考模式」
361
+ 下拉切换,或手改 `config.yml`:
362
+
363
+ | 值 | 行为 | 适用 |
364
+ |---|---|---|
365
+ | `never` | 不思考。默认值 | 热路径首选,不想拖慢每次工具调用 |
366
+ | `auto` | 仅在模型自己判断需要时思考 | 折中 |
367
+ | `always` | 总是思考 | 高风险场景、排查误判时临时使用;每次约多等 5s |
368
+
369
+ 非法值(如 `thinkMode: sometimes`)会被归一成 `never`,不会被原样发给服务端:
370
+ 配置来自自研 YAML 子集解析器,绕过了 Schemastery 校验,因此取值必须在这里兜。
371
+ - **只读工具直接放行**:`read`/`grep`/`glob`/`todo_write` 等不发请求。
372
+ - **state 截断**:`args` 走头尾摘要,`recent` 只带最近 4 条、每条 240 字符。
373
+ - **失败冷却**:连续 3 次失败进入 60s 冷却,避免对着故障端点烧钱。
374
+ - **自身预算**:`budgetTokens` 限制决策模型每小时的花费,超限自动退回启发式。
375
+ - **默认只观察**:新决策点先跑 `observe`,确认与人工判断一致后再切 `enforce`。
376
+
377
+ ## 验证链路
378
+
379
+ ### 自动化验收(三条,全部可重复跑)
380
+
381
+ ```sh
382
+ pnpm test # 113 项单元测试:纯函数 + 适配器接线 + providers + 配置往返 + 设置界面渲染
383
+ pnpm verify:wiring # 接线检查:用真实 schemastery 加载插件
384
+ pnpm verify:endpoint # 8 个决策点逐个打真实端点,校验题型与答案形状
385
+ pnpm verify # 前两条一起跑
386
+ ```
387
+
388
+ 四个测试文件各管一层,互不依赖宿主:
389
+
390
+ | 文件 | 覆盖 |
391
+ |---|---|
392
+ | `test/spec.test.js` | 每个决策点的问法 / 解析 / 处置与阈值边界 |
393
+ | `test/engine.test.js` | 适配器接线、off/observe/enforce 语义、全部降级分支 |
394
+ | `test/providers.test.js` | provider 预设、地址拼接、URL 校验、鉴权与请求构造 |
395
+ | `test/config-file.test.js` | `config.yml` 读写往返:保存后再打开的回显、切换不互相覆盖 |
396
+ | `test/client-ui.test.js` | 设置界面渲染:provider 下拉、custom 字段、错误提示 |
397
+
398
+ `test/providers.test.js` 是 provider 改造的验收用例(不依赖宿主依赖,
399
+ 裸仓库可直接跑),覆盖六项硬约束:
400
+
401
+ 1. 内置 `system one` 可用(默认地址 / `/v1/systemone` / 自己的密钥变量名)
402
+ 2. 内置 `bamboonode` 行为不变(默认端点、老 `baseUrl` 覆盖、老模型名)
403
+ 3. `custom` base URL 可用(含端点风格、请求头、密钥来源优先级)
404
+ 4. 非法 URL 被拒绝(缺 scheme、非 http(s)、含空格、带查询串、空值)
405
+ 5. 末尾斜杠与端点拼接正确、幂等,不出现 `/v1/v1` 或 `//decisions`
406
+ 6. 请求构造不再硬编码 `bamboonode.dev`(含源码级断言:`lib/index.js` 与
407
+ `lib/client.js` 里不得再出现该域名)
408
+
409
+ `verify:wiring` 需要 profile 的依赖可解析。仓库里没有 `node_modules` 时先建链接:
410
+
411
+ ```sh
412
+ ln -s ~/.dsh/profiles/desktop/node_modules node_modules
413
+ ```
414
+
415
+ 它检查的是「宿主到底给不给这个服务」——包括缺 `translator` / `sessionTitle`
416
+ 时 `apply()` 不抛、所有监听器都注册上、每个新点默认 `off`(升级不改变行为)。
417
+
418
+ `verify:endpoint` 要网络与对应 provider 的密钥(`bamboonode` 默认读 `BAMBOO_API_KEY`,
419
+ 可用 `DECISION_ENGINE_BASE_URL` + `DECISION_ENGINE_API_KEY` 指向其它
420
+ system-one 服务),它逐点校验:
421
+ 端点接受该点的问题集(`choice` 必须有 `criteria`、`score` 必须 2–10 档、
422
+ 题型只认三种)、答案能被 `parse()` 解析、`decide()` 产出合法 `kind`。
423
+ **这一步不能省**:题型写错会在运行时 400 并静默降级,光看日志很难发现
424
+ 「这个点其实一直没生效」。
425
+
426
+ ### 手工验证
427
+
428
+ 1. `「测试端点」按钮` → 看到 `HTTP 200` 和 `x-oneapi-request-id`,说明配置与网络都对;
429
+ 诊断文本第一行就是当前 provider,接着是实际 POST 的完整端点。
430
+ 2. `插件配置页` → 确认 `服务提供方`、`生效端点`、`模型`、`密钥 已配置 ✅`;
431
+ 决策点列表里每个点都应有 `关闭 / 观察 / 生效` 三态(没有「即将支持」)。
432
+ 若某个点显示 `宿主缺服务`,说明宿主没有它依赖的服务(如 `translator`),
433
+ 该点无法开启——这不是 bug,是不适用的部署。
434
+ 3. 把某个点切到 `观察`,触发一次对应动作 → 看
435
+ `~/.dsh/dsh-decision-engine/telemetry.jsonl`(首次升级时旧目录的历史记录
436
+ 会被自动拷过来):
437
+ - `action=degraded, detail=no-key` → key 没读到
438
+ - `detail=http-404` / `http-400` → 地址或模型不对(记录里带 `endpoint` 和 `body`)
439
+ - `detail=answer-shape-mismatch` → 端点返回的答案形状不对(该点的问题集要复核)
440
+ - `point=route, action=switch` → 该点判出了「本该下沉到便宜模型」
441
+ - 每条的 `point` 字段标明是哪个决策点,`scores` 里是原始判断分数
442
+ 4. 服务商后台 → 出现 `type=2 · Action decision` 记录。
443
+
444
+ ## 阈值
445
+
446
+ ### 工具授权
447
+
448
+ | 键 | 默认 | 含义 |
449
+ | --- | --- | --- |
450
+ | `guardDenyConfidence` | 0.85 | 高风险 + 高置信 + 不可逆 → `deny` |
451
+ | `guardIrreversibleDenyFloor` | 0.5 | 判定不可逆的门槛 |
452
+ | `guardIrreversibleCeiling` | 0.2 | 低风险但不可逆 → 升级询问 |
453
+ | `guardConfidenceMin` | 0.6 | 中风险置信下限,低于则放行 |
454
+ | `guardInjectionAskThreshold` | 0.8 | 注入嫌疑 → 升级询问 |
455
+
456
+ ### 其余八个决策点
457
+
458
+ 全部阈值都在 `config.yml` 里可覆盖,默认值按实测校准(见上文「阈值是实测
459
+ 校准的」)。下表列出「改动它会怎样」:
460
+
461
+ | 键 | 默认 | 调高会怎样 | 调低会怎样 |
462
+ | --- | --- | --- | --- |
463
+ | `routeCheapProvider` / `routeCheapModel` | 空 | 留空即不路由,点开着也只记录 | — |
464
+ | `routeMaxStepsPerTurn` | 3 | 每轮判断更多步,更准但更慢 | 更快但后续步骤沿用旧裁决 |
465
+ | `routeMaxScore` | 0.4 | 更少下沉(更保守) | 更多下沉(更省但可能降质) |
466
+ | `routeMechanicalFloor` | 0.6 | 更难判为机械(更保守) | 更易下沉 |
467
+ | `routeRiskFloor` | 0.3 | 风险稍高就保留强模型(更保守) | 更敢下沉 |
468
+ | `delegateConfidenceFloor` | 0.5 | 更少建议分派 | 更多建议分派 |
469
+ | `delegateBlockingFloor` | 0.6 | 更容易判「被阻塞」→ 更少分派 | 更敢分派 |
470
+ | `delegateParallelFloor` | 0.5 | 组队需要更强并行证据 | 更容易建议组队 |
471
+ | `compactContextWindow` | 0 | — | 设成实际窗口才能按比例提前介入 |
472
+ | `compactThresholdRatio` | 0.7 | 更晚介入(贴近 compaction 自己的 0.8) | 更早介入 |
473
+ | `compactDistilledFloor` | 0.6 | 更少裁剪(更保守) | 更多裁剪 |
474
+ | `compactVerbatinFloor` | 0.6 | 更少因「需要原文」而改走摘要 | 更保守(更容易摘要) |
475
+ | `turnStopMaxSteers` | 1 | 一轮可续多次(注意空转风险) | 0 = 永不续轮 |
476
+ | `turnStopUnfinishedFloor` | 0.6 | 更少续轮 | 更易续轮 |
477
+ | `turnStopValueFloor` | 0.35 | 更少续轮 | 更易续轮 |
478
+ | `retrievalCoveredFloor` | 0.5 | 更少跳过检索(更安全) | 更敢跳过 |
479
+ | `retrievalWouldAddCeiling` | 0.8 | 更敢跳过(更省) | 更少跳过 |
480
+ | `skillPickConfidenceFloor` | 0.5 | 更少改指路 | 更敢改指路 |
481
+ | `cotProseFloor` | 0.5 | 更少翻译(更省) | 更敢翻译 |
482
+ | `cotSameLanguageFloor` | 0.8 | 更少判「已是目标语言」→ 更多翻译 | 更敢跳过 |
483
+
484
+ ## License
485
+
486
+ MIT