dsh-auto-check 0.1.1 → 0.2.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 CHANGED
@@ -9,7 +9,7 @@
9
9
  - 自动审查请求的 `system` 提示以 `REVIEW_POLICY\n` 开头,这是插件识别它的唯一稳定标记。
10
10
  - 命中后,插件用配置的 provider / model 重新发起一次 LLM 请求;未配置或只配置了一半时,保持原有行为。
11
11
  - 改写后的请求会被标记,避免再次进入本插件造成递归。
12
- - 不替换、不修改自带的 `auto-review` 插件;自动审查的策略、审批和拒绝逻辑全部保持原样。
12
+ - 不替换、不修改自带的 `auto-review` 插件;审查策略本身保持原样。插件只在其外层包一层 `tools/pre-execute` 监听,把「审查拒绝 / 审查失败」交给用户选择(见下文「审查拒绝时」)。
13
13
 
14
14
  ## 配置
15
15
 
@@ -20,8 +20,32 @@
20
20
  - **自动识别**(默认):目标模型名包含 `Qwen3Guard` 时使用 Qwen3Guard 适配,否则使用标准 JSON 协议。
21
21
  - **标准 JSON**:要求审查模型严格输出 `{risk, decision, reason?}`,适用于通用模型。
22
22
  - **Qwen3Guard 安全分级**:适用于 Qwen3Guard-Gen 系列(0.6B / 4B / 8B)。
23
+ 5. 按需设置「审查拒绝时」(`onBlock`)与「审查超时」(`reviewTimeoutMs`),见下一节。
23
24
 
24
- 模型列表来自当前会话可路由的模型目录。只影响自动审查,不影响主对话或子代理。
25
+ 模型列表来自当前会话可路由的模型目录。打开页面、窗口重新聚焦,以及模型/凭据设置变更时会自动刷新;也可以点击「刷新列表」手动重新拉取。只影响自动审查,不影响主对话或子代理。
26
+
27
+ ## 审查拒绝时:把选择权交还给你
28
+
29
+ 自动审查在以下三种情况下会阻止工具调用:
30
+
31
+ | 情况 | 原有行为 | 现在(`onBlock: ask`,默认) |
32
+ | --- | --- | --- |
33
+ | 审查模型拒绝,审批策略为 `never` 覆盖 | 直接拒绝 | 弹出问题让你选择 |
34
+ | 审查模型拒绝,审批策略为 `ask` | 弹出审批确认 | 不变(仍由宿主的审批卡片确认) |
35
+ | 审查模型拒绝,配置的审批策略为 `never` | 审批服务自动拒绝 | 弹出问题让你选择 |
36
+ | 审查模型出错 / 无响应 / 超时 | 直接拒绝(fail-closed) | 弹出问题让你选择 |
37
+
38
+ 问题卡片会显示工具名和审查原因(例如 `Qwen3Guard: Controversial (categories: ...)`),选项为:
39
+
40
+ - **维持拒绝**(推荐,默认预选):工具不执行,代理收到原来的拒绝结果。
41
+ - **仍要执行(放行一次)**:只执行这一次调用,不会放行其他调用。
42
+
43
+ 安全底线:只有明确点选「放行一次」才会执行。跳过、取消、自定义输入、会话中止、没有可用的提问通道(例如无界面运行或子代理)或提问出错时,**一律维持原拒绝**,绝不静默放行。
44
+
45
+ - `onBlock`:`ask`(默认)或 `deny`。`deny` 恢复原有行为:不弹问题,按原策略拒绝。
46
+ - `reviewTimeoutMs`:单独配置的审查模型在此时长(毫秒)内没有给出结果时,插件中止该请求并以 `REVIEWER_TIMEOUT` 错误结束审查流,自动审查据此判定「审查失败」,再交给你选择。默认 `60000`,`0` 表示不限时。只对本插件改写过路由的审查请求生效;「跟随主模型」时不加超时,但主模型出错导致的审查失败同样会弹出选择。
47
+
48
+ 实现说明:宿主的审查闸门以 `prepend` 方式注册在 `tools/pre-execute` 上。cordis 的 `prepend` 使用 `unshift`,`waterfall` 从数组第 0 个开始执行,所以最后注册的 `prepend` 监听在最外层。插件加载顺序不固定,因此本插件监听 `internal/listener`:每当有新的 `prepend` 监听注册到 `tools/pre-execute`,就在下一个微任务把自己重新注册回最外层,保证总能拿到闸门的结果。测试用真实 cordis 验证了两种加载顺序。
25
49
 
26
50
  ## Qwen3Guard 适配
27
51
 
@@ -42,9 +66,38 @@ Categories: <分类列表 | None>
42
66
 
43
67
  > Qwen3Guard 只判断内容安全,不判断工具调用的业务正确性;`Safe` 只代表内容没有命中安全分类,不代表这次工具调用一定符合你的意图。
44
68
 
45
- > Qwen3Guard 只支持 Gen 系列的安全分级输出,Stream 系列是逐 token 审核,不适用本适配。`Controversial` 在本插件中映射为中风险拒绝:在 auto review 的默认审批策略下,它可能被转成一次用户确认,而不是直接丢弃;如果审批策略是 `never`,则会直接拒绝。若模型别名不含 `Qwen3Guard`,请在「审查协议」中显式选择 Qwen3Guard。
69
+ > Qwen3Guard 只支持 Gen 系列的安全分级输出,Stream 系列是逐 token 审核,不适用本适配。`Controversial` 在本插件中映射为中风险拒绝:默认(`onBlock: ask`)会弹出问题,由你选择放行一次或维持拒绝,审批策略为 `never` 时也是如此;设为 `onBlock: deny` 则恢复原有行为(`never` 下直接拒绝)。若模型别名不含 `Qwen3Guard`,请在「审查协议」中显式选择 Qwen3Guard。
70
+
71
+ > 审查模型不可用(例如凭据失效、模型下线、超过「审查超时」)时,默认会把失败原因展示给你并由你选择放行一次或维持拒绝;`onBlock: deny` 时按原有失败策略直接拒绝。长期不可用时请换回「跟随主模型」或选择一个可用模型。
72
+
73
+ ### 长会话与 32k 上下文
74
+
75
+ 自动审查会把当前会话的过滤历史和待审工具调用一起发给审查模型。长会话的审查输入可能达到数万 Token,超过 Qwen3Guard-Gen 本地 32k 上下文,导致自动审查失败(默认每次都会弹出选择,`onBlock: deny` 时直接拒绝)。
76
+
77
+ 「最大审查字符数」用于 Qwen3Guard 协议:超过预算时保留开头(环境与项目指令)和结尾(最近历史与待审调用),截断中间并插入标记。默认 `64000` 字符(约 16k Token),适配 32k 上下文并留出输出余量;设为 `0` 可关闭截断。使用更大上下文的远程模型时可以调高或关闭。
78
+
79
+ ### 云端 / 兼容网关:`store` 字段被拒绝
80
+
81
+ 一些 OpenAI 兼容网关(例如 newapi 代理的 Qwen3Guard)会返回:
82
+
83
+ ```text
84
+ 400 'store' is not supported by this endpoint (param: store, code: unsupported_field)
85
+ ```
86
+
87
+ 这是 pi-ai 的兼容层在 `compat.supportsStore` 为真时自动附加了 `store: false`,而网关不接受该字段。给该 provider 的模型配置 `compat`:
88
+
89
+ ```yaml
90
+ security-huahuo-newapi:
91
+ api: openai-completions
92
+ baseURL: https://newapi.amaicat.work/v1
93
+ compat:
94
+ supportsStore: false
95
+ models:
96
+ - id: Qwen3Guard-Gen-4B
97
+ name: Qwen3Guard-Gen-4B
98
+ ```
46
99
 
47
- > 审查模型不可用(例如凭据失效、模型下线)时,自动审查会按原有失败策略拒绝工具调用。换回「跟随主模型」或选择一个可用模型即可恢复。
100
+ 本插件也会在 Qwen3Guard 请求中主动丢弃 `store`,但适配器层附加的字段需要在 provider 配置里关闭。
48
101
 
49
102
  ## 安装
50
103
 
@@ -58,12 +111,13 @@ Categories: <分类列表 | None>
58
111
  ## 开发验证
59
112
 
60
113
  ```powershell
61
- node --test test/review-route.test.mjs
114
+ npm test
62
115
  node --check lib/index.js
116
+ node --check lib/review-choice.js
63
117
  node --check client.js
64
118
  ```
65
119
 
66
- 测试覆盖:普通请求放行、审查请求改写、递归回放、空配置回退、同路由不重复改写、Qwen3Guard 协议探测、请求改写、标签映射与流转换。
120
+ 测试覆盖:普通请求放行、审查请求改写、递归回放、空配置回退、同路由不重复改写、Qwen3Guard 协议探测、请求改写、字符预算截断、标签映射与流转换;审查闸门三种拒绝形态识别、提问与答案解析、提问失败一律维持拒绝、审查超时合成 `REVIEWER_TIMEOUT` 结束块,以及在真实 cordis 中两种加载顺序下都位于闸门外层(找不到宿主 checkout 时该项跳过)。
67
121
 
68
122
  ## 发布到 npm
69
123
 
package/client.js CHANGED
@@ -1,10 +1,12 @@
1
1
  window.__ModuleLoader__.load({
2
2
  id: "dsh-auto-check",
3
3
  factory: (require) => {
4
+ const React = require("react");
4
5
  const { jsx, jsxs } = require("react/jsx-runtime");
5
6
  const {
6
7
  SettingsForm,
7
8
  SettingsFormModel,
9
+ settingsNumberField,
8
10
  settingsTextField,
9
11
  } = require("@deepseek-ai/dsh-client-ui-primitives");
10
12
 
@@ -17,7 +19,13 @@ window.__ModuleLoader__.load({
17
19
  ".dshAutoCheckLabel{color:var(--dsw-alias-label-primary);font-size:13px;line-height:1.5}" +
18
20
  ".dshAutoCheckSelect{background:var(--dsw-alias-bg-layer-1);border:.5px solid var(--dsw-alias-border-l4);border-radius:var(--dsw-radius-md);color:var(--dsw-alias-label-primary);font:inherit;font-size:13px;min-height:32px;padding:4px 8px;width:100%;max-width:520px}" +
19
21
  ".dshAutoCheckSelect:disabled{opacity:.6}" +
22
+ ".dshAutoCheckInput{background:var(--dsw-alias-bg-layer-1);border:.5px solid var(--dsw-alias-border-l4);border-radius:var(--dsw-radius-md);color:var(--dsw-alias-label-primary);font:inherit;font-size:13px;min-height:32px;padding:4px 8px;width:100%;max-width:260px}" +
23
+ ".dshAutoCheckInput:disabled{opacity:.6}" +
20
24
  ".dshAutoCheckHint{margin:0;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:1.5}" +
25
+ ".dshAutoCheckRefresh{display:flex;align-items:center;gap:8px;margin:0}" +
26
+ ".dshAutoCheckRefresh button{background:0 0;border:.5px solid var(--dsw-alias-border-l3);border-radius:var(--dsw-radius-sm);color:var(--dsw-alias-label-primary);cursor:pointer;font:inherit;font-size:12px;line-height:18px;padding:2px 10px}" +
27
+ ".dshAutoCheckRefresh button:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}" +
28
+ ".dshAutoCheckRefresh button:disabled{opacity:.5;cursor:default}" +
21
29
  ".dshAutoCheckNotice{margin:0;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:1.5}" +
22
30
  ".dshAutoCheckError{display:flex;align-items:center;gap:12px;margin:0;color:var(--dsw-alias-state-error-primary);font-size:12px;line-height:1.5}" +
23
31
  ".dshAutoCheckError button{background:0 0;border:0;color:var(--dsw-alias-brand-primary);cursor:pointer;font:inherit;padding:0}";
@@ -44,8 +52,20 @@ window.__ModuleLoader__.load({
44
52
  protocolJson: "标准 JSON(通用模型)",
45
53
  protocolQwen3Guard: "Qwen3Guard 安全分级",
46
54
  protocolHint: "Qwen3Guard 模式会去掉 auto review 的 JSON 策略,把待审内容作为用户消息发送,并把 Safety 标签映射为风险等级:Safe 放行,Controversial / Unsafe 拒绝。",
47
- protocolWarning: "Controversial 按中风险拒绝;默认审批策略下可能转人工确认,审批策略为 never 时直接拒绝。",
55
+ protocolWarning: "Controversial 按中风险拒绝。按下方「审查拒绝时」的设置,默认会弹出问题让你选择放行一次或维持拒绝(审批策略为 never 时同样如此)。",
56
+ onBlock: "审查拒绝时",
57
+ onBlockAsk: "询问我:放行一次或维持拒绝(默认)",
58
+ onBlockDeny: "直接拒绝(原有行为)",
59
+ onBlockHint: "审查模型拒绝、出错或超时时,默认把原因展示给你并由你选择;跳过、取消或无法弹出问题时一律维持拒绝。选「直接拒绝」恢复原有的失败即拒绝行为。",
60
+ reviewTimeoutMs: "审查超时",
61
+ reviewTimeoutMsHint: "单独配置的审查模型超过此时长仍未给出结果时,视为无响应并交给你选择。0 表示不限时。跟随主模型时不生效。",
62
+ reviewTimeoutMsUnit: "毫秒",
63
+ maxInputChars: "最大审查字符数",
64
+ maxInputCharsHint: "Qwen3Guard 模式下,超过此长度的审查内容会保留首尾、截断中间,避免 32k 本地模型上下文溢出。0 表示不截断。",
65
+ maxInputCharsUnit: "字符",
48
66
  loading: "正在加载可用模型…",
67
+ refresh: "刷新列表",
68
+ refreshing: "刷新中…",
49
69
  loadFailed: "无法加载模型列表。",
50
70
  retry: "重试",
51
71
  partial: "部分提供商暂不可用,列表可能不完整。",
@@ -68,8 +88,20 @@ window.__ModuleLoader__.load({
68
88
  protocolJson: "Standard JSON (general models)",
69
89
  protocolQwen3Guard: "Qwen3Guard safety grading",
70
90
  protocolHint: "Qwen3Guard mode removes auto review's JSON policy, sends the pending content as the user message, and maps the Safety label to a risk level: Safe allows; Controversial / Unsafe deny.",
71
- protocolWarning: "Controversial is denied as medium risk; the default approval policy may turn it into a user confirmation, while a `never` override denies it outright.",
91
+ protocolWarning: "Controversial is denied as medium risk. With \"When review blocks\" below set to ask (the default), you are asked to allow it once or keep it denied, including under the `never` approval policy.",
92
+ onBlock: "When review blocks",
93
+ onBlockAsk: "Ask me: allow once or keep denied (default)",
94
+ onBlockDeny: "Deny outright (original behaviour)",
95
+ onBlockHint: "When the reviewer denies, errors or times out, the reason is shown and you decide. A skipped, cancelled or undeliverable question always keeps the denial. Choose \"Deny outright\" to restore the original fail-closed behaviour.",
96
+ reviewTimeoutMs: "Review timeout",
97
+ reviewTimeoutMsHint: "If a dedicated reviewer model gives no result within this time, it counts as unresponsive and you decide. 0 disables the timeout. Not applied when following the main model.",
98
+ reviewTimeoutMsUnit: "milliseconds",
99
+ maxInputChars: "Maximum review characters",
100
+ maxInputCharsHint: "In Qwen3Guard mode, longer review input keeps its head and tail and drops the middle, so a 32k local model does not overflow. 0 disables truncation.",
101
+ maxInputCharsUnit: "characters",
72
102
  loading: "Loading available models…",
103
+ refresh: "Refresh list",
104
+ refreshing: "Refreshing…",
73
105
  loadFailed: "Could not load the model list.",
74
106
  retry: "Retry",
75
107
  partial: "Some providers are unavailable, so the list may be incomplete.",
@@ -97,6 +129,9 @@ window.__ModuleLoader__.load({
97
129
  settingsTextField("provider"),
98
130
  settingsTextField("model"),
99
131
  settingsTextField("protocol"),
132
+ settingsNumberField("maxInputChars"),
133
+ settingsTextField("onBlock"),
134
+ settingsNumberField("reviewTimeoutMs"),
100
135
  ]);
101
136
  this.actions = this.form.actions();
102
137
  this.store = this.form.bind(() => this.projection());
@@ -104,7 +139,6 @@ window.__ModuleLoader__.load({
104
139
  this.catalogStatus = "loading";
105
140
  this.catalogPartial = false;
106
141
  this.generation = 0;
107
- void this.refreshCatalog();
108
142
  }
109
143
 
110
144
  projection() {
@@ -113,6 +147,9 @@ window.__ModuleLoader__.load({
113
147
  provider: this.form.field("provider"),
114
148
  model: this.form.field("model"),
115
149
  protocol: this.form.field("protocol"),
150
+ maxInputChars: this.form.field("maxInputChars"),
151
+ onBlock: this.form.field("onBlock"),
152
+ reviewTimeoutMs: this.form.field("reviewTimeoutMs"),
116
153
  groups: this.groups,
117
154
  catalogStatus: this.catalogStatus,
118
155
  catalogPartial: this.catalogPartial,
@@ -175,11 +212,33 @@ window.__ModuleLoader__.load({
175
212
  function AutoCheckCard(props) {
176
213
  const { t } = props;
177
214
  const state = props.useAutoCheckCard((snapshot) => snapshot);
215
+ const refreshRef = React.useRef(props.refreshCatalog);
216
+ refreshRef.current = props.refreshCatalog;
217
+
218
+ React.useEffect(() => {
219
+ refreshRef.current();
220
+ }, []);
221
+
222
+ React.useEffect(() => {
223
+ const refresh = () => refreshRef.current();
224
+ const onVisibility = () => {
225
+ if (document.visibilityState === "visible") refresh();
226
+ };
227
+ window.addEventListener("focus", refresh);
228
+ document.addEventListener("visibilitychange", onVisibility);
229
+ return () => {
230
+ window.removeEventListener("focus", refresh);
231
+ document.removeEventListener("visibilitychange", onVisibility);
232
+ };
233
+ }, []);
234
+
178
235
  if (props.view === "summary") return t("description");
179
236
 
180
237
  const provider = state.provider.text.trim();
181
238
  const model = state.model.text.trim();
182
239
  const protocol = state.protocol.text.trim() || "auto";
240
+ const onBlock =
241
+ (state.onBlock?.text ?? "").trim().toLowerCase() === "deny" ? "deny" : "ask";
183
242
  const current = provider !== "" && model !== "" ? provider + SEP + model : "";
184
243
  const routes = new Set();
185
244
  for (const group of state.groups) {
@@ -285,6 +344,81 @@ window.__ModuleLoader__.load({
285
344
  }),
286
345
  ],
287
346
  }),
347
+ jsxs("div", {
348
+ className: "dshAutoCheckField",
349
+ children: [
350
+ jsx("label", {
351
+ className: "dshAutoCheckLabel",
352
+ htmlFor: "dsh-auto-check-max-input",
353
+ children: t("maxInputChars"),
354
+ }),
355
+ jsx("input", {
356
+ className: "dshAutoCheckInput",
357
+ id: "dsh-auto-check-max-input",
358
+ type: "number",
359
+ min: 0,
360
+ step: 1000,
361
+ value: state.maxInputChars?.text ?? "",
362
+ disabled: !state.writable || state.saving,
363
+ onChange: (event) =>
364
+ props.edit("maxInputChars", event.target.value),
365
+ }),
366
+ jsx("p", {
367
+ className: "dshAutoCheckHint",
368
+ children: t("maxInputCharsHint"),
369
+ }),
370
+ ],
371
+ }),
372
+ jsxs("div", {
373
+ className: "dshAutoCheckField",
374
+ children: [
375
+ jsx("label", {
376
+ className: "dshAutoCheckLabel",
377
+ htmlFor: "dsh-auto-check-on-block",
378
+ children: t("onBlock"),
379
+ }),
380
+ jsx("select", {
381
+ className: "dshAutoCheckSelect",
382
+ id: "dsh-auto-check-on-block",
383
+ value: onBlock,
384
+ disabled: !state.writable || state.saving,
385
+ onChange: (event) => props.edit("onBlock", event.target.value),
386
+ children: [
387
+ jsx("option", { value: "ask", children: t("onBlockAsk") }, "ask"),
388
+ jsx("option", { value: "deny", children: t("onBlockDeny") }, "deny"),
389
+ ],
390
+ }),
391
+ jsx("p", {
392
+ className: "dshAutoCheckHint",
393
+ children: t("onBlockHint"),
394
+ }),
395
+ ],
396
+ }),
397
+ jsxs("div", {
398
+ className: "dshAutoCheckField",
399
+ children: [
400
+ jsx("label", {
401
+ className: "dshAutoCheckLabel",
402
+ htmlFor: "dsh-auto-check-review-timeout",
403
+ children: t("reviewTimeoutMs") + " (" + t("reviewTimeoutMsUnit") + ")",
404
+ }),
405
+ jsx("input", {
406
+ className: "dshAutoCheckInput",
407
+ id: "dsh-auto-check-review-timeout",
408
+ type: "number",
409
+ min: 0,
410
+ step: 1000,
411
+ value: state.reviewTimeoutMs?.text ?? "",
412
+ disabled: !state.writable || state.saving,
413
+ onChange: (event) =>
414
+ props.edit("reviewTimeoutMs", event.target.value),
415
+ }),
416
+ jsx("p", {
417
+ className: "dshAutoCheckHint",
418
+ children: t("reviewTimeoutMsHint"),
419
+ }),
420
+ ],
421
+ }),
288
422
  jsx("p", {
289
423
  className: "dshAutoCheckHint",
290
424
  children: t("hint"),
@@ -299,6 +433,20 @@ window.__ModuleLoader__.load({
299
433
  children: t("protocolWarning"),
300
434
  })
301
435
  : null,
436
+ jsxs("div", {
437
+ className: "dshAutoCheckRefresh",
438
+ children: [
439
+ jsx("button", {
440
+ type: "button",
441
+ disabled: state.catalogStatus === "loading" || state.saving,
442
+ onClick: () => props.refreshCatalog(),
443
+ children:
444
+ state.catalogStatus === "loading"
445
+ ? t("refreshing")
446
+ : t("refresh"),
447
+ }),
448
+ ],
449
+ }),
302
450
  state.catalogStatus === "loading"
303
451
  ? jsx("p", {
304
452
  className: "dshAutoCheckNotice",
@@ -350,6 +498,25 @@ window.__ModuleLoader__.load({
350
498
  );
351
499
  ctx.effect(() => () => card.dispose(), "dsh-auto-check: form subscription");
352
500
 
501
+ ctx.effect(() => {
502
+ const events = [
503
+ "llm/adapters-updated",
504
+ "settings/document-updated",
505
+ "credentials/record-updated",
506
+ "credentials/reference-updated",
507
+ ];
508
+ const disposers = events.map((event) =>
509
+ ctx.remote.$on(event, () => {
510
+ void card.refreshCatalog();
511
+ }),
512
+ );
513
+ return () => {
514
+ for (const dispose of disposers) {
515
+ if (typeof dispose === "function") dispose();
516
+ }
517
+ };
518
+ }, "dsh-auto-check: model catalog refresh");
519
+
353
520
  ctx.effect(
354
521
  () =>
355
522
  ctx.configForms.whileServed([ENTRY_ID], () =>
@@ -363,6 +530,7 @@ window.__ModuleLoader__.load({
363
530
  locale: NS,
364
531
  inject: () => ({
365
532
  ...card.inject(),
533
+ refreshCatalog: () => card.refreshCatalog(),
366
534
  retryCatalog: () => card.refreshCatalog(),
367
535
  selectRoute: (route) => card.selectRoute(route),
368
536
  }),
package/lib/index.js CHANGED
@@ -1,17 +1,30 @@
1
1
  import { Service } from "@deepseek-ai/cordis";
2
2
  import z from "@deepseek-ai/schemastery";
3
3
  import { routeReview } from "./review-route.js";
4
+ import {
5
+ createReviewChoiceListener,
6
+ keepOutermost,
7
+ withReviewDeadline,
8
+ } from "./review-choice.js";
4
9
 
5
10
  /**
6
11
  * Owns the reviewer route and rewrites Auto review requests before they reach
7
12
  * the adapter. An empty provider or model keeps the shipped behaviour: the
8
13
  * reviewer follows the main model selected for the session.
14
+ *
15
+ * It also wraps the Auto review gate so a reviewer denial or a reviewer that
16
+ * does not answer becomes a question to the user instead of a hard denial
17
+ * (`onBlock: "ask"`, the default). `onBlock: "deny"` keeps the shipped
18
+ * behaviour.
9
19
  */
10
20
  class AutoCheckReviewer extends Service {
11
21
  static Config = z.object({
12
22
  provider: z.string().default("").volatile(),
13
23
  model: z.string().default("").volatile(),
14
24
  protocol: z.string().default("auto").volatile(),
25
+ maxInputChars: z.number().default(64000).volatile(),
26
+ onBlock: z.string().default("ask").volatile(),
27
+ reviewTimeoutMs: z.number().default(60000).volatile(),
15
28
  });
16
29
 
17
30
  constructor(ctx, config) {
@@ -26,20 +39,41 @@ class AutoCheckReviewer extends Service {
26
39
  child.effect(() =>
27
40
  child.on(
28
41
  "llm/stream",
29
- (options, next) =>
30
- routeReview(
42
+ (options, next) => {
43
+ const deadline = new AbortController();
44
+ const stream = routeReview(
31
45
  options,
32
46
  {
33
47
  provider: this.config.provider.get(),
34
48
  model: this.config.model.get(),
35
49
  protocol: this.config.protocol.get(),
50
+ maxInputChars: this.config.maxInputChars.get(),
51
+ signal: deadline.signal,
36
52
  },
37
53
  (request) => child.llm.stream(request),
38
- ) ?? next(),
54
+ );
55
+ if (stream === undefined) return next();
56
+ return withReviewDeadline(
57
+ stream,
58
+ Number(this.config.reviewTimeoutMs.get()),
59
+ () => deadline.abort(new Error("auto-check: reviewer timed out")),
60
+ );
61
+ },
39
62
  { global: true, prepend: true },
40
63
  ),
41
64
  );
42
65
  });
66
+
67
+ // Services are looked up per call, so a missing `userQuestions` service
68
+ // simply keeps the original denial (the same as `onBlock: "deny"`).
69
+ // `keepOutermost` documents why this listener must wrap the gate and how
70
+ // it stays at index 0 regardless of plugin load order.
71
+ const listener = createReviewChoiceListener({
72
+ onBlock: () => this.config.onBlock.get(),
73
+ userQuestions: () => ctx.get("userQuestions"),
74
+ approval: () => ctx.get("approval"),
75
+ });
76
+ ctx.effect(() => keepOutermost(ctx, "tools/pre-execute", listener));
43
77
  }
44
78
  }
45
79
 
@@ -0,0 +1,336 @@
1
+ /**
2
+ * Hand Auto review denials back to the user.
3
+ *
4
+ * The shipped Auto review gate (`dsh-experimental-auto-review`) ends a tool
5
+ * call in one of three ways when its reviewer does not allow it:
6
+ *
7
+ * - `denied()`: `{kind:"deny", info:{code:"AUTO_REVIEW_DENIED"}}`, returned
8
+ * when the session approval override is `never`. Final, no prompt.
9
+ * - `failed()`: `{kind:"deny", reason:'Auto review of tool "X" failed; ...'}`,
10
+ * returned when the reviewer threw, errored or (with our deadline) timed out.
11
+ * - `askUser()`: `{kind:"ask", displayReason}`, which `dsh-tools` forwards to
12
+ * the approval service. Under the `never` policy that service rejects it
13
+ * without asking anybody.
14
+ *
15
+ * This module turns those outcomes into a `userQuestions` prompt so the user
16
+ * picks "allow once" or "keep denied". Every failure to ask, a cancelled or
17
+ * skipped prompt, and any answer other than the explicit allow option keep the
18
+ * original gate result: nothing is ever allowed silently.
19
+ */
20
+
21
+ /** Must match `AUTO_REVIEW_DENIED_CODE` in dsh-experimental-auto-review. */
22
+ export const AUTO_REVIEW_DENIED_CODE = "AUTO_REVIEW_DENIED";
23
+
24
+ /** Code of the synthetic finish chunk emitted when the reviewer is too slow. */
25
+ export const REVIEWER_TIMEOUT_CODE = "REVIEWER_TIMEOUT";
26
+
27
+ /** Question id used for the single review-choice question. */
28
+ export const REVIEW_CHOICE_QUESTION_ID = "dsh-auto-check-review-choice";
29
+
30
+ /** Labels are bilingual because the host side has no viewer locale. */
31
+ export const KEEP_DENIED_LABEL = "Keep denied / 维持拒绝 (recommended)";
32
+ export const ALLOW_ONCE_LABEL = "Allow once / 仍要执行(放行一次)";
33
+
34
+ const FAILED_REASON = /^Auto review of tool "/;
35
+
36
+ /**
37
+ * Classify one `tools/pre-execute` gate result.
38
+ *
39
+ * `ask` is matched purely by shape; whether it would be auto-rejected depends
40
+ * on the approval policy, which the caller checks.
41
+ *
42
+ * @param {unknown} gate
43
+ * @returns {"denied" | "failed" | "ask" | undefined}
44
+ */
45
+ export function reviewDenialKind(gate) {
46
+ if (gate === null || typeof gate !== "object") return undefined;
47
+ if (gate.kind === "deny") {
48
+ if (gate.info?.code === AUTO_REVIEW_DENIED_CODE) return "denied";
49
+ if (typeof gate.reason === "string" && FAILED_REASON.test(gate.reason)) {
50
+ return "failed";
51
+ }
52
+ return undefined;
53
+ }
54
+ if (gate.kind === "ask" && typeof gate.reason === "string" && /^Auto review denied tool "/.test(gate.reason)) {
55
+ return "ask";
56
+ }
57
+ return undefined;
58
+ }
59
+
60
+ /**
61
+ * Whether a gate result is one of the three Auto review denial shapes.
62
+ * @param {unknown} gate
63
+ * @returns {boolean}
64
+ */
65
+ export function isReviewDenial(gate) {
66
+ return reviewDenialKind(gate) !== undefined;
67
+ }
68
+
69
+ /**
70
+ * The most specific human-readable reason carried by a gate result.
71
+ * @param {any} gate
72
+ * @returns {string}
73
+ */
74
+ export function denialReason(gate) {
75
+ if (typeof gate?.info?.reason === "string" && gate.info.reason !== "") {
76
+ return gate.info.reason;
77
+ }
78
+ const display = gate?.displayReason;
79
+ if (display !== undefined && display !== null && typeof display === "object") {
80
+ const zh = typeof display.zh === "string" ? display.zh : "";
81
+ const en = typeof display.en === "string" ? display.en : "";
82
+ if (zh !== "" || en !== "") return [en, zh].filter(Boolean).join("\n\n");
83
+ }
84
+ if (typeof display === "string" && display !== "") return display;
85
+ return typeof gate?.reason === "string" ? gate.reason : "";
86
+ }
87
+
88
+ /** Indent text as a Markdown blockquote so reviewer output cannot restyle the card. */
89
+ function quote(text) {
90
+ return text
91
+ .split(/\r?\n/)
92
+ .map((line) => `> ${line}`)
93
+ .join("\n");
94
+ }
95
+
96
+ /**
97
+ * Build the `userQuestions.ask` request for one blocked tool call.
98
+ *
99
+ * `options[0]` is the recommended "keep denied" option: the client preselects
100
+ * it, so allowing always takes an explicit click. No `intent` is sent.
101
+ *
102
+ * @param {{ name?: unknown, agent?: unknown }} exec
103
+ * @param {object} gate
104
+ * @param {AbortSignal} [signal]
105
+ * @returns {object}
106
+ */
107
+ export function reviewQuestion(exec, gate, signal) {
108
+ const kind = reviewDenialKind(gate);
109
+ const name = typeof exec?.name === "string" ? exec.name : "unknown";
110
+ const reason = denialReason(gate);
111
+ const headline =
112
+ kind === "failed"
113
+ ? "The reviewer model did not answer (error or timeout). / 审查模型没有响应(出错或超时)。"
114
+ : "The reviewer model blocked this tool call. / 审查模型拒绝了此工具调用。";
115
+ const detail = [
116
+ headline,
117
+ "",
118
+ `**Tool / 工具:** \`${name.replace(/`/g, "'")}\``,
119
+ "",
120
+ "**Reason / 原因:**",
121
+ "",
122
+ quote(reason === "" ? "(no reason given / 未给出原因)" : reason),
123
+ "",
124
+ "Allowing runs this one call now with full access; nothing else is approved. / 放行只执行这一次调用,不会放行其他调用。",
125
+ ].join("\n");
126
+
127
+ return {
128
+ questions: [
129
+ {
130
+ id: REVIEW_CHOICE_QUESTION_ID,
131
+ header: "Auto review",
132
+ question: `Run tool "${name}" anyway? / 仍要执行工具 "${name}" 吗?`,
133
+ detail,
134
+ options: [
135
+ {
136
+ label: KEEP_DENIED_LABEL,
137
+ description: "Do not run it; the agent sees the original denial. / 不执行,代理收到原拒绝结果。",
138
+ },
139
+ {
140
+ label: ALLOW_ONCE_LABEL,
141
+ description: "Run this call once, overriding the reviewer. / 越过审查,只执行这一次。",
142
+ },
143
+ ],
144
+ multiSelect: false,
145
+ },
146
+ ],
147
+ ...(exec?.agent !== undefined ? { agent: exec.agent } : {}),
148
+ ...(signal !== undefined ? { signal } : {}),
149
+ };
150
+ }
151
+
152
+ /**
153
+ * Map a `userQuestions` answer to a decision. Only the exact allow option
154
+ * allows; skipped, custom text, unknown labels and malformed answers deny.
155
+ *
156
+ * @param {unknown} answer - `{answers:[{id, selected:[label], custom?}]}`.
157
+ * @param {string} [allowLabel]
158
+ * @returns {"allow" | "deny"}
159
+ */
160
+ export function resolveChoice(answer, allowLabel = ALLOW_ONCE_LABEL) {
161
+ const answers = Array.isArray(answer?.answers) ? answer.answers : [];
162
+ const entry =
163
+ answers.find((item) => item?.id === REVIEW_CHOICE_QUESTION_ID) ??
164
+ (answers.length === 1 ? answers[0] : undefined);
165
+ const selected = Array.isArray(entry?.selected) ? entry.selected : [];
166
+ if (selected.length !== 1) return "deny";
167
+ return selected[0] === allowLabel ? "allow" : "deny";
168
+ }
169
+
170
+ /**
171
+ * Build the outermost `tools/pre-execute` listener.
172
+ *
173
+ * @param {object} deps
174
+ * @param {() => string} deps.onBlock - `"ask"` (default) or `"deny"`.
175
+ * @param {() => any} deps.userQuestions - current userQuestions service or undefined.
176
+ * @param {() => any} deps.approval - current approval service or undefined.
177
+ * @param {(error: unknown) => void} [deps.onAskError] - diagnostics hook.
178
+ * @returns {(exec: any, next: () => Promise<any>) => Promise<any>}
179
+ */
180
+ export function createReviewChoiceListener(deps) {
181
+ return async function reviewChoice(exec, next) {
182
+ const gate = await next();
183
+ try {
184
+ if (String(deps.onBlock() ?? "ask").trim().toLowerCase() === "deny") return gate;
185
+ const kind = reviewDenialKind(gate);
186
+ if (kind === undefined) return gate;
187
+ const agent = exec?.agent;
188
+ if (agent === undefined) return gate;
189
+ if (kind === "ask") {
190
+ // Under the `ask` policy the approval card already asks the user.
191
+ const approval = deps.approval();
192
+ const policy = approval?.effectivePolicy?.(agent.session);
193
+ if (policy !== "never") return gate;
194
+ }
195
+ const questions = deps.userQuestions();
196
+ if (typeof questions?.ask !== "function") return gate;
197
+
198
+ const signal = exec.signal instanceof AbortSignal ? exec.signal : undefined;
199
+ const answer = await questions.ask(reviewQuestion(exec, gate, signal));
200
+ if (signal?.aborted) return gate;
201
+ return resolveChoice(answer) === "allow" ? { kind: "allow" } : gate;
202
+ } catch (error) {
203
+ // NO_PROVIDER, ASK_CANCELLED, ASK_ABORTED, CALLER_NOT_LIVE, ... all
204
+ // keep the original denial. Never fall through to allow.
205
+ try {
206
+ deps.onAskError?.(error);
207
+ } catch {
208
+ // Diagnostics must never change the decision.
209
+ }
210
+ return gate;
211
+ }
212
+ };
213
+ }
214
+
215
+ /**
216
+ * Bound how long a reviewer stream may take to reach its terminal finish.
217
+ *
218
+ * When the deadline passes first, `onTimeout` runs (the caller aborts the
219
+ * underlying request) and a synthetic error finish is emitted. Auto review's
220
+ * `readDecision` turns it into `failed()`, which the choice listener then
221
+ * hands to the user. `timeoutMs <= 0` or non-finite disables the deadline.
222
+ *
223
+ * @param {AsyncIterable<object>} stream
224
+ * @param {number} timeoutMs
225
+ * @param {() => void} [onTimeout]
226
+ * @returns {AsyncGenerator<object>}
227
+ */
228
+ export async function* withReviewDeadline(stream, timeoutMs, onTimeout) {
229
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
230
+ yield* stream;
231
+ return;
232
+ }
233
+ const iterator = stream[Symbol.asyncIterator]();
234
+ let timer;
235
+ const expired = new Promise((resolve) => {
236
+ timer = setTimeout(() => resolve(TIMED_OUT), timeoutMs);
237
+ });
238
+ let finished = false;
239
+ let done = false;
240
+ try {
241
+ while (true) {
242
+ const step = finished ? await iterator.next() : await Promise.race([iterator.next(), expired]);
243
+ if (step === TIMED_OUT) {
244
+ try {
245
+ onTimeout?.();
246
+ } catch {
247
+ // Aborting is best effort; the synthetic finish is what matters.
248
+ }
249
+ Promise.resolve()
250
+ .then(() => iterator.return?.())
251
+ .catch(() => {});
252
+ done = true;
253
+ yield {
254
+ type: "finish",
255
+ reason: {
256
+ kind: "error",
257
+ failure: {
258
+ code: REVIEWER_TIMEOUT_CODE,
259
+ message: `reviewer did not finish within ${timeoutMs} ms`,
260
+ },
261
+ },
262
+ };
263
+ return;
264
+ }
265
+ if (step.done) {
266
+ done = true;
267
+ return;
268
+ }
269
+ if (step.value?.type === "finish") {
270
+ finished = true;
271
+ clearTimeout(timer);
272
+ }
273
+ yield step.value;
274
+ }
275
+ } finally {
276
+ clearTimeout(timer);
277
+ if (!done) await iterator.return?.();
278
+ }
279
+ }
280
+
281
+ const TIMED_OUT = Symbol("timed-out");
282
+
283
+ /**
284
+ * Keep one `tools/pre-execute` listener outermost in the cordis waterfall.
285
+ *
286
+ * cordis `register()` uses `unshift` for `prepend`, and `waterfall()` runs
287
+ * index 0 first, so the most recently registered prepend listener is the
288
+ * outermost one. The Auto review gate registers itself with `prepend: true`
289
+ * inside its own lifecycle effect, which may run before or after this plugin
290
+ * depending on service availability. To see the gate's result we must wrap it,
291
+ * so whenever another prepend listener for the same event registers, this
292
+ * re-registers ours on the next microtask, putting it back at index 0.
293
+ *
294
+ * @param {any} ctx - cordis context.
295
+ * @param {string} name - event name.
296
+ * @param {Function} listener
297
+ * @param {(error: unknown) => void} [onError]
298
+ * @returns {() => void} disposer.
299
+ */
300
+ export function keepOutermost(ctx, name, listener, onError) {
301
+ const options = { global: true, prepend: true };
302
+ let disposed = false;
303
+ let moving = false;
304
+ let pending = false;
305
+ let dispose = ctx.on(name, listener, options);
306
+
307
+ const disposeWatch = ctx.on(
308
+ "internal/listener",
309
+ (event, _listener, listenerOptions) => {
310
+ if (event !== name || moving || pending || disposed) return undefined;
311
+ if (listenerOptions !== true && !listenerOptions?.prepend) return undefined;
312
+ pending = true;
313
+ queueMicrotask(() => {
314
+ pending = false;
315
+ if (disposed) return;
316
+ moving = true;
317
+ try {
318
+ dispose();
319
+ dispose = ctx.on(name, listener, options);
320
+ } catch (error) {
321
+ onError?.(error);
322
+ } finally {
323
+ moving = false;
324
+ }
325
+ });
326
+ return undefined;
327
+ },
328
+ { global: true },
329
+ );
330
+
331
+ return () => {
332
+ disposed = true;
333
+ disposeWatch();
334
+ dispose();
335
+ };
336
+ }
@@ -80,6 +80,33 @@ export function reviewText(options) {
80
80
  return typeof options.messages === "string" ? options.messages : "";
81
81
  }
82
82
 
83
+ const TRUNCATION_MARKER =
84
+ "\n\n[... auto-review input truncated to fit the reviewer context window ...]\n\n";
85
+
86
+ /**
87
+ * Trim a review prompt while preserving both ends.
88
+ *
89
+ * Auto review renders `ENVIRONMENT`, `PROJECT_INSTRUCTIONS`, `FILTERED_HISTORY`
90
+ * and `PENDING_ACTION` in that order. A 32k-token local model cannot hold a long
91
+ * session's full history, but dropping the tail would also drop the pending
92
+ * tool call that is being judged. Keep the head (environment/instructions) and
93
+ * the tail (recent history and the pending action), and mark the cut.
94
+ *
95
+ * @param {string} text
96
+ * @param {number} maxChars - 0 or a non-finite value keeps the text unchanged.
97
+ * @returns {string}
98
+ */
99
+ export function truncateReviewText(text, maxChars) {
100
+ if (!Number.isFinite(maxChars) || maxChars <= 0) return text;
101
+ if (text.length <= maxChars) return text;
102
+ if (maxChars <= TRUNCATION_MARKER.length) return text.slice(-maxChars);
103
+
104
+ const budget = maxChars - TRUNCATION_MARKER.length;
105
+ const head = Math.floor(budget * 0.25);
106
+ const tail = budget - head;
107
+ return text.slice(0, head) + TRUNCATION_MARKER + text.slice(-tail);
108
+ }
109
+
83
110
  /**
84
111
  * Build the request Qwen3Guard expects.
85
112
  *
@@ -87,15 +114,18 @@ export function reviewText(options) {
87
114
  * not follow auto-review's JSON policy. Sending the policy as a system message
88
115
  * would be classified as part of the content, so only the pending-action text
89
116
  * is forwarded as the user turn. The output is short (`Safety:` plus
90
- * `Categories:`), so 128 tokens is ample.
117
+ * `Categories:`), so 128 tokens is ample. `store` is stripped because some
118
+ * OpenAI-compatible gateways reject it outright.
91
119
  *
92
120
  * @param {object} options - immutable Auto review request.
93
121
  * @param {string} provider
94
122
  * @param {string} model
123
+ * @param {number} [maxInputChars] - 0 disables truncation.
95
124
  * @returns {object}
96
125
  */
97
- export function qwen3GuardRequest(options, provider, model) {
98
- const { system: _system, messages: _messages, ...rest } = options;
126
+ export function qwen3GuardRequest(options, provider, model, maxInputChars = 0) {
127
+ const { system: _system, messages: _messages, store: _store, ...rest } = options;
128
+ const text = truncateReviewText(reviewText(options), maxInputChars);
99
129
  return {
100
130
  ...rest,
101
131
  provider,
@@ -103,7 +133,7 @@ export function qwen3GuardRequest(options, provider, model) {
103
133
  messages: [
104
134
  {
105
135
  role: "user",
106
- content: [{ type: "text", text: reviewText(options) }],
136
+ content: [{ type: "text", text }],
107
137
  },
108
138
  ],
109
139
  maxTokens: 128,
@@ -213,7 +243,8 @@ export async function* adaptQwen3GuardStream(stream) {
213
243
  * re-entered copy created by this function.
214
244
  *
215
245
  * @param {object} options - immutable LLM request.
216
- * @param {{ provider?: unknown, model?: unknown, protocol?: unknown }} route - configured route.
246
+ * @param {{ provider?: unknown, model?: unknown, protocol?: unknown, maxInputChars?: unknown, signal?: AbortSignal }} route
247
+ * - configured route; `signal` (the reviewer deadline) also aborts the dispatched request.
217
248
  * @param {(request: object) => unknown} dispatch - sends a request to the LLM service.
218
249
  * @returns the dispatched stream for a rewritten request, otherwise `undefined`.
219
250
  */
@@ -242,8 +273,21 @@ export function routeReview(options, route, dispatch) {
242
273
 
243
274
  const next =
244
275
  protocol === "qwen3guard"
245
- ? qwen3GuardRequest(options, targetProvider, targetModel)
276
+ ? qwen3GuardRequest(
277
+ options,
278
+ targetProvider,
279
+ targetModel,
280
+ Number(route.maxInputChars ?? 0),
281
+ )
246
282
  : { ...options, provider: targetProvider, model: targetModel };
283
+ // An optional route signal (the reviewer deadline) also cancels the
284
+ // dispatched request, alongside the caller's own signal.
285
+ if (route.signal instanceof AbortSignal) {
286
+ next.signal =
287
+ options.signal instanceof AbortSignal
288
+ ? AbortSignal.any([options.signal, route.signal])
289
+ : route.signal;
290
+ }
247
291
  rewritten.add(next);
248
292
  const stream = dispatch(next);
249
293
  return protocol === "qwen3guard" ? adaptQwen3GuardStream(stream) : stream;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-auto-check",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Give DeepSeek Harness Auto review its own reviewer model instead of following the main model.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -30,7 +30,7 @@
30
30
  "url": "https://github.com/ltzXiaoYanMo/dsh-auto-check/issues"
31
31
  },
32
32
  "scripts": {
33
- "test": "node --test test/review-route.test.mjs"
33
+ "test": "node --test \"test/*.test.mjs\""
34
34
  },
35
35
  "dsh": {
36
36
  "bundle": {
@@ -45,8 +45,5 @@
45
45
  "@deepseek-ai/dsh-api-remotes"
46
46
  ]
47
47
  }
48
- },
49
- "dependencies": {
50
- "dsh-auto-check": "^0.1.0"
51
48
  }
52
49
  }