@leonhardthust/dsh-decision-engine 1.9.2 → 1.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/engine.js CHANGED
@@ -28,9 +28,11 @@ const NOOP = Object.freeze({ mode: "off", action: undefined });
28
28
  * @param options.modeOf - `(cfg, id) => 'off'|'observe'|'enforce'`。
29
29
  * @param options.log - `(entry) => Promise<void>`,写 telemetry.jsonl。
30
30
  * @param options.specOf - `(id) => spec`,从注册表取 build/parse/decide。
31
- * @param options.recordDetail - 可选。`(record) => void`,写 decisions.jsonl
32
- * (「决策历史」页的数据源)。缺省时不写——单元测试里的 runner 只关心
33
- * 裁决语义,不该被明细存储拖进来。
31
+ * @param options.recordDetail - 可选。`(record, session) => void`,写决策明细
32
+ * (「决策历史」页的数据源)。第二个参数是**原始的会话引用**(Session 对象
33
+ * 或 id 字符串):`session`/`workspace` 分文件模式要用它解析会话名与工作区名,
34
+ * 而名字不在记录体里。缺省时不写——单元测试里的 runner 只关心裁决语义,
35
+ * 不该被明细存储拖进来。
34
36
  */
35
37
  export function createDecisionRunner(options) {
36
38
  const { getConfig, client, logger, modeOf, log, specOf } = options;
@@ -99,7 +101,12 @@ export function createDecisionRunner(options) {
99
101
  *
100
102
  * @param id - 决策点 id(同时是遥测里的 `point` 字段)。
101
103
  * @param input - 传给 spec.build 的现场数据。
102
- * @param extra - 可选:signal / tool / degradeHint。
104
+ * @param extra - 可选:signal / tool / session。
105
+ * `session` 是**会话引用**,接受 Session 对象或 id 字符串 —— 思维链翻译点
106
+ * 只拿得到 `TranslationSpec.sessionId`,没有 Session 对象可传。它不参与
107
+ * 裁决,只用于把这条明细归属到会话。字段名就叫 `session`(不是
108
+ * `sessionId`):`sessionIdOf()` 与明细存储都从这里取,两个名字收同一件事
109
+ * 迟早只会改一个——那正是「付费通道的翻译决策归不到会话」那次的成因。
103
110
  *
104
111
  * 软缓存(同类复用)不在引擎层开启:决策点的裁决要么与完整 state 一一
105
112
  * 对应(精确键),要么干脆不复用。归一化签名会丢掉现场内容,任何
@@ -136,34 +143,40 @@ export function createDecisionRunner(options) {
136
143
  */
137
144
  const noteDetail = (builtState, builtQuestions, outcome, answeredPart) => {
138
145
  try {
139
- recordDetail({
140
- ts: new Date().toISOString(),
141
- sessionId,
142
- point: id,
143
- tag: id,
144
- mode,
145
- topMode: cfg.mode,
146
- provider: outcome.provider,
147
- model: outcome.model,
148
- action: outcome.action,
149
- detail: outcome.detail,
150
- cached: outcome.cached === true,
151
- degraded: outcome.degraded === true,
152
- latencyMs: outcome.latencyMs,
153
- inputTokens: outcome.inputTokens,
154
- outputTokens: outcome.outputTokens,
155
- ...(extra.tool === undefined ? {} : { tool: extra.tool }),
156
- ...(extra.turn === undefined ? {} : { turn: extra.turn }),
157
- ...(extra.step === undefined ? {} : { step: extra.step }),
158
- ...(outcome.scores === undefined ? {} : { scores: outcome.scores }),
159
- // 内容一律交原始三件套;截断与脱敏在存储层做(它知道配置上限,
160
- // 也负责「只落本地」的那条边界)。
161
- content: {
162
- state: builtState,
163
- questions: builtQuestions,
164
- ...(answeredPart === undefined ? {} : { answers: answeredPart }),
146
+ recordDetail(
147
+ {
148
+ ts: new Date().toISOString(),
149
+ sessionId,
150
+ point: id,
151
+ tag: id,
152
+ mode,
153
+ topMode: cfg.mode,
154
+ provider: outcome.provider,
155
+ model: outcome.model,
156
+ action: outcome.action,
157
+ detail: outcome.detail,
158
+ cached: outcome.cached === true,
159
+ degraded: outcome.degraded === true,
160
+ latencyMs: outcome.latencyMs,
161
+ inputTokens: outcome.inputTokens,
162
+ outputTokens: outcome.outputTokens,
163
+ ...(extra.tool === undefined ? {} : { tool: extra.tool }),
164
+ ...(extra.turn === undefined ? {} : { turn: extra.turn }),
165
+ ...(extra.step === undefined ? {} : { step: extra.step }),
166
+ ...(outcome.scores === undefined ? {} : { scores: outcome.scores }),
167
+ // 内容一律交原始三件套;截断与脱敏在存储层做(它知道配置上限,
168
+ // 也负责「只落本地」的那条边界)。
169
+ content: {
170
+ state: builtState,
171
+ questions: builtQuestions,
172
+ ...(answeredPart === undefined ? {} : { answers: answeredPart }),
173
+ },
165
174
  },
166
- });
175
+ // 会话**对象**也一并交给存储层:session/workspace 分文件模式要拿
176
+ // 会话名与工作区名做文件名,而名字只挂在 Session 上(不是 id 字符串)。
177
+ // 思维链翻译点只有 id 字符串,同样原样传下去,由解析方兜底。
178
+ extra.session,
179
+ );
167
180
  } catch (error) {
168
181
  // 明细是旁路:写不进去只留一行警告,绝不影响裁决。
169
182
  logger?.warn?.(
@@ -38,6 +38,20 @@ export function classifyToolEffect({ toolName, args, trustedContext = {}, scope
38
38
  }
39
39
  }
40
40
  if (toolName === "working_directory" && fields(args, [])) return known("read_local", "working-directory-query", { recoverability: "no-mutation" });
41
+ // `job_output` 是只读查询:它只读取指定后台任务已经产生的输出,不写文件、
42
+ // 不改任务状态。缺了这条契约,它就会落进末尾的 unknown 分支并把 highImpact
43
+ // 设成 true,于是一次纯读取也要先过模型复核(用户实际感受到的「读一下任务
44
+ // 输出都被拦」)。
45
+ //
46
+ // 只在参数形状**严格**匹配(`job_id` 字符串、`wait` 布尔、`timeout_ms` 有限数)
47
+ // 时才下这个结论:多出任何未知字段,就可能是在借这个工具做别的事(往参数里
48
+ // 夹带别名或其他动作),那时必须退回 unknown 交给模型复核,不能凭工具名放行。
49
+ if (toolName === "job_output" && fields(args, ["job_id", "wait", "timeout_ms"], ["job_id"])
50
+ && typeof args.job_id === "string"
51
+ && (args.wait === undefined || typeof args.wait === "boolean")
52
+ && (args.timeout_ms === undefined || Number.isFinite(args.timeout_ms))) {
53
+ return known("read_local", "known-local-job-output-query", { recoverability: "no-mutation" });
54
+ }
41
55
  if (toolName === "skill" && fields(args, ["name"], ["name"]) && typeof args.name === "string") return known("read_local", "known-skill-document-query", { recoverability: "no-mutation" });
42
56
  if (toolName === "cordis_inspect_list" && fields(args, [])) return known("read_local", "known-runtime-schema-query", { recoverability: "no-mutation" });
43
57
  if (toolName === "list_subagent_models" && fields(args, ["provider", "model"]) && strings(args, ["provider", "model"])) return known("read_local", "known-model-route-query", { recoverability: "no-mutation" });
@@ -5,8 +5,8 @@ import { GUARD_QUESTIONS_VERSION, guardQuestions, parseModelAdvisory, decideMode
5
5
  import { createGuardAudit, hashSummary, joinHostOutcome } from "./audit.js";
6
6
  import { classifyWorkspaceTarget } from "../scope.js";
7
7
  import { resolveTolerance, severityOf, toleranceCutoff } from "../tolerance.js";
8
- import { redactGuardData, reviewWithFallback } from "./fallback.js";
9
- import { guardOutcomeMessage } from "./messages.js";
8
+ import { redactGuardData, reviewWithFallback, GUARD_FALLBACKS } from "./fallback.js";
9
+ import { guardOutcomeMessage, toleranceAskAction } from "./messages.js";
10
10
 
11
11
  export function approvalOutcomeForCall(session, callId, toolName, afterSeq) {
12
12
  if (!Number.isFinite(afterSeq) || !callId || typeof session?.snapshotEvents !== "function") return "unknown";
@@ -198,7 +198,27 @@ export function mountGuard(ctx, deps) {
198
198
  tolerated = resolveTolerance({ level: config.tolerance, legacyKind: baseAction.kind, severity, cutoff: cutoffInfo.cutoff, mandatory: enforcingPolicy ? mandatory : undefined, approvalAvailable: context.approvalAvailable });
199
199
  let action = tolerated.kind === baseAction.kind ? baseAction : { kind: tolerated.kind, reason: tolerated.note };
200
200
  decisionSource = "primary_model";
201
- const unresolved = mandatory.status === "unknown";
201
+ // 为什么「本地未知副作用」可以只凭主模型意见收尾(去掉冗余的第二次复核):
202
+ //
203
+ // `mandatory=unknown` 只表示「本地静态规则无法判定这次调用的权限边界」,
204
+ // 它本身**不是**「需要升级」。当 effect 只是 unknown(没有任何强制权限
205
+ // 约束)、主模型给出了有效意见且结论是 delegate、档位也没把它升级成询问、
206
+ // 而回退策略又只是「再叫一个模型复核一次」(session / model)时,再为
207
+ // `mandatory=unknown` 单独跑一次回退,等于给每条无法静态判定的本地命令
208
+ // 稳定加倍一次模型调用——正是用户报告的「决策模型定不了论就回退」。
209
+ //
210
+ // 刻意**不**豁免的三种情况:
211
+ // - 外网/MCP 副作用(`external_network`):边界在宿主之外,第二次独立
212
+ // 复核是刻意保留的安全余量,不是冗余;
213
+ // - `baseAction` / `action` 是 ask,或主模型失效:这是软性判断本身要问,
214
+ // 与原逻辑一致;
215
+ // - 显式 `allow` / `deny` 回退策略:那是用户写下的政策裁决,不能被这条
216
+ // 豁免绕过(`allow` 会放行、`deny` 会拦截,都不是「多跑一次模型」)。
217
+ const fallbackPolicy = GUARD_FALLBACKS.includes(config.guardFallback) ? config.guardFallback : "session";
218
+ const localUnknownDeferOnly = effect.kind === "unknown" && effect.known === false
219
+ && advisory?.status === "valid" && baseAction.kind === "delegate" && tolerated.kind === "delegate"
220
+ && ["session", "model"].includes(fallbackPolicy);
221
+ const unresolved = mandatory.status === "unknown" && !localUnknownDeferOnly;
202
222
  if (automaticReview(context, config) && (unresolved || baseAction.kind === "ask" || action.kind === "ask" || primary.status === "failed")) {
203
223
  action = await runFallback();
204
224
  } else if (primary.status === "failed") {
@@ -206,6 +226,11 @@ export function mountGuard(ctx, deps) {
206
226
  } else if (action.kind === "ask" && context.approvalPolicy === "never") {
207
227
  action = { ...action, kind: "deny" };
208
228
  }
229
+ // 档位把「默认放行」升级成「询问」时,`action.reason` 是档位 note(含
230
+ // `severity=` 与阈值小数)——那是审计口径,不能当审批卡标题。这里换成
231
+ // 固定双语文案;note 仍进 `humanReason` / 遥测 detail,事后核对仍能看出
232
+ // 是哪一档、什么分数。放在回退之后:回退给出的裁决有自己的文案。
233
+ if (tolerated.changed && action.kind === "ask") action = toleranceAskAction(action);
209
234
  return finish(action, degradedReason);
210
235
  } catch {
211
236
  ctx.logger?.warn?.("decision-engine: guard failed; explicit permission constraints remain protected");
@@ -21,9 +21,44 @@ const MESSAGES = {
21
21
  "advisory-failed-unknown-effect": ["模型审查失败,无法确认此工具的副作用", "Model review failed and the tool's effects remain unresolved"],
22
22
  "guard-exception": ["审查链发生内部错误,未将错误视为授权", "The review pipeline failed; an error does not grant authorization"],
23
23
  "review-cancelled": ["审查已取消", "Review was cancelled"],
24
+ // 安全档位(tolerance)把「默认放行」升级成「询问」时的固定文案。
25
+ //
26
+ // 为什么需要一条独立词条而不是复用上面任何一条:那些描述的是**模型发现了
27
+ // 什么**(注入嫌疑、材料风险……),而这一条描述的是**用户选的档位要求问**。
28
+ // 借用前者会让审计记录说谎——一次模型判定低风险的调用,标题却写着「模型
29
+ // 提示可能有风险」。文案里刻意不出现档位 note 里的 `severity=` / 阈值小数:
30
+ // 审批卡标题是给人读的一行话,不是遥测字段。
31
+ "tolerance-ask": ["安全档位要求确认此次调用", "The safety level asks for confirmation"],
24
32
  };
25
33
 
34
+ /**
35
+ * 给「安全档位把默认放行升级成询问」的裁决挂上固定双语标题。
36
+ *
37
+ * 为什么不能只靠 `MESSAGES` 按 `reason` 查表:这条 ask 的 `reason` 是档位
38
+ * note(`tolerance=relaxed(严重度 0.350 ≥ 0.600…)`),属于审计/遥测口径,
39
+ * 含字段名与裸小数,不能拿去当标题;但它又必须**逐字保留**,否则事后核对
40
+ * 无法解释「这次为什么问」。于是两条通道分开:
41
+ * - `reason` 保持档位 note(审计与遥测原文);
42
+ * - `displayReason` 用本条固定文案(宿主审批卡只渲染它)。
43
+ *
44
+ * @param action - 当前 ask 裁决(`{kind:'ask', reason:<档位 note>}`)。
45
+ * @returns 附带 `reasonCode: 'tolerance-ask'` 与 `displayReason` 的副本。
46
+ */
47
+ export function toleranceAskAction(action) {
48
+ const [zh, en] = MESSAGES["tolerance-ask"];
49
+ return { ...action, reasonCode: "tolerance-ask", displayReason: { zh, en } };
50
+ }
51
+
26
52
  export function guardOutcomeMessage(action, mandatory) {
53
+ // 已显式带 `reasonCode` 的裁决(目前只有 tolerance 触发的 ask):`reasonCode`
54
+ // 是权威口径,`reason` 是调用方给的审计原文,这里只补/保留 `displayReason`。
55
+ // 若走下面的通用分支,`reason` 会被文案表覆盖(档位 note 丢失),
56
+ // `displayReason` 又会被填成与 `reason` 逐字相同的旧口径——DEC-041 已取消
57
+ // 那条契约,标题必须说日常语言。
58
+ const explicit = action.reasonCode !== undefined && Object.hasOwn(MESSAGES, action.reasonCode) ? MESSAGES[action.reasonCode] : undefined;
59
+ if (explicit) {
60
+ return { ...action, ...(action.kind === "ask" ? { displayReason: action.displayReason ?? { zh: explicit[0], en: explicit[1] } } : {}) };
61
+ }
27
62
  const code = action.reason ?? mandatory?.reasonCodes?.[0] ?? action.kind;
28
63
  const message = MESSAGES[code];
29
64
  if (!message) return { ...action, reasonCode: code };