dsh-decision-engine 0.0.0-stage → 0.4.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 +489 -2
- 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 +1199 -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 +2007 -0
- package/lib/points/spec.js +598 -0
- package/lib/providers.js +491 -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
- package/startlux.example.yml +218 -0
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 子任务分派、轮次延续、上下文压缩 —— 三个都挂在 `agent/pre-step` 与
|
|
3
|
+
* `agent/turn-stopping` 上的适配器。
|
|
4
|
+
*
|
|
5
|
+
* 为什么合在一个文件:三者的触发条件都依赖「这一步/这一轮到哪了」,
|
|
6
|
+
* 分成三个监听器会让同一份 inbox/step 状态被读三遍,也让「这一步到底问了
|
|
7
|
+
* 几个决策点」变得难以推断。这里用一个 pre-step 监听器内部分派。
|
|
8
|
+
*
|
|
9
|
+
* 各点的生效方式都刻意保守:
|
|
10
|
+
*
|
|
11
|
+
* 分派 只注入一条 runtime-context 建议,绝不替模型开子代理
|
|
12
|
+
* (开子代理是模型的决定,插件越权会让上下文分叉不可控)
|
|
13
|
+
* 轮次 用 `agent.steer()` 续一轮,且每轮最多一次
|
|
14
|
+
* 压缩 只在自己判定够便宜时先跑确定性裁剪;摘要交给 compaction-basic
|
|
15
|
+
*
|
|
16
|
+
* @module dsh-decision-engine/adapters/agent
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* 挂载分派 / 轮次 / 压缩三个监听器。
|
|
21
|
+
* @param ctx - cordis 插件上下文。
|
|
22
|
+
* @param deps - 运行期依赖。
|
|
23
|
+
*/
|
|
24
|
+
export function mountAgentPoints(ctx, deps) {
|
|
25
|
+
const { runner, getConfig, digestRecent, telemetry, makeUserMessage } = deps;
|
|
26
|
+
|
|
27
|
+
/** 每会话的续轮计数:turn → 已 steer 次数。防止自续无限循环。 */
|
|
28
|
+
const steerCounts = new WeakMap();
|
|
29
|
+
|
|
30
|
+
const steersThisTurn = (session, turn) => {
|
|
31
|
+
const map = steerCounts.get(session);
|
|
32
|
+
return map?.get(turn) ?? 0;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const noteSteer = (session, turn) => {
|
|
36
|
+
let map = steerCounts.get(session);
|
|
37
|
+
if (map === undefined) {
|
|
38
|
+
map = new Map();
|
|
39
|
+
steerCounts.set(session, map);
|
|
40
|
+
}
|
|
41
|
+
map.set(turn, (map.get(turn) ?? 0) + 1);
|
|
42
|
+
// 只保留最近两轮,避免长会话里这个 Map 无界增长。
|
|
43
|
+
if (map.size > 2) {
|
|
44
|
+
const oldest = map.keys().next();
|
|
45
|
+
if (!oldest.done) map.delete(oldest.value);
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/** 取最近一步的助手文本,作为「这一轮说了什么」的判断依据。 */
|
|
50
|
+
const lastAssistantText = (session, chars) => {
|
|
51
|
+
let events;
|
|
52
|
+
try {
|
|
53
|
+
events = session.events ?? [];
|
|
54
|
+
} catch {
|
|
55
|
+
return "";
|
|
56
|
+
}
|
|
57
|
+
for (let index = events.length - 1; index >= 0; index -= 1) {
|
|
58
|
+
const event = events[index];
|
|
59
|
+
if (event?.type !== "assistant/message") continue;
|
|
60
|
+
const content = event.data?.message?.content;
|
|
61
|
+
if (!Array.isArray(content)) continue;
|
|
62
|
+
const text = content
|
|
63
|
+
.map((block) => (block?.type === "text" && typeof block.text === "string" ? block.text : ""))
|
|
64
|
+
.filter((part) => part.length > 0)
|
|
65
|
+
.join(" ");
|
|
66
|
+
if (text.length > 0) return text.slice(-chars);
|
|
67
|
+
}
|
|
68
|
+
return "";
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
// ---------------------------------------------------------------- pre-step
|
|
72
|
+
const preStep = async (payload, next) => {
|
|
73
|
+
const decision = await next();
|
|
74
|
+
// 判 reject 说明这一步根本没进入,注入建议没有意义。
|
|
75
|
+
if (decision.kind === "reject") return decision;
|
|
76
|
+
const agent = payload?.agent;
|
|
77
|
+
if (agent?.session === undefined) return decision;
|
|
78
|
+
const cfg = getConfig();
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
const additions = [];
|
|
82
|
+
|
|
83
|
+
// ---- 上下文压缩 ------------------------------------------------
|
|
84
|
+
if (runner.participates("compact")) {
|
|
85
|
+
const compacted = await maybePruneContext(ctx, agent, payload, cfg, runner, telemetry);
|
|
86
|
+
if (compacted !== undefined) additions.push(compacted);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ---- 子任务分派 ------------------------------------------------
|
|
90
|
+
// 只在「每轮第一步 + 这一步确实带着新的用户输入」时判断:
|
|
91
|
+
// 分派决策只在这个时刻可能改变,后续步骤再问必然是 inline。
|
|
92
|
+
if (runner.participates("delegate") && payload.step === 1 && payload.messages.length > 0) {
|
|
93
|
+
const incoming = payload.messages
|
|
94
|
+
.map((message) => textOf(message))
|
|
95
|
+
.filter((text) => text.length > 0)
|
|
96
|
+
.join("\n")
|
|
97
|
+
.slice(0, 1200);
|
|
98
|
+
if (incoming.length > 0) {
|
|
99
|
+
const result = await runner.ask(
|
|
100
|
+
"delegate",
|
|
101
|
+
{
|
|
102
|
+
turn: payload.turn,
|
|
103
|
+
step: payload.step,
|
|
104
|
+
incoming,
|
|
105
|
+
recent: digestRecent(agent.session, cfg.recentMessages, cfg.recentMessageChars),
|
|
106
|
+
},
|
|
107
|
+
{ signal: payload.signal },
|
|
108
|
+
);
|
|
109
|
+
// 只有明确建议「开子代理/组队」时才注入提示;inline 不打扰。
|
|
110
|
+
if (runner.enforces("delegate") && result.action?.kind === "advise") {
|
|
111
|
+
additions.push(
|
|
112
|
+
makeUserMessage(
|
|
113
|
+
`[decision-engine-delegate] ${result.action.reason}\n` +
|
|
114
|
+
`如果确实适合,请用 subagent / 团队工具把它分出去;否则直接在当前上下文里做完,不要为了分派而分派。`,
|
|
115
|
+
"delegate",
|
|
116
|
+
),
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (additions.length === 0) return decision;
|
|
123
|
+
return { ...decision, messages: [...decision.messages, ...additions] };
|
|
124
|
+
} catch (error) {
|
|
125
|
+
telemetry.noteAdapterError("pre-step", error, ctx);
|
|
126
|
+
return decision;
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
ctx.on("agent/pre-step", preStep);
|
|
131
|
+
|
|
132
|
+
// ----------------------------------------------------------- turn-stopping
|
|
133
|
+
/**
|
|
134
|
+
* 轮次延续:`agent/turn-stopping` 是 serial 而不是 waterfall,返回值不传递,
|
|
135
|
+
* 唯一的生效方式是调用 `agent.steer(...)` 把消息塞回 inbox —— 机器随后
|
|
136
|
+
* 重新读一遍 inbox,有东西就再跑一步。
|
|
137
|
+
*
|
|
138
|
+
* 契约明确写了「数据决定,监听器顺序不改变结果」,因此这里唯一能做的就是
|
|
139
|
+
* 「steer 或不 steer」,不能否决别的监听器的意见。
|
|
140
|
+
*/
|
|
141
|
+
const turnStopping = async (payload) => {
|
|
142
|
+
const agent = payload?.agent;
|
|
143
|
+
if (agent?.session === undefined) return;
|
|
144
|
+
if (!runner.participates("turnStop")) return;
|
|
145
|
+
|
|
146
|
+
try {
|
|
147
|
+
const cfg = getConfig();
|
|
148
|
+
// 每轮最多续一次:否则一轮里的空转会被无限放大成烧钱循环。
|
|
149
|
+
if (steersThisTurn(agent.session, payload.turn) >= Math.max(0, cfg.turnStopMaxSteers ?? 1)) return;
|
|
150
|
+
|
|
151
|
+
const last = lastAssistantText(agent.session, cfg.recentMessageChars * 4);
|
|
152
|
+
const result = await runner.ask(
|
|
153
|
+
"turnStop",
|
|
154
|
+
{
|
|
155
|
+
turn: payload.turn,
|
|
156
|
+
last,
|
|
157
|
+
pendingTools: 0,
|
|
158
|
+
steered: steersThisTurn(agent.session, payload.turn),
|
|
159
|
+
},
|
|
160
|
+
{ signal: payload.signal },
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
if (!runner.enforces("turnStop")) return;
|
|
164
|
+
if (result.action?.kind !== "continue") return;
|
|
165
|
+
if (typeof agent.steer !== "function") return;
|
|
166
|
+
|
|
167
|
+
noteSteer(agent.session, payload.turn);
|
|
168
|
+
agent.steer(
|
|
169
|
+
makeUserMessage(
|
|
170
|
+
`[decision-engine-turnstop] ${result.action.reason}\n继续把没做完的部分做完;如果其实已经完成,直接说明结论即可。`,
|
|
171
|
+
"turnStop",
|
|
172
|
+
),
|
|
173
|
+
);
|
|
174
|
+
ctx.logger?.info?.(`dsh-decision-engine: 第 ${payload.turn} 轮按决策续一轮`);
|
|
175
|
+
} catch (error) {
|
|
176
|
+
telemetry.noteAdapterError("turnStop", error, ctx);
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
ctx.on("agent/turn-stopping", turnStopping);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** 把一条消息压成纯文本(只取 text block)。 */
|
|
184
|
+
function textOf(message) {
|
|
185
|
+
const content = message?.content;
|
|
186
|
+
if (typeof content === "string") return content;
|
|
187
|
+
if (!Array.isArray(content)) return "";
|
|
188
|
+
return content
|
|
189
|
+
.map((block) => (block?.type === "text" && typeof block.text === "string" ? block.text : ""))
|
|
190
|
+
.filter((part) => part.length > 0)
|
|
191
|
+
.join(" ");
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* 上下文压缩:判断「现在只丢陈旧工具结果够不够」。
|
|
196
|
+
*
|
|
197
|
+
* 生效方式是调用宿主的 `toolResultPruner.pruneSession(session)` —— 这是一个
|
|
198
|
+
* 纯确定性、无模型、replay 安全的裁剪,把超长工具结果的中间段换成标记。
|
|
199
|
+
* 裁完之后 compaction-basic 会在同一步重新测量压力:低于阈值它就**跳过摘要**,
|
|
200
|
+
* 于是真正省下一次摘要调用。
|
|
201
|
+
*
|
|
202
|
+
* 绝不在 observe 模式下裁剪(那会改变会话内容),也绝不在没超阈值时裁剪。
|
|
203
|
+
*
|
|
204
|
+
* @returns 需要追加进这一步的提示消息,或 undefined(不注入)。
|
|
205
|
+
*/
|
|
206
|
+
async function maybePruneContext(ctx, agent, payload, cfg, runner, telemetry) {
|
|
207
|
+
const meter = ctx.get?.("tokenMeter");
|
|
208
|
+
const pruner = ctx.get?.("toolResultPruner");
|
|
209
|
+
if (meter === undefined || pruner === undefined) return undefined;
|
|
210
|
+
|
|
211
|
+
let measurement;
|
|
212
|
+
try {
|
|
213
|
+
measurement = meter.measure(agent.session);
|
|
214
|
+
} catch {
|
|
215
|
+
return undefined;
|
|
216
|
+
}
|
|
217
|
+
const total = Number(measurement?.totalTokens ?? 0);
|
|
218
|
+
if (total <= 0) return undefined;
|
|
219
|
+
|
|
220
|
+
// 触发线:compaction-basic 默认 thresholdRatio 0.8。它只在达到 0.8×contextWindow
|
|
221
|
+
// 时才动手,所以我们要在它之前判断,取 0.7 作为「提前介入」的位置。
|
|
222
|
+
// 拿不到 contextWindow 时退回绝对阈值,避免完全不动。
|
|
223
|
+
const contextWindow = Number(cfg.compactContextWindow ?? 0);
|
|
224
|
+
const threshold = contextWindow > 0 ? contextWindow * (cfg.compactThresholdRatio ?? 0.7) : (cfg.compactTriggerTokens ?? 100000);
|
|
225
|
+
if (total < threshold) return undefined;
|
|
226
|
+
|
|
227
|
+
// 先数一下有多少超长工具结果,没有就不必问模型。
|
|
228
|
+
const oversized = countOversizedToolResults(agent.session, cfg.compactToolResultChars ?? 8192);
|
|
229
|
+
if (oversized === 0) return undefined;
|
|
230
|
+
|
|
231
|
+
const result = await runner.ask(
|
|
232
|
+
"compact",
|
|
233
|
+
{
|
|
234
|
+
totalTokens: total,
|
|
235
|
+
triggerTokens: Math.round(threshold),
|
|
236
|
+
oversized,
|
|
237
|
+
nodes: Array.isArray(measurement?.nodes) ? measurement.nodes.length : 0,
|
|
238
|
+
recent: digestRecentSafe(agent.session, cfg),
|
|
239
|
+
},
|
|
240
|
+
{ signal: payload.signal },
|
|
241
|
+
);
|
|
242
|
+
|
|
243
|
+
if (!runner.enforces("compact")) return undefined;
|
|
244
|
+
if (result.action?.kind !== "prune") return undefined;
|
|
245
|
+
|
|
246
|
+
let pruned;
|
|
247
|
+
try {
|
|
248
|
+
pruned = pruner.pruneSession(agent.session);
|
|
249
|
+
} catch (error) {
|
|
250
|
+
telemetry.noteAdapterError("compact", error, ctx);
|
|
251
|
+
return undefined;
|
|
252
|
+
}
|
|
253
|
+
if (pruned === undefined || pruned.pruned.length === 0) return undefined;
|
|
254
|
+
|
|
255
|
+
ctx.logger?.info?.(
|
|
256
|
+
`dsh-decision-engine: 裁剪 ${pruned.pruned.length} 条超长工具结果,省 ${pruned.charsRemoved} 字符(${result.action.reason})`,
|
|
257
|
+
);
|
|
258
|
+
return undefined;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** 数当前 surface 上超长的 tool/result 数量。 */
|
|
262
|
+
function countOversizedToolResults(session, limit) {
|
|
263
|
+
let count = 0;
|
|
264
|
+
let nodes;
|
|
265
|
+
try {
|
|
266
|
+
nodes = session.surface?.nodes;
|
|
267
|
+
} catch {
|
|
268
|
+
return 0;
|
|
269
|
+
}
|
|
270
|
+
if (nodes === undefined) return 0;
|
|
271
|
+
for (const seq of nodes) {
|
|
272
|
+
let event;
|
|
273
|
+
try {
|
|
274
|
+
event = session.eventAt(seq);
|
|
275
|
+
} catch {
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
if (event?.type !== "tool/result") continue;
|
|
279
|
+
const content = event.data?.message?.content;
|
|
280
|
+
if (!Array.isArray(content)) continue;
|
|
281
|
+
let chars = 0;
|
|
282
|
+
for (const block of content) {
|
|
283
|
+
if (block?.type === "text" && typeof block.text === "string") chars += block.text.length;
|
|
284
|
+
}
|
|
285
|
+
if (chars > limit) count += 1;
|
|
286
|
+
}
|
|
287
|
+
return count;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** 压缩判断里只需要很短的上下文;单独一个 helper 避免把大字符串传进 state。 */
|
|
291
|
+
function digestRecentSafe(session, cfg) {
|
|
292
|
+
try {
|
|
293
|
+
const count = Math.min(cfg.recentMessages ?? 0, 4);
|
|
294
|
+
if (count <= 0) return "";
|
|
295
|
+
const events = session.events ?? [];
|
|
296
|
+
const lines = [];
|
|
297
|
+
for (let index = events.length - 1; index >= 0 && lines.length < count; index -= 1) {
|
|
298
|
+
const event = events[index];
|
|
299
|
+
if (event?.type !== "user/message" && event?.type !== "assistant/message") continue;
|
|
300
|
+
const text = textOf(event.data?.message ?? event.data);
|
|
301
|
+
if (text.length > 0) lines.unshift(text.slice(0, 160));
|
|
302
|
+
}
|
|
303
|
+
return lines.join("\n");
|
|
304
|
+
} catch {
|
|
305
|
+
return "";
|
|
306
|
+
}
|
|
307
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 模型路由:`agent/request` 适配器。
|
|
3
|
+
*
|
|
4
|
+
* 这个点是**热路径**——每步一次模型请求就触发一次。若每次都问决策模型,
|
|
5
|
+
* 等于每步多等约 2 秒,比省下的 token 更贵。因此这里用三道闸把它压下来:
|
|
6
|
+
*
|
|
7
|
+
* 闸1 步数闸 每轮只在前 N 步判断(默认 3)。后续步骤沿用本轮的裁决——
|
|
8
|
+
* 一个任务的「要不要强模型」在开头就定了,逐步重问是浪费。
|
|
9
|
+
* 闸2 路由闸 提议的 config 与上一次实际使用的不同才问。同一 provider/model
|
|
10
|
+
* 连续多步时,裁决已经生效,不必再问。
|
|
11
|
+
* 闸3 机械闸 只有本地判定「看起来机械」的步骤才问:上一步有工具结果、
|
|
12
|
+
* 且最近没有用户新输入。新推理多半需要强模型,问了也是 keep。
|
|
13
|
+
*
|
|
14
|
+
* 生效方式:返回替换后的 LlmCallConfig。observe 只记录「本该换成什么」。
|
|
15
|
+
*
|
|
16
|
+
* 安全约束(照 `agent/request` 的契约):这个 waterfall 不能改 messages,
|
|
17
|
+
* 只能改 config。因此这里只动 provider/model 两个字段,其余字段原样透传,
|
|
18
|
+
* 否则会把 maxTokens、reasoningEffort 等宿主已解析的默认值弄丢。
|
|
19
|
+
*
|
|
20
|
+
* @module dsh-decision-engine/adapters/route
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* 挂载模型路由监听器。
|
|
25
|
+
* @param ctx - cordis 插件上下文。
|
|
26
|
+
* @param deps - 运行期依赖。
|
|
27
|
+
*/
|
|
28
|
+
export function mountRoutePoint(ctx, deps) {
|
|
29
|
+
const { runner, getConfig, digestRecent, telemetry } = deps;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* 每轮的路由状态:turn → { askedSteps, lastProvider, lastModel }。
|
|
33
|
+
* 用 WeakMap 挂在 session 上,会话销毁即回收,不担心泄漏。
|
|
34
|
+
*/
|
|
35
|
+
const perSession = new WeakMap();
|
|
36
|
+
|
|
37
|
+
const stateFor = (session) => {
|
|
38
|
+
let state = perSession.get(session);
|
|
39
|
+
if (state === undefined) {
|
|
40
|
+
state = { turn: -1, askedSteps: new Set(), lastProvider: undefined, lastModel: undefined };
|
|
41
|
+
perSession.set(session, state);
|
|
42
|
+
}
|
|
43
|
+
// 换轮即重置:新的一轮是一个新任务,前一轮的「机械」判断不适用。
|
|
44
|
+
return state;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/** 最近一条 user 消息是否比最近一条工具结果更新(= 有新推理进入)。 */
|
|
48
|
+
const hasFreshUserInput = (session) => {
|
|
49
|
+
let events;
|
|
50
|
+
try {
|
|
51
|
+
events = session.events ?? [];
|
|
52
|
+
} catch {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
for (let index = events.length - 1; index >= 0; index -= 1) {
|
|
56
|
+
const event = events[index];
|
|
57
|
+
if (event?.type === "tool/result") return false;
|
|
58
|
+
if (event?.type === "user/message") return true;
|
|
59
|
+
}
|
|
60
|
+
return false;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const listener = async (payload, next) => {
|
|
64
|
+
const proposed = await next();
|
|
65
|
+
const agent = payload?.agent;
|
|
66
|
+
if (agent?.session === undefined) return proposed;
|
|
67
|
+
if (!runner.participates("route")) return proposed;
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
const cfg = getConfig();
|
|
71
|
+
// 下沉目标没配就完全不判断:留空等于「不路由」,问一次模型再放弃是纯浪费。
|
|
72
|
+
if (
|
|
73
|
+
typeof cfg.routeCheapProvider !== "string" ||
|
|
74
|
+
cfg.routeCheapProvider.length === 0 ||
|
|
75
|
+
typeof cfg.routeCheapModel !== "string" ||
|
|
76
|
+
cfg.routeCheapModel.length === 0
|
|
77
|
+
) {
|
|
78
|
+
return proposed;
|
|
79
|
+
}
|
|
80
|
+
const state = stateFor(agent.session);
|
|
81
|
+
if (state.turn !== payload.turn) {
|
|
82
|
+
state.turn = payload.turn;
|
|
83
|
+
state.askedSteps.clear();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// 闸1:本轮已问够步数就不再问,沿用当前(可能已被替换的)路由。
|
|
87
|
+
if (state.askedSteps.size >= Math.max(1, cfg.routeMaxStepsPerTurn ?? 3)) return proposed;
|
|
88
|
+
|
|
89
|
+
// 闸2:路由没变过就不必重问——裁决已经在上一问里生效。
|
|
90
|
+
if (state.lastProvider === proposed.provider && state.lastModel === proposed.model) {
|
|
91
|
+
// 但同一轮里第一次见到这个路由时,还是要问一次。
|
|
92
|
+
if (state.askedSteps.size > 0) return proposed;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// 闸3:新用户输入带来的步骤通常是新推理,问了也多半 keep。
|
|
96
|
+
if (hasFreshUserInput(agent.session) && state.askedSteps.size > 0) return proposed;
|
|
97
|
+
|
|
98
|
+
// 已经在用便宜模型了,再往下沉没有意义。
|
|
99
|
+
if (
|
|
100
|
+
proposed.provider === cfg.routeCheapProvider &&
|
|
101
|
+
proposed.model === cfg.routeCheapModel
|
|
102
|
+
) {
|
|
103
|
+
return proposed;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
state.askedSteps.add(payload.step);
|
|
107
|
+
|
|
108
|
+
const result = await runner.ask(
|
|
109
|
+
"route",
|
|
110
|
+
{
|
|
111
|
+
turn: payload.turn,
|
|
112
|
+
step: payload.step,
|
|
113
|
+
current: { provider: proposed.provider, model: proposed.model },
|
|
114
|
+
recent: digestRecent(agent.session, cfg.recentMessages, cfg.recentMessageChars),
|
|
115
|
+
},
|
|
116
|
+
{ signal: payload.signal },
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
if (result.action?.kind !== "switch") return proposed;
|
|
120
|
+
// 校验目标路由确实配好了:留空等于「不路由」,不能真去请求空 provider。
|
|
121
|
+
const provider = result.action.provider;
|
|
122
|
+
const model = result.action.model;
|
|
123
|
+
if (typeof provider !== "string" || provider.length === 0) return proposed;
|
|
124
|
+
if (typeof model !== "string" || model.length === 0) return proposed;
|
|
125
|
+
if (provider === proposed.provider && model === proposed.model) return proposed;
|
|
126
|
+
|
|
127
|
+
// observe 到这里就结束:记录已写,但不改路由。
|
|
128
|
+
if (!runner.enforces("route")) return proposed;
|
|
129
|
+
|
|
130
|
+
state.lastProvider = provider;
|
|
131
|
+
state.lastModel = model;
|
|
132
|
+
ctx.logger?.info?.(
|
|
133
|
+
`dsh-decision-engine: 第 ${payload.turn}/${payload.step} 步路由 ${proposed.provider}/${proposed.model} → ${provider}/${model}`,
|
|
134
|
+
);
|
|
135
|
+
// 只覆盖 provider/model,其余字段(maxTokens / reasoningEffort / temperature)
|
|
136
|
+
// 原样保留——它们是宿主已解析好的,弄丢会改变请求语义。
|
|
137
|
+
return { ...proposed, provider, model };
|
|
138
|
+
} catch (error) {
|
|
139
|
+
telemetry.noteAdapterError("route", error, ctx);
|
|
140
|
+
return proposed;
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
ctx.on("agent/request", listener);
|
|
145
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 检索必要性 + 技能选择的 tools/pre-execute 适配器。
|
|
3
|
+
*
|
|
4
|
+
* 这两个点都挂在 `tools/pre-execute` 上,但只对「特定工具」生效:
|
|
5
|
+
*
|
|
6
|
+
* 检索必要性 只有 web_search / web_fetch / research 等检索工具才判断。
|
|
7
|
+
* 其余工具一行 `includes` 就返回,零请求零延迟。
|
|
8
|
+
* 技能选择 只有 `skill` 工具、且候选清单非空时才判断。
|
|
9
|
+
*
|
|
10
|
+
* 「只对特定工具生效」不是优化,而是必要条件:这两个点在注册表里都是
|
|
11
|
+
* 非热路径,但 `tools/pre-execute` 本身是每次工具调用都会走的,
|
|
12
|
+
* 若不按工具名过滤,就等于给每个 bash/read/edit 都加 2 秒。
|
|
13
|
+
*
|
|
14
|
+
* 两者的生效方式不同:
|
|
15
|
+
* 检索 deny 直接拦下(这是唯一能真省下搜索 token 的方式)
|
|
16
|
+
* 技能 deny 会给出更匹配的名字(拦掉错的、指对对的)
|
|
17
|
+
*
|
|
18
|
+
* @module dsh-decision-engine/adapters/tools
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** 检索类工具名(默认值;可在配置里用 retrievalTools 覆盖)。 */
|
|
22
|
+
export const DEFAULT_RETRIEVAL_TOOLS = ["web_search", "web_fetch", "research", "multi_search", "advanced_search"];
|
|
23
|
+
|
|
24
|
+
/** 技能加载工具名。 */
|
|
25
|
+
export const SKILL_TOOL = "skill";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 判断一次工具调用是不是检索。
|
|
29
|
+
* @param name - 工具名。
|
|
30
|
+
* @param tools - 配置里的检索工具名集合。
|
|
31
|
+
* @returns 是否按检索处理。
|
|
32
|
+
*/
|
|
33
|
+
export function isRetrievalTool(name, tools) {
|
|
34
|
+
return typeof name === "string" && tools.has(name);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* 从工具参数里摘出「这次要检索什么」。
|
|
39
|
+
*
|
|
40
|
+
* 不同检索工具的参数名不一样(web_search 用 queries,web_fetch 用 url,
|
|
41
|
+
* research 用 question),逐个认;认不出来就退回整段参数预览。
|
|
42
|
+
* 这个字符串同时是软缓存的签名——同一个问题问第二遍应该命中缓存。
|
|
43
|
+
*
|
|
44
|
+
* @param args - 工具参数。
|
|
45
|
+
* @returns 用于 state 与缓存的查询文本。
|
|
46
|
+
*/
|
|
47
|
+
export function retrievalQuery(args) {
|
|
48
|
+
if (args === null || typeof args !== "object") return "";
|
|
49
|
+
const candidates = [args.query, args.question, args.url, args.prompt];
|
|
50
|
+
if (Array.isArray(args.queries)) candidates.push(args.queries.join(" | "));
|
|
51
|
+
for (const candidate of candidates) {
|
|
52
|
+
if (typeof candidate === "string" && candidate.trim().length > 0) return candidate.trim();
|
|
53
|
+
}
|
|
54
|
+
try {
|
|
55
|
+
return JSON.stringify(args).slice(0, 400);
|
|
56
|
+
} catch {
|
|
57
|
+
return "";
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* 挂载检索必要性 + 技能选择两个监听器。
|
|
63
|
+
*
|
|
64
|
+
* 两者合成一个 `tools/pre-execute` 监听器而不是注册两个:一个监听器里先按
|
|
65
|
+
* 工具名分派,省掉一次 dispatch 往返,也让「这次调用归哪个点管」在日志里
|
|
66
|
+
* 只有一个明确答案(不会出现两个点同时记录同一次调用)。
|
|
67
|
+
*
|
|
68
|
+
* @param ctx - cordis 插件上下文。
|
|
69
|
+
* @param deps - 运行期依赖。
|
|
70
|
+
*/
|
|
71
|
+
export function mountToolPoints(ctx, deps) {
|
|
72
|
+
const { runner, getConfig, digestRecent, telemetry } = deps;
|
|
73
|
+
|
|
74
|
+
const listener = async (exec, next) => {
|
|
75
|
+
if (exec?.agent === undefined) return next();
|
|
76
|
+
const name = exec.name;
|
|
77
|
+
const cfg = getConfig();
|
|
78
|
+
|
|
79
|
+
// ---- 技能选择 ------------------------------------------------------
|
|
80
|
+
if (name === SKILL_TOOL && runner.participates("skillPick")) {
|
|
81
|
+
try {
|
|
82
|
+
const requested = typeof exec.arguments?.name === "string" ? exec.arguments.name : "";
|
|
83
|
+
const candidates = await loadSkillCandidates(ctx, exec, cfg);
|
|
84
|
+
// 候选太少时挑选没有意义,直接放行——别为一次「只有一个选项」付费。
|
|
85
|
+
if (candidates.length >= 2) {
|
|
86
|
+
const result = await runner.ask(
|
|
87
|
+
"skillPick",
|
|
88
|
+
{
|
|
89
|
+
requested,
|
|
90
|
+
candidates,
|
|
91
|
+
recent: digestRecent(exec.agent.session, cfg.recentMessages, cfg.recentMessageChars),
|
|
92
|
+
},
|
|
93
|
+
{ tool: name, signal: exec.signal, softCacheable: (value) => value !== undefined },
|
|
94
|
+
);
|
|
95
|
+
// observe 只记录;enforce 且判出更匹配的技能时才改写这次调用。
|
|
96
|
+
if (runner.enforces("skillPick") && result.action?.kind === "redirect") {
|
|
97
|
+
const suggest = result.action.suggest;
|
|
98
|
+
if (typeof suggest === "string" && suggest.length > 0 && suggest !== requested) {
|
|
99
|
+
// 不改写参数(那会替模型做决定),而是拦下并告知更匹配的那个:
|
|
100
|
+
// 模型看到原因后可以自己重发,避免「装错再换」付两遍正文 token。
|
|
101
|
+
return {
|
|
102
|
+
kind: "deny",
|
|
103
|
+
reason: `${result.action.reason}。若要装该技能,请改用以 skill(name: "${suggest}") 重试。`,
|
|
104
|
+
info: { name: "StartluxSkillPickError", code: "STARTLUX_SKILLPICK_REDIRECT", suggest },
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
} catch (error) {
|
|
110
|
+
telemetry.noteAdapterError("skillPick", error, ctx);
|
|
111
|
+
}
|
|
112
|
+
return next();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ---- 检索必要性 ----------------------------------------------------
|
|
116
|
+
if (runner.participates("retrieval") && isRetrievalTool(name, deps.retrievalTools())) {
|
|
117
|
+
try {
|
|
118
|
+
const query = retrievalQuery(exec.arguments);
|
|
119
|
+
const result = await runner.ask(
|
|
120
|
+
"retrieval",
|
|
121
|
+
{
|
|
122
|
+
tool: name,
|
|
123
|
+
query,
|
|
124
|
+
recent: digestRecent(exec.agent.session, cfg.recentMessages, cfg.recentMessageChars),
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
tool: name,
|
|
128
|
+
signal: exec.signal,
|
|
129
|
+
// 只有「检索有必要」的裁决可跨调用复用;skip 永不复用,
|
|
130
|
+
// 免得一次「已知」把后续同类问题全部拦掉。
|
|
131
|
+
softCacheable: (value) => value !== undefined,
|
|
132
|
+
},
|
|
133
|
+
);
|
|
134
|
+
if (runner.enforces("retrieval") && result.action?.kind === "skip") {
|
|
135
|
+
return {
|
|
136
|
+
kind: "deny",
|
|
137
|
+
reason: result.action.reason,
|
|
138
|
+
info: { name: "StartluxRetrievalError", code: "STARTLUX_RETRIEVAL_SKIP" },
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
} catch (error) {
|
|
142
|
+
telemetry.noteAdapterError("retrieval", error, ctx);
|
|
143
|
+
}
|
|
144
|
+
return next();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return next();
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
ctx.on("tools/pre-execute", listener);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* 取当前可见的技能候选清单(名称 + 描述)。
|
|
155
|
+
*
|
|
156
|
+
* 只带名字和一行描述,绝不带正文:决策模型每次约 1.9k token,把技能正文
|
|
157
|
+
* 塞进 state 会让这个「省钱的点」变成主要花销。正文只在模型真正加载时付费。
|
|
158
|
+
*
|
|
159
|
+
* @param ctx - 插件上下文(用于 `ctx.get('skills')`)。
|
|
160
|
+
* @param exec - 待执行的工具调用(提供 agent scope 与 cwd)。
|
|
161
|
+
* @param cfg - 当前配置。
|
|
162
|
+
* @returns 候选数组;取不到时返回空数组。
|
|
163
|
+
*/
|
|
164
|
+
async function loadSkillCandidates(ctx, exec, cfg) {
|
|
165
|
+
const skills = ctx.get?.("skills");
|
|
166
|
+
if (skills === undefined || typeof skills.list !== "function") return [];
|
|
167
|
+
let summaries;
|
|
168
|
+
try {
|
|
169
|
+
summaries = await skills.list({
|
|
170
|
+
cwd: exec.agent?.session?.header?.cwd,
|
|
171
|
+
signal: exec.signal,
|
|
172
|
+
scope: exec.agent,
|
|
173
|
+
});
|
|
174
|
+
} catch {
|
|
175
|
+
return [];
|
|
176
|
+
}
|
|
177
|
+
// 决策模型每次只有很小的一段 state;候选超过上限就截断,宁可少挑不可超预算。
|
|
178
|
+
const limit = Math.max(2, cfg.skillPickCandidates ?? 8);
|
|
179
|
+
return summaries.slice(0, limit).map((summary) => ({
|
|
180
|
+
name: String(summary.name ?? ""),
|
|
181
|
+
description: String(summary.description ?? "").slice(0, 160),
|
|
182
|
+
}));
|
|
183
|
+
}
|