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 +21 -0
- package/README.md +485 -2
- package/config.example.yml +223 -0
- package/cordis.patch.yml +14 -0
- package/icon.svg +17 -0
- package/lib/adapters/agent.js +307 -0
- package/lib/adapters/route.js +145 -0
- package/lib/adapters/tools.js +183 -0
- package/lib/adapters/translate.js +138 -0
- package/lib/budget.js +92 -0
- package/lib/client.js +1248 -0
- package/lib/config-file.js +136 -0
- package/lib/credentials.js +187 -0
- package/lib/decision-cache.js +182 -0
- package/lib/decision-points.js +205 -0
- package/lib/engine.js +140 -0
- package/lib/index.js +2083 -0
- package/lib/points/spec.js +598 -0
- package/lib/providers.js +525 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +82 -4
- package/scripts/migrate-profile.mjs +102 -0
|
@@ -0,0 +1,598 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 每个决策点的「问题 / 答案 / 处置」三件套 —— 全部是纯函数。
|
|
3
|
+
*
|
|
4
|
+
* 这一层刻意不含任何 DSH 依赖:不发请求、不碰会话、不读文件。它只做三件事:
|
|
5
|
+
*
|
|
6
|
+
* build(input) 把某次触发点的现场压成 state + questions(发给决策模型的东西)
|
|
7
|
+
* parse(answers) 校验决策模型回来的答案形状,不合格一律返回 undefined(→ 降级)
|
|
8
|
+
* decide(parsed, cfg) 纯函数映射成处置(switch / advise / prune / continue / deny …)
|
|
9
|
+
*
|
|
10
|
+
* 之所以这样切分:决策模型的端点只接受三种题型(choice / score / noul),
|
|
11
|
+
* 每种题型的返回形状都不同,而「答案形状不对」必须降级放行——不能因为模型
|
|
12
|
+
* 返回畸形数据就放宽策略。把形状校验集中在这里,每个决策点都走同一条
|
|
13
|
+
* 「不合格即 undefined」的路径,测试也只需要对着这三个纯函数写。
|
|
14
|
+
*
|
|
15
|
+
* 端点实测约束(写死在这里,避免各决策点各写一套):
|
|
16
|
+
*
|
|
17
|
+
* choice criteria 必须是非空对象或数组 → 回 {type:'choice', choice, confidence,
|
|
18
|
+
* probabilities, none_probability}
|
|
19
|
+
* score criteria 必须是 2–10 个档位 → 回 {type:'score', score: 浮点档位, confidence,
|
|
20
|
+
* probabilities, legend}
|
|
21
|
+
* noul 回 {type:'noul', noul: 概率}
|
|
22
|
+
*
|
|
23
|
+
* @module dsh-decision-engine/points/spec
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 校验一个概率值:必须是 [0,1] 内的有限数,否则返回 undefined。
|
|
28
|
+
*
|
|
29
|
+
* 刻意**不做夹取**:越界值说明响应是畸形的,应当走降级(不改行为)。
|
|
30
|
+
* 夹取会把 `noul: 1.7` 悄悄变成 1.0,等于让畸形数据取得了最强的效果——
|
|
31
|
+
* 与「形状不符即降级」的原则相反。
|
|
32
|
+
*
|
|
33
|
+
* @param value - 待校验的值。
|
|
34
|
+
* @returns 合法的概率,或 undefined。
|
|
35
|
+
*/
|
|
36
|
+
function unit(value) {
|
|
37
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return undefined;
|
|
38
|
+
if (value < 0 || value > 1) return undefined;
|
|
39
|
+
return value;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* 把浮点比值夹到 [0,1]。
|
|
44
|
+
*
|
|
45
|
+
* 只用于 score 的归一化:`score / (levels-1)` 在浮点边界上可能给出
|
|
46
|
+
* 1.0000000000000002(例如 4/4 的累积误差),那是合法的 1,不该被判畸形。
|
|
47
|
+
*
|
|
48
|
+
* @param value - 比值。
|
|
49
|
+
* @returns 夹取后的值,或 undefined(非有限数)。
|
|
50
|
+
*/
|
|
51
|
+
function clamp01(value) {
|
|
52
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return undefined;
|
|
53
|
+
return Math.min(1, Math.max(0, value));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 读一条 choice 答案。
|
|
58
|
+
* @param answers - 端点返回的 answers 对象。
|
|
59
|
+
* @param key - 问题 id。
|
|
60
|
+
* @returns `{choice, confidence, probabilities}`,或 undefined。
|
|
61
|
+
*/
|
|
62
|
+
export function readChoice(answers, key) {
|
|
63
|
+
const raw = answers?.[key];
|
|
64
|
+
if (raw === null || typeof raw !== "object") return undefined;
|
|
65
|
+
if (raw.type !== "choice") return undefined;
|
|
66
|
+
if (typeof raw.choice !== "string" || raw.choice.length === 0) return undefined;
|
|
67
|
+
const confidence = unit(raw.confidence);
|
|
68
|
+
if (confidence === undefined) return undefined;
|
|
69
|
+
return {
|
|
70
|
+
choice: raw.choice,
|
|
71
|
+
confidence,
|
|
72
|
+
probabilities: raw.probabilities !== null && typeof raw.probabilities === "object" ? raw.probabilities : {},
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* 读一条 noul 答案。
|
|
78
|
+
* @param answers - 端点返回的 answers 对象。
|
|
79
|
+
* @param key - 问题 id。
|
|
80
|
+
* @returns 概率,或 undefined。
|
|
81
|
+
*/
|
|
82
|
+
export function readNoul(answers, key) {
|
|
83
|
+
const raw = answers?.[key];
|
|
84
|
+
if (raw === null || typeof raw !== "object") return undefined;
|
|
85
|
+
if (raw.type !== "noul") return undefined;
|
|
86
|
+
return unit(raw.noul);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* 读一条 score 答案并归一到 [0,1]。
|
|
91
|
+
*
|
|
92
|
+
* 端点把 score 返回成「档位浮点数」(0 …. levels-1),不是概率;例如 5 档时
|
|
93
|
+
* 1.85 表示约 0.46。归一在这里做,下游阈值就都能用 0–1 表达。
|
|
94
|
+
*
|
|
95
|
+
* @param answers - 端点返回的 answers 对象。
|
|
96
|
+
* @param key - 问题 id。
|
|
97
|
+
* @param levels - 该题的档位数。
|
|
98
|
+
* @returns 归一后的分数,或 undefined。
|
|
99
|
+
*/
|
|
100
|
+
export function readScore(answers, key, levels) {
|
|
101
|
+
const raw = answers?.[key];
|
|
102
|
+
if (raw === null || typeof raw !== "object") return undefined;
|
|
103
|
+
if (raw.type !== "score") return undefined;
|
|
104
|
+
const numeric = Number(raw.score);
|
|
105
|
+
if (!Number.isFinite(numeric)) return undefined;
|
|
106
|
+
// 档位必须落在 [0, levels-1];越界即畸形,不夹取(同 unit 的理由)。
|
|
107
|
+
const span = Math.max(1, levels - 1);
|
|
108
|
+
if (numeric < 0 || numeric > span) return undefined;
|
|
109
|
+
const score = clamp01(numeric / span);
|
|
110
|
+
if (score === undefined) return undefined;
|
|
111
|
+
return score;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** 统一的证据串:写进 reason 与遥测,方便事后校准时对照。 */
|
|
115
|
+
function evidence(parts) {
|
|
116
|
+
return parts
|
|
117
|
+
.filter(([, value]) => value !== undefined)
|
|
118
|
+
.map(([name, value]) => `${name}=${value.toFixed(2)}`)
|
|
119
|
+
.join(" ");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* 各决策点的规格。
|
|
124
|
+
*
|
|
125
|
+
* 每个条目实现:
|
|
126
|
+
* build(input) → { state, questions, signatureText? }
|
|
127
|
+
* parse(answers) → 解析结果 或 undefined(形状不合格)
|
|
128
|
+
* decide(parsed, cfg) → { kind, reason, … }
|
|
129
|
+
*/
|
|
130
|
+
export const POINT_SPECS = {
|
|
131
|
+
// ---------------------------------------------------------------- 模型路由
|
|
132
|
+
/**
|
|
133
|
+
* 判断这一步值不值得用强模型。value 在于「机械步骤下沉」:读结果、改格式、
|
|
134
|
+
* 重试这类步骤用便宜模型做等价的事,主通道省 20–40% token。
|
|
135
|
+
*
|
|
136
|
+
* 保守优先:任一「风险偏高」「能力需求超阈值」「不够机械」都保留强模型。
|
|
137
|
+
* 误判成 cheap 的代价是一次坏回答,误判成 strong 的代价只是那一次的钱。
|
|
138
|
+
*/
|
|
139
|
+
route: {
|
|
140
|
+
build(input) {
|
|
141
|
+
const state = {
|
|
142
|
+
turn: input.turn,
|
|
143
|
+
step: input.step,
|
|
144
|
+
current: input.current,
|
|
145
|
+
recent: input.recent ?? "",
|
|
146
|
+
};
|
|
147
|
+
return {
|
|
148
|
+
state,
|
|
149
|
+
signatureText: `${input.current?.provider ?? ""}/${input.current?.model ?? ""}`,
|
|
150
|
+
questions: {
|
|
151
|
+
capability: {
|
|
152
|
+
type: "score",
|
|
153
|
+
instructions:
|
|
154
|
+
"How much reasoning capability does the next reply require? Judge the work, not the topic's importance.",
|
|
155
|
+
criteria: ["trivial", "light", "moderate", "demanding", "hard"],
|
|
156
|
+
},
|
|
157
|
+
mechanical: {
|
|
158
|
+
type: "noul",
|
|
159
|
+
instructions:
|
|
160
|
+
"Is this step mechanical bookkeeping — reformatting, retrying, reading results, or applying an already-decided edit — rather than new reasoning?",
|
|
161
|
+
},
|
|
162
|
+
risky: {
|
|
163
|
+
type: "noul",
|
|
164
|
+
instructions:
|
|
165
|
+
"Would a weaker model plausibly make an error here whose consequences are hard to undo afterwards?",
|
|
166
|
+
},
|
|
167
|
+
},
|
|
168
|
+
};
|
|
169
|
+
},
|
|
170
|
+
parse(answers) {
|
|
171
|
+
const capability = readScore(answers, "capability", 5);
|
|
172
|
+
const mechanical = readNoul(answers, "mechanical");
|
|
173
|
+
const risky = readNoul(answers, "risky");
|
|
174
|
+
if (capability === undefined || mechanical === undefined || risky === undefined) return undefined;
|
|
175
|
+
return { capability, mechanical, risky };
|
|
176
|
+
},
|
|
177
|
+
decide(parsed, cfg) {
|
|
178
|
+
const note = evidence([
|
|
179
|
+
["cap", parsed.capability],
|
|
180
|
+
["mech", parsed.mechanical],
|
|
181
|
+
["risk", parsed.risky],
|
|
182
|
+
]);
|
|
183
|
+
if (parsed.risky >= cfg.routeRiskFloor) {
|
|
184
|
+
return { kind: "keep", reason: `decision-engine-route: 本步风险偏高,保留强模型(${note})` };
|
|
185
|
+
}
|
|
186
|
+
if (parsed.capability > cfg.routeMaxScore) {
|
|
187
|
+
return { kind: "keep", reason: `decision-engine-route: 能力需求超过下沉阈值,保留强模型(${note})` };
|
|
188
|
+
}
|
|
189
|
+
if (parsed.mechanical < cfg.routeMechanicalFloor) {
|
|
190
|
+
return { kind: "keep", reason: `decision-engine-route: 不是机械步骤,保留强模型(${note})` };
|
|
191
|
+
}
|
|
192
|
+
return {
|
|
193
|
+
kind: "switch",
|
|
194
|
+
reason: `decision-engine-route: 机械步骤下沉到 ${cfg.routeCheapProvider}/${cfg.routeCheapModel}(${note})`,
|
|
195
|
+
provider: cfg.routeCheapProvider,
|
|
196
|
+
model: cfg.routeCheapModel,
|
|
197
|
+
};
|
|
198
|
+
},
|
|
199
|
+
},
|
|
200
|
+
|
|
201
|
+
// ------------------------------------------------------------ 子任务分派
|
|
202
|
+
/**
|
|
203
|
+
* 判断该自己接着做、开 subagent、还是组 agent team。
|
|
204
|
+
*
|
|
205
|
+
* 只在「本轮第一步 + 有新的用户输入」时判断:那是唯一可能改变分派决策的时刻,
|
|
206
|
+
* 每步都问等于每步多等 2 秒。生效时注入一条 runtime-context 提示,而不是
|
|
207
|
+
* 替模型去开子代理——工具选择仍然归模型。
|
|
208
|
+
*/
|
|
209
|
+
delegate: {
|
|
210
|
+
build(input) {
|
|
211
|
+
const state = {
|
|
212
|
+
turn: input.turn,
|
|
213
|
+
step: input.step,
|
|
214
|
+
incoming: input.incoming ?? "",
|
|
215
|
+
recent: input.recent ?? "",
|
|
216
|
+
};
|
|
217
|
+
return {
|
|
218
|
+
state,
|
|
219
|
+
signatureText: input.incoming ?? "",
|
|
220
|
+
questions: {
|
|
221
|
+
scope: {
|
|
222
|
+
type: "choice",
|
|
223
|
+
instructions:
|
|
224
|
+
"Should this work continue inline in the current agent, be delegated to one subagent, or require an agent team working in parallel?",
|
|
225
|
+
criteria: {
|
|
226
|
+
inline: "Small, single-threaded work the current agent can finish directly",
|
|
227
|
+
subagent: "Self-contained but sizeable work with its own context, better isolated from this conversation",
|
|
228
|
+
team: "Several genuinely independent parts that must run in parallel and then be merged",
|
|
229
|
+
},
|
|
230
|
+
},
|
|
231
|
+
parallel: {
|
|
232
|
+
type: "noul",
|
|
233
|
+
instructions:
|
|
234
|
+
"Would this work split into parts that could run concurrently without waiting on each other?",
|
|
235
|
+
},
|
|
236
|
+
blocking: {
|
|
237
|
+
type: "noul",
|
|
238
|
+
instructions:
|
|
239
|
+
"Is the next action blocked on a fact the current agent can only learn by doing the work itself right now?",
|
|
240
|
+
},
|
|
241
|
+
},
|
|
242
|
+
};
|
|
243
|
+
},
|
|
244
|
+
parse(answers) {
|
|
245
|
+
const scope = readChoice(answers, "scope");
|
|
246
|
+
const parallel = readNoul(answers, "parallel");
|
|
247
|
+
const blocking = readNoul(answers, "blocking");
|
|
248
|
+
if (scope === undefined || parallel === undefined || blocking === undefined) return undefined;
|
|
249
|
+
if (scope.choice !== "inline" && scope.choice !== "subagent" && scope.choice !== "team") return undefined;
|
|
250
|
+
return { scope: scope.choice, confidence: scope.confidence, parallel, blocking };
|
|
251
|
+
},
|
|
252
|
+
/**
|
|
253
|
+
* @param parsed - 已校验的答案。
|
|
254
|
+
* @param cfg - 当前配置。
|
|
255
|
+
*
|
|
256
|
+
* 实测校准的重要发现:`choice` 的 `confidence` 是**被选中项的概率**,
|
|
257
|
+
* 三个选项平分时天然只有 0.33。实测「明确可并行的大活」给出
|
|
258
|
+
* team=0.99/conf=0.98(可信),而「需要先看代码」给出 inline=0.69/conf=0.53
|
|
259
|
+
* (模糊)。因此置信门槛必须按选项数校准,不能固定 0.6——否则多选项
|
|
260
|
+
* 场景下永远判不出 advise。这里按 `1/选项数` 的相对倍数判定:
|
|
261
|
+
* 概率至少要显著高于均分才认为模型真有倾向。
|
|
262
|
+
*/
|
|
263
|
+
decide(parsed, cfg) {
|
|
264
|
+
const note = evidence([
|
|
265
|
+
["conf", parsed.confidence],
|
|
266
|
+
["par", parsed.parallel],
|
|
267
|
+
["block", parsed.blocking],
|
|
268
|
+
]);
|
|
269
|
+
// 三个选项 → 均分 0.333;乘 1.5 得 0.5 作为「有倾向」的门槛。
|
|
270
|
+
const floor = Math.max(cfg.delegateConfidenceFloor, 0.5);
|
|
271
|
+
if (parsed.confidence < floor) {
|
|
272
|
+
return { kind: "inline", reason: `decision-engine-delegate: 分派置信不足(低于 ${floor}),接着自己做(${note})` };
|
|
273
|
+
}
|
|
274
|
+
if (parsed.blocking >= cfg.delegateBlockingFloor) {
|
|
275
|
+
return { kind: "inline", reason: `decision-engine-delegate: 下一步被本地事实阻塞,接着自己做(${note})` };
|
|
276
|
+
}
|
|
277
|
+
if (parsed.scope === "team") {
|
|
278
|
+
// team 还需要真并行才有意义:串行工作进行组队只是多付上下文。
|
|
279
|
+
if (parsed.parallel >= cfg.delegateParallelFloor) {
|
|
280
|
+
return { kind: "advise", target: "team", reason: `decision-engine-delegate: 建议组 agent team(${note})` };
|
|
281
|
+
}
|
|
282
|
+
return { kind: "inline", reason: `decision-engine-delegate: 建议组队但并行度不足,接着自己做(${note})` };
|
|
283
|
+
}
|
|
284
|
+
if (parsed.scope === "subagent") {
|
|
285
|
+
return { kind: "advise", target: "subagent", reason: `decision-engine-delegate: 建议开 subagent(${note})` };
|
|
286
|
+
}
|
|
287
|
+
return { kind: "inline", reason: `decision-engine-delegate: 继续内联处理(${note})` };
|
|
288
|
+
},
|
|
289
|
+
},
|
|
290
|
+
|
|
291
|
+
// ------------------------------------------------------------ 上下文压缩
|
|
292
|
+
/**
|
|
293
|
+
* 判断现在该摘要压缩,还是只丢掉几条已经没用的工具结果。
|
|
294
|
+
*
|
|
295
|
+
* 后者完全不花摘要的钱:compaction-basic 在每一步都会重新测量压力,
|
|
296
|
+
* 我们把体积压到阈值以下,它就直接跳过摘要(`measure.totalTokens <
|
|
297
|
+
* spec.thresholdTokens → return null`)。这是本决策点唯一真实可执行的动作。
|
|
298
|
+
*/
|
|
299
|
+
compact: {
|
|
300
|
+
build(input) {
|
|
301
|
+
const state = {
|
|
302
|
+
totalTokens: input.totalTokens,
|
|
303
|
+
triggerTokens: input.triggerTokens,
|
|
304
|
+
oversized: input.oversized ?? 0,
|
|
305
|
+
nodes: input.nodes ?? 0,
|
|
306
|
+
recent: input.recent ?? "",
|
|
307
|
+
};
|
|
308
|
+
return {
|
|
309
|
+
state,
|
|
310
|
+
questions: {
|
|
311
|
+
/**
|
|
312
|
+
* 实测:问「策略该 prune 还是 summarize」时,两类输入都答 prune
|
|
313
|
+
* (0.98 / 0.86),完全没有区分力——因为从模型视角看,丢工具结果
|
|
314
|
+
* 的中间段永远是「便宜且看起来安全」的。
|
|
315
|
+
*
|
|
316
|
+
* 换成问「结论是否已经被写进对话」之后,两类给出 0.81 / 0.41,
|
|
317
|
+
* 这是真正能分开的事实判断:模型只需要看 recent 里有没有落地的
|
|
318
|
+
* 结论,不需要判断抽象的「策略」。
|
|
319
|
+
*/
|
|
320
|
+
distilled: {
|
|
321
|
+
type: "noul",
|
|
322
|
+
instructions:
|
|
323
|
+
"Has the essential information from those oversized tool results already been distilled into the recent conversation — a conclusion, decision, or summary the agent has written down?",
|
|
324
|
+
},
|
|
325
|
+
/**
|
|
326
|
+
* 安全否决项:剩余工作仍需要逐字原文时绝不裁掉中间段。
|
|
327
|
+
* 实测两类都低(0.26 / 0.26),因此它只作否决、不作主判据。
|
|
328
|
+
*/
|
|
329
|
+
verbatim_needed: {
|
|
330
|
+
type: "noul",
|
|
331
|
+
instructions:
|
|
332
|
+
"Does the remaining work still require the verbatim middle of those tool outputs — exact line numbers, long identifiers, or values not yet copied into the conversation?",
|
|
333
|
+
},
|
|
334
|
+
},
|
|
335
|
+
};
|
|
336
|
+
},
|
|
337
|
+
parse(answers) {
|
|
338
|
+
const distilled = readNoul(answers, "distilled");
|
|
339
|
+
const verbatim = readNoul(answers, "verbatim_needed");
|
|
340
|
+
if (distilled === undefined || verbatim === undefined) return undefined;
|
|
341
|
+
return { distilled, verbatim };
|
|
342
|
+
},
|
|
343
|
+
decide(parsed, cfg) {
|
|
344
|
+
const note = evidence([
|
|
345
|
+
["dist", parsed.distilled],
|
|
346
|
+
["verb", parsed.verbatim],
|
|
347
|
+
]);
|
|
348
|
+
if (parsed.verbatim >= cfg.compactVerbatinFloor) {
|
|
349
|
+
return { kind: "summarize", reason: `decision-engine-compact: 仍需逐字原文,交给摘要压缩(${note})` };
|
|
350
|
+
}
|
|
351
|
+
if (parsed.distilled >= cfg.compactDistilledFloor) {
|
|
352
|
+
return { kind: "prune", reason: `decision-engine-compact: 结论已落地,只丢陈旧工具结果并跳过摘要(${note})` };
|
|
353
|
+
}
|
|
354
|
+
return { kind: "summarize", reason: `decision-engine-compact: 结论尚未落地,交给摘要压缩(${note})` };
|
|
355
|
+
},
|
|
356
|
+
},
|
|
357
|
+
|
|
358
|
+
// ------------------------------------------------------------ 轮次延续
|
|
359
|
+
/**
|
|
360
|
+
* 判断再来一轮还有没有增量价值。生效时用 `agent.steer()` 注入一条提示把
|
|
361
|
+
* 轮次再撑起来;不生效就什么都不做,让这一轮自然结束。
|
|
362
|
+
*/
|
|
363
|
+
turnStop: {
|
|
364
|
+
build(input) {
|
|
365
|
+
const state = {
|
|
366
|
+
turn: input.turn,
|
|
367
|
+
last: input.last ?? "",
|
|
368
|
+
pendingTools: input.pendingTools ?? 0,
|
|
369
|
+
steered: input.steered ?? 0,
|
|
370
|
+
};
|
|
371
|
+
return {
|
|
372
|
+
state,
|
|
373
|
+
signatureText: input.last ?? "",
|
|
374
|
+
questions: {
|
|
375
|
+
unfinished: {
|
|
376
|
+
type: "noul",
|
|
377
|
+
instructions:
|
|
378
|
+
"Is there clearly identified remaining work in the user's request that this turn has not done yet?",
|
|
379
|
+
},
|
|
380
|
+
value: {
|
|
381
|
+
type: "noul",
|
|
382
|
+
instructions:
|
|
383
|
+
"Would another round produce a materially better or more complete result, rather than restating or padding what is already said?",
|
|
384
|
+
},
|
|
385
|
+
closed: {
|
|
386
|
+
type: "noul",
|
|
387
|
+
instructions: "Has the user's request been fully answered as stated?",
|
|
388
|
+
},
|
|
389
|
+
},
|
|
390
|
+
};
|
|
391
|
+
},
|
|
392
|
+
parse(answers) {
|
|
393
|
+
const unfinished = readNoul(answers, "unfinished");
|
|
394
|
+
const value = readNoul(answers, "value");
|
|
395
|
+
const closed = readNoul(answers, "closed");
|
|
396
|
+
if (unfinished === undefined || value === undefined || closed === undefined) return undefined;
|
|
397
|
+
return { unfinished, value, closed };
|
|
398
|
+
},
|
|
399
|
+
/**
|
|
400
|
+
* @param parsed - 已校验的答案。
|
|
401
|
+
* @param cfg - 当前配置。
|
|
402
|
+
*
|
|
403
|
+
* 实测(同一轮两种输入):
|
|
404
|
+
* 已完成 closed 0.74 / unfin 0.09 / value 0.09
|
|
405
|
+
* 未完成 closed 0.28 / unfin 0.83 / value 0.39
|
|
406
|
+
*
|
|
407
|
+
* 判据顺序很关键:**先看 unfin**。若先看 closed,则「未完成」那组
|
|
408
|
+
* 因为 closed=0.28 不达线,会落到最后的默认 stop —— 而它恰恰是该续轮的。
|
|
409
|
+
* 只有 unfin 低(确实没剩活)时才允许按 closed 收束。
|
|
410
|
+
*/
|
|
411
|
+
decide(parsed, cfg) {
|
|
412
|
+
const note = evidence([
|
|
413
|
+
["unfin", parsed.unfinished],
|
|
414
|
+
["value", parsed.value],
|
|
415
|
+
["closed", parsed.closed],
|
|
416
|
+
]);
|
|
417
|
+
// 排在前面:确实还有活,且续一轮有增量价值 → 续。
|
|
418
|
+
if (parsed.unfinished >= cfg.turnStopUnfinishedFloor && parsed.value >= cfg.turnStopValueFloor) {
|
|
419
|
+
return { kind: "continue", reason: `decision-engine-turnstop: 仍有未完成的活,续一轮(${note})` };
|
|
420
|
+
}
|
|
421
|
+
if (parsed.closed >= cfg.turnStopClosedFloor) {
|
|
422
|
+
return { kind: "stop", reason: `decision-engine-turnstop: 请求已完成,收束轮次(${note})` };
|
|
423
|
+
}
|
|
424
|
+
return { kind: "stop", reason: `decision-engine-turnstop: 再来一轮没有增量价值(${note})` };
|
|
425
|
+
},
|
|
426
|
+
},
|
|
427
|
+
|
|
428
|
+
// ------------------------------------------------------------ 检索必要性
|
|
429
|
+
/**
|
|
430
|
+
* 判断这条信息是否已经知道、或能否直接从上下文推断,避免不必要的搜索与抓取。
|
|
431
|
+
* 只对检索类工具生效(web_search / web_fetch / research / …),其余工具不打扰。
|
|
432
|
+
*/
|
|
433
|
+
retrieval: {
|
|
434
|
+
build(input) {
|
|
435
|
+
const state = {
|
|
436
|
+
tool: input.tool,
|
|
437
|
+
query: input.query ?? "",
|
|
438
|
+
recent: input.recent ?? "",
|
|
439
|
+
};
|
|
440
|
+
return {
|
|
441
|
+
state,
|
|
442
|
+
signatureText: input.query ?? "",
|
|
443
|
+
questions: {
|
|
444
|
+
/**
|
|
445
|
+
* 实测:问「这个信息是否已知道」时,两类输入的答案几乎同值
|
|
446
|
+
* (已知 0.30 / 未知 0.12),完全没有区分力。改成问「recent 里
|
|
447
|
+
* 是否**逐字覆盖**了这个事实,只依据那段文本回答」之后,
|
|
448
|
+
* 两类分别给出 0.92 / 0.01 —— 这是本题唯一有效的问法,
|
|
449
|
+
* 因为模型无法靠常识作答,只能查文本。
|
|
450
|
+
*/
|
|
451
|
+
covered: {
|
|
452
|
+
type: "noul",
|
|
453
|
+
instructions:
|
|
454
|
+
"Is the queried fact explicitly covered by the recent conversation text above? Answer strictly from that text; do not use outside knowledge.",
|
|
455
|
+
},
|
|
456
|
+
/**
|
|
457
|
+
* 与 covered 成对:即使文本里提过,重新搜索仍可能带来新信息。
|
|
458
|
+
* 实测 0.63 / 0.98 也分得开,作为第二道确认。
|
|
459
|
+
*/
|
|
460
|
+
would_add: {
|
|
461
|
+
type: "noul",
|
|
462
|
+
instructions:
|
|
463
|
+
"Would a fresh web search return substantially new information beyond what the recent conversation already states?",
|
|
464
|
+
},
|
|
465
|
+
},
|
|
466
|
+
};
|
|
467
|
+
},
|
|
468
|
+
parse(answers) {
|
|
469
|
+
const covered = readNoul(answers, "covered");
|
|
470
|
+
const wouldAdd = readNoul(answers, "would_add");
|
|
471
|
+
if (covered === undefined || wouldAdd === undefined) return undefined;
|
|
472
|
+
return { covered, wouldAdd };
|
|
473
|
+
},
|
|
474
|
+
decide(parsed, cfg) {
|
|
475
|
+
const note = evidence([
|
|
476
|
+
["cov", parsed.covered],
|
|
477
|
+
["add", parsed.wouldAdd],
|
|
478
|
+
]);
|
|
479
|
+
// 两个条件都必须满足才跳过:文本已覆盖、且再搜也拿不到新东西。
|
|
480
|
+
if (parsed.covered >= cfg.retrievalCoveredFloor && parsed.wouldAdd <= cfg.retrievalWouldAddCeiling) {
|
|
481
|
+
return {
|
|
482
|
+
kind: "skip",
|
|
483
|
+
reason: `decision-engine-retrieval: 上下文已覆盖该事实且再搜无新信息,跳过检索(${note})`,
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
return { kind: "allow", reason: `decision-engine-retrieval: 检索有必要(${note})` };
|
|
487
|
+
},
|
|
488
|
+
},
|
|
489
|
+
|
|
490
|
+
// ------------------------------------------------------------ 技能选择
|
|
491
|
+
/**
|
|
492
|
+
* 判断该装哪个技能。装错再换等于付两遍技能正文的 token,因此把候选清单
|
|
493
|
+
* 交给决策模型挑一个,比让主模型自己试错便宜。
|
|
494
|
+
*/
|
|
495
|
+
skillPick: {
|
|
496
|
+
build(input) {
|
|
497
|
+
const candidates = input.candidates ?? [];
|
|
498
|
+
const criteria = {};
|
|
499
|
+
for (const item of candidates) criteria[item.name] = item.description || item.name;
|
|
500
|
+
criteria.none = "No listed skill applies to the current request";
|
|
501
|
+
const state = {
|
|
502
|
+
requested: input.requested ?? "",
|
|
503
|
+
candidates: candidates.map((item) => item.name),
|
|
504
|
+
recent: input.recent ?? "",
|
|
505
|
+
};
|
|
506
|
+
return {
|
|
507
|
+
state,
|
|
508
|
+
signatureText: input.requested ?? "",
|
|
509
|
+
questions: {
|
|
510
|
+
best: {
|
|
511
|
+
type: "choice",
|
|
512
|
+
instructions:
|
|
513
|
+
"Which listed skill best matches what the current request actually needs? Pick the one whose own description covers the work; never pick a skill because its name sounds related.",
|
|
514
|
+
criteria,
|
|
515
|
+
},
|
|
516
|
+
},
|
|
517
|
+
};
|
|
518
|
+
},
|
|
519
|
+
parse(answers) {
|
|
520
|
+
const best = readChoice(answers, "best");
|
|
521
|
+
if (best === undefined) return undefined;
|
|
522
|
+
return { best: best.choice, confidence: best.confidence };
|
|
523
|
+
},
|
|
524
|
+
decide(parsed, cfg) {
|
|
525
|
+
const note = evidence([["conf", parsed.confidence]]);
|
|
526
|
+
if (parsed.best === "none") {
|
|
527
|
+
return { kind: "allow", reason: `decision-engine-skillpick: 没有更匹配的技能,放行(${note})` };
|
|
528
|
+
}
|
|
529
|
+
if (parsed.confidence < cfg.skillPickConfidenceFloor) {
|
|
530
|
+
return { kind: "allow", reason: `decision-engine-skillpick: 置信不足,放行(${note})` };
|
|
531
|
+
}
|
|
532
|
+
return {
|
|
533
|
+
kind: "redirect",
|
|
534
|
+
suggest: parsed.best,
|
|
535
|
+
reason: `decision-engine-skillpick: 更匹配的是「${parsed.best}」(${note})`,
|
|
536
|
+
};
|
|
537
|
+
},
|
|
538
|
+
},
|
|
539
|
+
|
|
540
|
+
// ------------------------------------------------------- 思维链翻译门槛
|
|
541
|
+
/**
|
|
542
|
+
* 判断这条内容是否真的需要翻译。翻译链路按条计费,纯代码块、标识符、
|
|
543
|
+
* 已经是目标语言的内容都可以直接跳过。
|
|
544
|
+
*/
|
|
545
|
+
cotTranslate: {
|
|
546
|
+
build(input) {
|
|
547
|
+
const state = {
|
|
548
|
+
target: input.targetLanguage ?? "auto",
|
|
549
|
+
source: input.sourceLanguage ?? "auto",
|
|
550
|
+
chars: input.text?.length ?? 0,
|
|
551
|
+
head: input.head ?? "",
|
|
552
|
+
};
|
|
553
|
+
return {
|
|
554
|
+
state,
|
|
555
|
+
signatureText: input.head ?? "",
|
|
556
|
+
questions: {
|
|
557
|
+
prose: {
|
|
558
|
+
type: "noul",
|
|
559
|
+
instructions:
|
|
560
|
+
"Does this text contain natural-language prose that a reader would need translated, rather than code, identifiers, paths, or punctuation?",
|
|
561
|
+
},
|
|
562
|
+
same_language: {
|
|
563
|
+
type: "noul",
|
|
564
|
+
instructions: "Is this text already written in the requested target language?",
|
|
565
|
+
},
|
|
566
|
+
},
|
|
567
|
+
};
|
|
568
|
+
},
|
|
569
|
+
parse(answers) {
|
|
570
|
+
const prose = readNoul(answers, "prose");
|
|
571
|
+
const sameLanguage = readNoul(answers, "same_language");
|
|
572
|
+
if (prose === undefined || sameLanguage === undefined) return undefined;
|
|
573
|
+
return { prose, sameLanguage };
|
|
574
|
+
},
|
|
575
|
+
decide(parsed, cfg) {
|
|
576
|
+
const note = evidence([
|
|
577
|
+
["prose", parsed.prose],
|
|
578
|
+
["same", parsed.sameLanguage],
|
|
579
|
+
]);
|
|
580
|
+
if (parsed.sameLanguage >= cfg.cotSameLanguageFloor) {
|
|
581
|
+
return { kind: "skip", reason: `decision-engine-cot: 已是目标语言,跳过翻译(${note})` };
|
|
582
|
+
}
|
|
583
|
+
if (parsed.prose < cfg.cotProseFloor) {
|
|
584
|
+
return { kind: "skip", reason: `decision-engine-cot: 没有需要翻译的正文,跳过(${note})` };
|
|
585
|
+
}
|
|
586
|
+
return { kind: "translate", reason: `decision-engine-cot: 需要翻译(${note})` };
|
|
587
|
+
},
|
|
588
|
+
},
|
|
589
|
+
};
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* 按 id 取规格。
|
|
593
|
+
* @param id - 决策点 id。
|
|
594
|
+
* @returns 规格对象,或 undefined。
|
|
595
|
+
*/
|
|
596
|
+
export function findSpec(id) {
|
|
597
|
+
return POINT_SPECS[id];
|
|
598
|
+
}
|