@leonhardthust/dsh-decision-engine 1.9.2 → 1.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -2
- package/README.zh.md +2 -2
- package/config.example.yml +70 -30
- package/docs/releasing.md +4 -4
- package/lib/adapters/agent.js +167 -9
- package/lib/adapters/route.js +42 -10
- package/lib/adapters/tools.js +109 -6
- package/lib/adapters/translate.js +94 -9
- package/lib/client.js +695 -227
- package/lib/decision-log.js +501 -0
- package/lib/decision-store.js +756 -97
- package/lib/engine.js +44 -31
- package/lib/guard/effects.js +14 -0
- package/lib/guard/listener.js +28 -3
- package/lib/guard/messages.js +35 -0
- package/lib/index.js +330 -15
- package/lib/outcomes.js +3 -1
- package/lib/points/spec.js +86 -5
- package/lib/providers.js +57 -8
- package/lib/tolerance.js +50 -40
- package/package.json +1 -1
package/lib/adapters/tools.js
CHANGED
|
@@ -58,6 +58,45 @@ export function retrievalQuery(args) {
|
|
|
58
58
|
}
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
/**
|
|
62
|
+
* 时效性意图:出现这些词就说明用户要的是**此刻**的事实。
|
|
63
|
+
*
|
|
64
|
+
* 中文用子串匹配(没有词边界这回事),英文用 `\b` 防止 "known" 里的 "now"、
|
|
65
|
+
* "download" 里的 "load" 之类的假命中。
|
|
66
|
+
*/
|
|
67
|
+
export const TIME_SENSITIVE_PATTERN =
|
|
68
|
+
/最新|今天|当前|目前|现在|版本|发布|价格|新闻|实时|\b(?:latest|today|current|currently|version|versions|release|released|price|prices|pricing|news|now)\b/i;
|
|
69
|
+
|
|
70
|
+
/** 查询里出现它就说明这次要抓的是一个**具体地址**。 */
|
|
71
|
+
const URL_PATTERN = /(?:https?:\/\/|www\.)/i;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* 「这类检索绝不能交给模型决定跳过」的确定性判据。
|
|
75
|
+
*
|
|
76
|
+
* 为什么需要它:retrieval 点的 skip 在 enforce 下会变成 deny(真的把调用拦下来)。
|
|
77
|
+
* 模型的判断依据只有「recent 里是否已覆盖」和「再搜是否有新信息」,而下面三种
|
|
78
|
+
* 情况下这个依据**在原理上就不成立**:
|
|
79
|
+
*
|
|
80
|
+
* 1. web_fetch:调用方给的是一条具体 URL。要判断「这份文档已经在上下文里了」
|
|
81
|
+
* 需要内容指纹,我们没有;模型只能凭 URL 猜。
|
|
82
|
+
* 2. 查询里带 URL:同上——用户明确要抓这个地址,不是要「一般性的知识」。
|
|
83
|
+
* 3. 时效性关键词(最新/今天/版本/发布/价格……):这类问题**天然**需要重新
|
|
84
|
+
* 检索,哪怕上下文里提过一次——「上次的答案」正是它要更新的东西。
|
|
85
|
+
* `would_add` 问的是「再搜会不会有新信息」,对这一类几乎必然答「会」,
|
|
86
|
+
* 可一旦模型答错,代价是一次被判死的检索,而用户完全无法理解为什么。
|
|
87
|
+
*
|
|
88
|
+
* @param name - 工具名。
|
|
89
|
+
* @param query - `retrievalQuery()` 摘出来的查询文本。
|
|
90
|
+
* @returns 命中的保护类别;不命中返回 undefined(= 可以交给模型裁决)。
|
|
91
|
+
*/
|
|
92
|
+
export function retrievalGuardReason(name, query) {
|
|
93
|
+
if (name === "web_fetch") return "explicit-url";
|
|
94
|
+
const text = typeof query === "string" ? query : "";
|
|
95
|
+
if (URL_PATTERN.test(text)) return "url-in-query";
|
|
96
|
+
if (TIME_SENSITIVE_PATTERN.test(text)) return "time-sensitive";
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
|
99
|
+
|
|
61
100
|
/**
|
|
62
101
|
* 挂载检索必要性 + 技能选择两个监听器。
|
|
63
102
|
*
|
|
@@ -80,7 +119,7 @@ export function mountToolPoints(ctx, deps) {
|
|
|
80
119
|
if (name === SKILL_TOOL && runner.participates("skillPick")) {
|
|
81
120
|
try {
|
|
82
121
|
const requested = typeof exec.arguments?.name === "string" ? exec.arguments.name : "";
|
|
83
|
-
const candidates = await loadSkillCandidates(ctx, exec, cfg);
|
|
122
|
+
const candidates = await loadSkillCandidates(ctx, exec, cfg, requested);
|
|
84
123
|
// 候选太少时挑选没有意义,直接放行——别为一次「只有一个选项」付费。
|
|
85
124
|
if (candidates.length >= 2) {
|
|
86
125
|
const result = await runner.ask(
|
|
@@ -97,6 +136,28 @@ export function mountToolPoints(ctx, deps) {
|
|
|
97
136
|
if (runner.enforces("skillPick") && result.action?.kind === "redirect") {
|
|
98
137
|
const suggest = result.action.suggest;
|
|
99
138
|
if (typeof suggest === "string" && suggest.length > 0 && suggest !== requested) {
|
|
139
|
+
/**
|
|
140
|
+
* 修之前这里直接采用模型给的 suggest,不校验它是不是候选之一。
|
|
141
|
+
* 模型偶尔会答一个「听起来更对」的名字(甚至是被截断掉、根本没
|
|
142
|
+
* 进候选的那个),于是这次调用被 deny 并指去一个**不存在的技能**:
|
|
143
|
+
* 模型照着原因再试一次只会再错一次,一次本来正确的调用被拖成
|
|
144
|
+
* 两次失败。候选清单就在手里,白名单校验是零成本的。
|
|
145
|
+
*
|
|
146
|
+
* 不合法就按「没有更匹配的」处理(放行),并留一条记录——
|
|
147
|
+
* 否则这类模型漂移在用户侧完全不可见(他只会看到偶发的怪错误)。
|
|
148
|
+
*/
|
|
149
|
+
if (!candidates.some((item) => item.name === suggest)) {
|
|
150
|
+
await telemetry.log({
|
|
151
|
+
ts: new Date().toISOString(),
|
|
152
|
+
point: "skillPick",
|
|
153
|
+
tag: "adapter/skillPick",
|
|
154
|
+
action: "degraded",
|
|
155
|
+
detail: "skill-suggest-unlisted",
|
|
156
|
+
latencyMs: 0,
|
|
157
|
+
cached: false,
|
|
158
|
+
});
|
|
159
|
+
return next();
|
|
160
|
+
}
|
|
100
161
|
// 不改写参数(那会替模型做决定),而是拦下并告知更匹配的那个:
|
|
101
162
|
// 模型看到原因后可以自己重发,避免「装错再换」付两遍正文 token。
|
|
102
163
|
return {
|
|
@@ -117,6 +178,18 @@ export function mountToolPoints(ctx, deps) {
|
|
|
117
178
|
if (runner.participates("retrieval") && isRetrievalTool(name, deps.retrievalTools())) {
|
|
118
179
|
try {
|
|
119
180
|
const query = retrievalQuery(exec.arguments);
|
|
181
|
+
/**
|
|
182
|
+
* 确定性保护:命中就**不问模型**,直接放行。
|
|
183
|
+
*
|
|
184
|
+
* 修之前这里无条件交给模型判断,模型说 skip 就 deny。于是一次抓取
|
|
185
|
+
* 还没读过的 URL、或一句「查一下最新版本」,都可能因为 recent 里恰好
|
|
186
|
+
* 提过相关字眼被判死——而且 enforce 下是真的拦下,模型连重试的机会
|
|
187
|
+
* 都没有。这不是省钱,是把「不确定」变成了「确定地不检索」。
|
|
188
|
+
*
|
|
189
|
+
* 这里不写遥测:它根本不是一次裁决(没有问、没有答),写进
|
|
190
|
+
* telemetry.jsonl 会让「服务端说了什么」的流水账里多出没有服务端的行。
|
|
191
|
+
*/
|
|
192
|
+
if (retrievalGuardReason(name, query) !== undefined) return next();
|
|
120
193
|
const result = await runner.ask(
|
|
121
194
|
"retrieval",
|
|
122
195
|
{
|
|
@@ -162,9 +235,10 @@ export function mountToolPoints(ctx, deps) {
|
|
|
162
235
|
* @param ctx - 插件上下文(用于 `ctx.get('skills')`)。
|
|
163
236
|
* @param exec - 待执行的工具调用(提供 agent scope 与 cwd)。
|
|
164
237
|
* @param cfg - 当前配置。
|
|
238
|
+
* @param requested - 这次调用请求的技能名(保证它不会被截断掉)。
|
|
165
239
|
* @returns 候选数组;取不到时返回空数组。
|
|
166
240
|
*/
|
|
167
|
-
async function loadSkillCandidates(ctx, exec, cfg) {
|
|
241
|
+
async function loadSkillCandidates(ctx, exec, cfg, requested) {
|
|
168
242
|
const skills = ctx.get?.("skills");
|
|
169
243
|
if (skills === undefined || typeof skills.list !== "function") return [];
|
|
170
244
|
let summaries;
|
|
@@ -179,8 +253,37 @@ async function loadSkillCandidates(ctx, exec, cfg) {
|
|
|
179
253
|
}
|
|
180
254
|
// 决策模型每次只有很小的一段 state;候选超过上限就截断,宁可少挑不可超长。
|
|
181
255
|
const limit = Math.max(2, cfg.skillPickCandidates ?? 8);
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
256
|
+
/**
|
|
257
|
+
* 先规范化 + 去重。
|
|
258
|
+
*
|
|
259
|
+
* 去重不是洁癖:同名技能在 list 里出现两次时,state 会白花一行,
|
|
260
|
+
* 而且 choice 的 criteria 是按键名索引的对象——同名条目会互相覆盖,
|
|
261
|
+
* 模型看到两个候选、实际只有一份描述。
|
|
262
|
+
* 没有名字的条目直接丢掉:它既不能出现在 criteria 里,也不可能被选中。
|
|
263
|
+
*/
|
|
264
|
+
const seen = new Set();
|
|
265
|
+
const unique = [];
|
|
266
|
+
for (const summary of summaries ?? []) {
|
|
267
|
+
const nameValue = String(summary?.name ?? "");
|
|
268
|
+
if (nameValue.length === 0 || seen.has(nameValue)) continue;
|
|
269
|
+
seen.add(nameValue);
|
|
270
|
+
unique.push({ name: nameValue, description: String(summary?.description ?? "").slice(0, 160) });
|
|
271
|
+
}
|
|
272
|
+
const head = unique.slice(0, limit);
|
|
273
|
+
/**
|
|
274
|
+
* 用户当前请求的那个技能必须留在候选里。
|
|
275
|
+
*
|
|
276
|
+
* 修之前是直接 `summaries.slice(0, limit)`,而 skills.list() 的顺序与本次
|
|
277
|
+
* 请求无关。技能多的环境里(超过上限)请求第 9 个技能时,它根本不在候选
|
|
278
|
+
* 里,模型只能从「别的技能」里挑一个——一次完全正确的调用被判成错误并改写,
|
|
279
|
+
* 这比不做技能选择坏得多(用户看到的是「我请求的技能被告知不存在」)。
|
|
280
|
+
*
|
|
281
|
+
* 替换最后一项而不是追加:候选上限是给决策模型 state 的长度预算,
|
|
282
|
+
* 追加会突破预算。替换掉的那一项排在最后,本来就最可能是被截断的边界项。
|
|
283
|
+
*/
|
|
284
|
+
if (head.length > 0 && seen.has(requested) && !head.some((item) => item.name === requested)) {
|
|
285
|
+
const wanted = unique.find((item) => item.name === requested);
|
|
286
|
+
if (wanted !== undefined) head[head.length - 1] = wanted;
|
|
287
|
+
}
|
|
288
|
+
return head;
|
|
186
289
|
}
|
|
@@ -18,6 +18,19 @@
|
|
|
18
18
|
* @module dsh-decision-engine/adapters/translate
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
+
import { createHash } from "node:crypto";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* 本地裁决缓存的「问法版本号」。
|
|
25
|
+
*
|
|
26
|
+
* 缓存里存的是**裁决**(要不要翻译),而一条裁决只对产生它的那套 questions
|
|
27
|
+
* 成立。spec.js 里 cotTranslate 的问法一改(加一问、换措辞、换判定口径),
|
|
28
|
+
* 旧裁决立刻失效——可这个缓存是内存里的 Map,热更新代码时它会跨版本存活。
|
|
29
|
+
* 把版本号写进键里,改问法时手动 +1 是最小的保险:忘了 +1 的后果是「新问法
|
|
30
|
+
* 一直在用老答案」,而且从现象上完全看不出来(翻译时好时坏,与文本无关)。
|
|
31
|
+
*/
|
|
32
|
+
const QUESTIONS_VERSION = 1;
|
|
33
|
+
|
|
21
34
|
/** 判断一段文本里「值得翻译的正文」占比是否够高(本地预筛,免费的)。 */
|
|
22
35
|
function looksLikeProse(text) {
|
|
23
36
|
if (typeof text !== "string" || text.length === 0) return false;
|
|
@@ -34,6 +47,32 @@ function looksLikeProse(text) {
|
|
|
34
47
|
return letters >= 4;
|
|
35
48
|
}
|
|
36
49
|
|
|
50
|
+
/**
|
|
51
|
+
* 构造一条裁决的缓存键。
|
|
52
|
+
*
|
|
53
|
+
* 修之前键是 `${targetLanguage}|${text.length}|${text.slice(0, 64)}`:
|
|
54
|
+
*
|
|
55
|
+
* - 同长 + 前 64 字符相同、后面不同的两段文本会共用一条裁决。翻译文本
|
|
56
|
+
* 经常以同样的开场白起头("Here's my plan: ..."),撞键不是理论问题;
|
|
57
|
+
* - `sourceLanguage` 根本不在键里:同一段英文,一次标 en、一次标 zh,
|
|
58
|
+
* 第二次直接复用第一次的结论——可 source 正是「是否已是目标语言」那
|
|
59
|
+
* 一问的输入。
|
|
60
|
+
*
|
|
61
|
+
* 现在键 = 问法版本 + 源语言 + 目标语言 + **全文**的 sha256。为什么用摘要而
|
|
62
|
+
* 不是全文:这个 Map 最多留 256 条,键若存全文,等于把 256 段思维链全文钉在
|
|
63
|
+
* 内存里;摘要定长,缓存体积与文本长度无关。
|
|
64
|
+
*
|
|
65
|
+
* @param spec - 翻译规格(只读 `sourceLanguage` / `targetLanguage`)。
|
|
66
|
+
* @param text - 待翻译全文。
|
|
67
|
+
* @returns 缓存键。
|
|
68
|
+
*/
|
|
69
|
+
function decisionKey(spec, text) {
|
|
70
|
+
const digest = createHash("sha256").update(text, "utf8").digest("hex");
|
|
71
|
+
const source = typeof spec?.sourceLanguage === "string" ? spec.sourceLanguage : "auto";
|
|
72
|
+
const target = typeof spec?.targetLanguage === "string" ? spec.targetLanguage : "auto";
|
|
73
|
+
return `${QUESTIONS_VERSION}|${source}|${target}|${digest}`;
|
|
74
|
+
}
|
|
75
|
+
|
|
37
76
|
/**
|
|
38
77
|
* 包住 translator.translate。
|
|
39
78
|
*
|
|
@@ -70,9 +109,39 @@ export function mountCotTranslatePoint(ctx, deps) {
|
|
|
70
109
|
if (!runner.participates("cotTranslate")) return original.call(this, spec, signal);
|
|
71
110
|
|
|
72
111
|
try {
|
|
112
|
+
/**
|
|
113
|
+
* 本点此刻是否允许真正改变行为。一次执行里只取一次。
|
|
114
|
+
*
|
|
115
|
+
* 为什么先取而不是每处现问:下面有三处要用它(本地预筛、缓存命中、
|
|
116
|
+
* 裁决落地),每次都读配置的话,配置中途被改会得到「预筛按 enforce
|
|
117
|
+
* 跳过、落裁决按 observe 放行」这种自相矛盾的一次执行。
|
|
118
|
+
*/
|
|
119
|
+
const enforced = runner.enforces("cotTranslate");
|
|
73
120
|
const text = typeof spec?.text === "string" ? spec.text : "";
|
|
74
121
|
// 本地预筛:明显不需要翻译的文本零成本跳过,不必问模型。
|
|
75
122
|
if (!looksLikeProse(text)) {
|
|
123
|
+
/**
|
|
124
|
+
* 修之前这个 return 跑在 `enforces()` 之前:observe 模式下也直接返回
|
|
125
|
+
* 原文,于是「只观察」变成了「真的跳过翻译」——用户拿到的是没翻译的
|
|
126
|
+
* 原文,而遥测里这次却被记成一次 observe。observe 的契约是只记录、
|
|
127
|
+
* 不改变行为;违反它比不开启这个点更坏,因为观察期得出的数据会被
|
|
128
|
+
* 当成「没人开过这个点」的干净样本。
|
|
129
|
+
*
|
|
130
|
+
* 现在:只有 enforce 才允许这条路真的返回原文;observe 一律走原翻译器,
|
|
131
|
+
* 只留一条「本该跳过」的记录,这样观察数据里仍能看到它到底值不值得开。
|
|
132
|
+
*/
|
|
133
|
+
if (!enforced) {
|
|
134
|
+
await telemetry.log({
|
|
135
|
+
ts: new Date().toISOString(),
|
|
136
|
+
point: "cotTranslate",
|
|
137
|
+
tag: "cotTranslate",
|
|
138
|
+
action: "skip",
|
|
139
|
+
detail: "decision-engine-cot: 本地预筛判定无正文;observe 模式仍走原翻译器(本该跳过)",
|
|
140
|
+
latencyMs: 0,
|
|
141
|
+
cached: false,
|
|
142
|
+
});
|
|
143
|
+
return original.call(this, spec, signal);
|
|
144
|
+
}
|
|
76
145
|
await telemetry.log({
|
|
77
146
|
ts: new Date().toISOString(),
|
|
78
147
|
point: "cotTranslate",
|
|
@@ -85,11 +154,27 @@ export function mountCotTranslatePoint(ctx, deps) {
|
|
|
85
154
|
return text;
|
|
86
155
|
}
|
|
87
156
|
|
|
88
|
-
//
|
|
89
|
-
const key =
|
|
157
|
+
// 缓存键:问法版本 + 源/目标语言 + 全文摘要(见 decisionKey 的说明)。
|
|
158
|
+
const key = decisionKey(spec, text);
|
|
90
159
|
let verdict = decisions.get(key);
|
|
91
160
|
if (verdict === undefined) {
|
|
92
161
|
const cfg = getConfig();
|
|
162
|
+
/**
|
|
163
|
+
* 会话引用:`spec.sessionId` 只有**付费** provider 才有
|
|
164
|
+
* (`AnonymousTranslationSpec` 没有这个字段)。因此这个点的会话归属是
|
|
165
|
+
* 「有则记、没有就算不可归属」:免费通道(google / bing)的决策不会
|
|
166
|
+
* 出现在任何会话的「决策历史」里。这是刻意的取舍,不是遗漏——为它
|
|
167
|
+
* 编造一个会话 id 会让用户看到一条不属于自己会话的记录,比缺失更糟
|
|
168
|
+
* (见 DEC-022)。
|
|
169
|
+
*
|
|
170
|
+
* 修之前这里传的是 `{ sessionId }`,而 engine.js 的 `ask()` 只从
|
|
171
|
+
* `extra.session` 取会话——字段名对不上,于是**付费通道的翻译决策也
|
|
172
|
+
* 永远归不到会话**:明细里的 sessionId 恒为空串,界面上永远不进任何
|
|
173
|
+
* 会话页。引擎的 `sessionIdOf()` 本来就接受 id 字符串,所以这里直接按
|
|
174
|
+
* 它认的字段名传,不新增第二个字段(两个名字收同一件事,迟早只改一个)。
|
|
175
|
+
*/
|
|
176
|
+
const sessionRef =
|
|
177
|
+
typeof spec.sessionId === "string" && spec.sessionId.length > 0 ? spec.sessionId : undefined;
|
|
93
178
|
const result = await runner.ask(
|
|
94
179
|
"cotTranslate",
|
|
95
180
|
{
|
|
@@ -98,15 +183,13 @@ export function mountCotTranslatePoint(ctx, deps) {
|
|
|
98
183
|
sourceLanguage: spec.sourceLanguage,
|
|
99
184
|
head: text.slice(0, 400),
|
|
100
185
|
},
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
// 不属于自己会话的记录,比缺失更糟(见 DEC-022)。
|
|
106
|
-
{ signal, sessionId: typeof spec.sessionId === "string" ? spec.sessionId : "" },
|
|
186
|
+
{
|
|
187
|
+
signal,
|
|
188
|
+
...(sessionRef === undefined ? {} : { session: sessionRef }),
|
|
189
|
+
},
|
|
107
190
|
);
|
|
108
191
|
// 降级 / observe / 关闭:一律按「需要翻译」处理,走原方法。
|
|
109
|
-
if (result.action === undefined || !
|
|
192
|
+
if (result.action === undefined || !enforced) {
|
|
110
193
|
return original.call(this, spec, signal);
|
|
111
194
|
}
|
|
112
195
|
verdict = result.action.kind === "translate";
|
|
@@ -114,6 +197,8 @@ export function mountCotTranslatePoint(ctx, deps) {
|
|
|
114
197
|
}
|
|
115
198
|
|
|
116
199
|
if (verdict === true) return original.call(this, spec, signal);
|
|
200
|
+
// 缓存里可能存着本点 enforce 期的裁决:切到 observe 后不能再拿它改行为。
|
|
201
|
+
if (!enforced) return original.call(this, spec, signal);
|
|
117
202
|
|
|
118
203
|
// 判为不需要翻译:返回原文。这与「已经是目标语言」的语义一致,
|
|
119
204
|
// 调用方不会看到异常,用户看到的还是那段文本。
|