dsh-agentic-router 1.4.1 → 1.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/README.md +6 -0
- package/docs/DESIGN.md +37 -0
- package/dsh/index.js +104 -16
- package/dsh/strategies.js +13 -9
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -14,6 +14,12 @@ DeepSeek Harness(DSH)插件:**学习型智能路由(agentic router)+
|
|
|
14
14
|
向会话日志追加 `request/header` 事件(reason: `router`),走 DSH 官方模型选择优先级链
|
|
15
15
|
(本进程选择 → 会话日志最新 header → 默认模型)真实切换。
|
|
16
16
|
|
|
17
|
+
**v1.5.0 步骤级路由**:在回合级基线之上,`agent/pre-step` 按**上一步的工具调用**做
|
|
18
|
+
步骤级调整——重型工具(写代码/执行/子 agent 等,见 `HEAVY_TOOLS`)→ 下一步升级 strong
|
|
19
|
+
(pro);轻量/纯文本 → 回到回合基线档。实现"简单步骤 flash、复杂步骤 pro"的近似形态
|
|
20
|
+
(步骤切换对下一步生效,一阶延迟)。步骤级决策落盘 `steps.jsonl`,`agentic_router_stats`
|
|
21
|
+
可见 `stepSwitches` 与 `stepLog`。
|
|
22
|
+
|
|
17
23
|
## 架构
|
|
18
24
|
|
|
19
25
|
```
|
package/docs/DESIGN.md
CHANGED
|
@@ -226,3 +226,40 @@ dsh-agentic-router/
|
|
|
226
226
|
- 离线回放:拿历史 session 日志重放,对比 shadow 路由与实际路由的评分分布
|
|
227
227
|
- 影子期 A/B:shadow 评分 vs 默认路由评分,达标才切 active
|
|
228
228
|
- 集成冒烟:mock ctx 注入 agent/request payload,验证 LlmCallConfig 替换与 next() 透传
|
|
229
|
+
|
|
230
|
+
---
|
|
231
|
+
|
|
232
|
+
## 13. v1.5.0 步骤级路由设计(已实现)
|
|
233
|
+
|
|
234
|
+
### 13.1 动机
|
|
235
|
+
|
|
236
|
+
回合级路由(v1.4.x)每回合决策一次、整回合同一模型。用户期望"单任务内简单步骤用 flash、复杂步骤用 pro"。步骤级路由在回合级基线之上做**按步骤升降档**。
|
|
237
|
+
|
|
238
|
+
### 13.2 时序依据(为什么对下一步生效)
|
|
239
|
+
|
|
240
|
+
loop 每步顺序:`inbox.claim()`(发 `agent/inbox/claimed`)→ `systemPrompt.assemble()`(快照模型)→ `agent/pre-step` waterfall → `buildRequest`。因此:
|
|
241
|
+
|
|
242
|
+
- **inbox 决策**在组装之前 → 回合第一步即生效(零延迟,v1.4.x 主路径)
|
|
243
|
+
- **pre-step 决策**在组装之后 → 追加 header 对**下一步**生效(一阶延迟,设计如此)
|
|
244
|
+
|
|
245
|
+
### 13.3 信号:上一步的工具调用
|
|
246
|
+
|
|
247
|
+
`session.deriveMessages()` 取最后一条 assistant 消息的 `tool-call` 块(只读叶子字段):
|
|
248
|
+
|
|
249
|
+
| 上一步工具 | 下一步档位 |
|
|
250
|
+
|---|---|
|
|
251
|
+
| 重型:bash/pwsh/write/edit/batch_edit/run_code/workflow/subagent/subagent_fork/terminal/pty/mcp | **strong(pro)** |
|
|
252
|
+
| 轻量:read/glob/grep 等 | 回合基线档 |
|
|
253
|
+
| 无工具(纯文本,通常回合收尾) | 保持现状 |
|
|
254
|
+
|
|
255
|
+
### 13.4 决策与落盘
|
|
256
|
+
|
|
257
|
+
- 步骤档位 → `resolveTier` → 目标模型 ≠ 当前模型 → 追加 `request/header`(reason: `router-step`)
|
|
258
|
+
- 每步一条 `steps.jsonl`:`{turn, step, lastTools, stepTier, target, current, applied}`
|
|
259
|
+
- stats 暴露 `stepSwitches` 与 `stepLog`(最近 20 条)
|
|
260
|
+
|
|
261
|
+
### 13.5 边界与后续
|
|
262
|
+
|
|
263
|
+
- 奖励飞轮仍按回合结算(回合基线的策略选择),步骤级是确定性规则;未来可把步骤级升降档纳入学习(如按工具调用频率学阈值)
|
|
264
|
+
- 重型集合 `HEAVY_TOOLS` 是静态清单,可配置化
|
|
265
|
+
- 降级方向:重型步骤后的轻量步骤会回到基线(简单任务场景:写代码阶段 pro、前后问答 flash)
|
package/dsh/index.js
CHANGED
|
@@ -54,6 +54,15 @@ function fallbackTiers() {
|
|
|
54
54
|
return { providers, tiers }
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
// 当前 provider 有候选模型的档位列表(UCB/LinUCB/k-NN 只在这些档位间探索)。
|
|
58
|
+
// provider 缺失或档位表不可用时回退到全部档位(保持专家函数原有行为)。
|
|
59
|
+
function availableTiersFor(provider, modelTiers) {
|
|
60
|
+
const all = ['fast', 'mid', 'strong']
|
|
61
|
+
if (!modelTiers || !provider) return all
|
|
62
|
+
const has = all.filter((t) => tierTarget(t, provider, modelTiers))
|
|
63
|
+
return has.length ? has : all
|
|
64
|
+
}
|
|
65
|
+
|
|
57
66
|
function freshState() {
|
|
58
67
|
return {
|
|
59
68
|
mode: 'shadow',
|
|
@@ -68,6 +77,8 @@ function freshState() {
|
|
|
68
77
|
recByTurn: new Map(),
|
|
69
78
|
headerAppends: 0,
|
|
70
79
|
lastActual: null,
|
|
80
|
+
stepLog: [],
|
|
81
|
+
counters: { stepSwitches: 0 },
|
|
71
82
|
ucb: { total: 0, arms: {} },
|
|
72
83
|
lin: {
|
|
73
84
|
total: 0,
|
|
@@ -179,10 +190,10 @@ function currentProvider(session) {
|
|
|
179
190
|
return c && typeof c.provider === 'string' ? c.provider : ''
|
|
180
191
|
}
|
|
181
192
|
|
|
182
|
-
// 核心切换:向会话日志追加 request/header(reason: 'router'),
|
|
193
|
+
// 核心切换:向会话日志追加 request/header(reason: 'router' / 'router-step'),
|
|
183
194
|
// 下一轮 prompt 组装即读到新模型。fast/mid 档去掉 reasoningEffort,
|
|
184
195
|
// 让目标模型走自身默认 effort(避免 flash 不支持 max 而整步失败)。
|
|
185
|
-
function appendRouterHeader(session, targetModel) {
|
|
196
|
+
function appendRouterHeader(session, targetModel, reason = 'router') {
|
|
186
197
|
try {
|
|
187
198
|
const last = session.requestHeader()
|
|
188
199
|
const header = {}
|
|
@@ -195,13 +206,40 @@ function appendRouterHeader(session, targetModel) {
|
|
|
195
206
|
config.model = targetModel
|
|
196
207
|
if (tierOf(targetModel) !== 'strong') delete config.reasoningEffort
|
|
197
208
|
header.config = config
|
|
198
|
-
session.append('request/header', { header, reason
|
|
209
|
+
session.append('request/header', { header, reason })
|
|
199
210
|
return true
|
|
200
211
|
} catch (e) {
|
|
201
212
|
return false
|
|
202
213
|
}
|
|
203
214
|
}
|
|
204
215
|
|
|
216
|
+
// 步骤级路由(v1.5.0):视为"重型工作"的工具调用——上一步出现它们时,
|
|
217
|
+
// 下一步升级到 strong(pro)处理其结果/继续执行;轻量工具保持回合基线。
|
|
218
|
+
const HEAVY_TOOLS = new Set([
|
|
219
|
+
'bash', 'pwsh', 'write', 'edit', 'batch_edit', 'run_code', 'workflow',
|
|
220
|
+
'subagent', 'subagent_fork', 'terminal', 'pty', 'mcp',
|
|
221
|
+
])
|
|
222
|
+
|
|
223
|
+
// 读取会话里最后一条 assistant 消息的工具调用名(只取叶子字段,不碰活数据)。
|
|
224
|
+
function lastToolCalls(session) {
|
|
225
|
+
try {
|
|
226
|
+
const msgs = typeof session.deriveMessages === 'function' ? session.deriveMessages() : null
|
|
227
|
+
if (!Array.isArray(msgs)) return []
|
|
228
|
+
for (let i = msgs.length - 1; i >= 0; i--) {
|
|
229
|
+
const m = msgs[i]
|
|
230
|
+
if (!m || typeof m !== 'object' || m.role !== 'assistant') continue
|
|
231
|
+
const blocks = m.content
|
|
232
|
+
if (!Array.isArray(blocks)) return []
|
|
233
|
+
const out = []
|
|
234
|
+
for (const b of blocks) {
|
|
235
|
+
if (b && typeof b === 'object' && b.type === 'tool-call' && typeof b.name === 'string') out.push(b.name)
|
|
236
|
+
}
|
|
237
|
+
return out
|
|
238
|
+
}
|
|
239
|
+
} catch (e) { /* read-only observation */ }
|
|
240
|
+
return []
|
|
241
|
+
}
|
|
242
|
+
|
|
205
243
|
async function refreshModelTiers(llm) {
|
|
206
244
|
if (!llm || typeof llm.listProviders !== 'function') return null
|
|
207
245
|
const providers = await llm.listProviders()
|
|
@@ -310,11 +348,14 @@ export function apply(ctx, config = {}) {
|
|
|
310
348
|
if (state.turnDecision[key]) return null
|
|
311
349
|
const cls = classify(text)
|
|
312
350
|
const f = featuresOf(cls, text)
|
|
351
|
+
// 只探索当前 provider 有候选模型的档位(跳过无候选的"幽灵档",
|
|
352
|
+
// 如 mid 无模型时探索 mid 只会白费并回退到最贵的 strong)
|
|
353
|
+
const available = availableTiersFor(provider, state.modelTiers)
|
|
313
354
|
const recs = {
|
|
314
355
|
rule: recommendRule(cls),
|
|
315
|
-
ucb: recommendUcb(cls, state.ucb),
|
|
316
|
-
lin: recommendLin(cls, f, state.lin),
|
|
317
|
-
knn: recommendKnn(cls, f, state.knn),
|
|
356
|
+
ucb: recommendUcb(cls, state.ucb, available),
|
|
357
|
+
lin: recommendLin(cls, f, state.lin, available),
|
|
358
|
+
knn: recommendKnn(cls, f, state.knn, available),
|
|
318
359
|
}
|
|
319
360
|
if (state.forced) recs.rule = { tier: 'forced', reason: 'forced', conf: 1 }
|
|
320
361
|
const exp3 = {
|
|
@@ -335,6 +376,8 @@ export function apply(ctx, config = {}) {
|
|
|
335
376
|
currentModel: current, provider, costWeight, costCap,
|
|
336
377
|
estInputTokens: Math.ceil(text.length / 1.5),
|
|
337
378
|
target, applied: false, needsApply: false, actualModel: current,
|
|
379
|
+
// 回合级基线(供步骤级路由 v1.5.0 参考:无重型工具时保持该档)
|
|
380
|
+
turnTier: target ? tierOf(target) : null, turnTarget: target,
|
|
338
381
|
}
|
|
339
382
|
const record = {
|
|
340
383
|
seq: state.seq,
|
|
@@ -417,6 +460,38 @@ export function apply(ctx, config = {}) {
|
|
|
417
460
|
} catch (e) {
|
|
418
461
|
log('pre-step capture failed: ' + String(e))
|
|
419
462
|
}
|
|
463
|
+
// ---- 步骤级路由(v1.5.0)----
|
|
464
|
+
// pre-step 在组装之后触发 → 此处追加 header 对下一步生效(一阶延迟,设计如此)。
|
|
465
|
+
// 信号:上一步 assistant 消息的工具调用。重型工具(写代码/执行/子agent)→ 下一步
|
|
466
|
+
// 升级 strong(pro);轻量/无工具 → 回到回合基线档。
|
|
467
|
+
try {
|
|
468
|
+
const session = sessionOf(payload)
|
|
469
|
+
if (!session) return next()
|
|
470
|
+
const key = fullKey(payload)
|
|
471
|
+
const td = state.turnDecision[key]
|
|
472
|
+
if (!td || td.finalized) return next()
|
|
473
|
+
const tools = lastToolCalls(session)
|
|
474
|
+
if (!tools.length) return next() // 上一步是纯文本(通常回合收尾),保持现状
|
|
475
|
+
const heavy = tools.some((n) => HEAVY_TOOLS.has(n))
|
|
476
|
+
const stepTier = heavy ? 'strong' : (td.turnTier || null)
|
|
477
|
+
if (!stepTier) return next()
|
|
478
|
+
const provider = td.provider || currentProvider(session) || ''
|
|
479
|
+
const resolved = resolveTier(stepTier, provider, state.modelTiers)
|
|
480
|
+
const target = resolved.target
|
|
481
|
+
const cur = currentModel(session)
|
|
482
|
+
const changed = target && target !== cur
|
|
483
|
+
// 注意:pre-step 在组装之后、buildRequest(loop 追加自己的 header)之前触发。
|
|
484
|
+
// 这里直接追加会被本步 buildRequest 的 change(旧模型) 覆盖(与 v1.4.0 冷启动同款竞态)。
|
|
485
|
+
// 因此只决策并挂起,真正追加推迟到 llm/stream 收尾(此时 loop 的 header 已落盘)。
|
|
486
|
+
if (changed) td.stepTarget = target
|
|
487
|
+
const stepRec = { turn: key, step: payload.step, lastTools: tools.slice(0, 5), stepTier, target, current: cur, applied: false, pending: changed, ts: Date.now() }
|
|
488
|
+
if (changed) td.stepRec = stepRec
|
|
489
|
+
state.stepLog.push(stepRec)
|
|
490
|
+
if (state.stepLog.length > 30) state.stepLog.shift()
|
|
491
|
+
try { store.appendJsonl('steps.jsonl', stepRec) } catch (e) { /* 落盘尽力而为 */ }
|
|
492
|
+
} catch (e) {
|
|
493
|
+
log('step routing failed: ' + String(e))
|
|
494
|
+
}
|
|
420
495
|
return next()
|
|
421
496
|
})
|
|
422
497
|
|
|
@@ -487,14 +562,25 @@ export function apply(ctx, config = {}) {
|
|
|
487
562
|
const key = sid ? state.openTurns.get(sid) : null
|
|
488
563
|
const td = key ? state.turnDecision[key] : null
|
|
489
564
|
if (td && !td.finalized) {
|
|
490
|
-
//
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
565
|
+
// 延迟应用(此时循环自身 header 已落盘,追加无竞态):
|
|
566
|
+
// 1) 步骤级路由挂起的升降档(router-step)
|
|
567
|
+
// 2) request 路径兜底决策(needsApply)
|
|
568
|
+
let session = null
|
|
569
|
+
try {
|
|
570
|
+
const sessions = ctx.get('sessions')
|
|
571
|
+
if (sessions && typeof sessions.get === 'function') session = sessions.get(sid) || null
|
|
572
|
+
} catch (e) { session = null }
|
|
573
|
+
if (session) {
|
|
574
|
+
if (td.stepTarget && td.stepTarget !== currentModel(session)) {
|
|
575
|
+
const ok = appendRouterHeader(session, td.stepTarget, 'router-step')
|
|
576
|
+
if (ok) {
|
|
577
|
+
state.counters.stepSwitches++
|
|
578
|
+
if (td.stepRec) { td.stepRec.applied = true }
|
|
579
|
+
log('STEP-SWAP -> ' + td.stepTarget + (td.stepRec ? ' (tools: ' + td.stepRec.lastTools.join(',') + ')' : ''))
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
td.stepTarget = null
|
|
583
|
+
if (td.needsApply && !td.applied) applyDecision(session, key, td, td.rec, td.target)
|
|
498
584
|
}
|
|
499
585
|
const u = td.usage || { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, reasoningTokens: 0, estimated: false }
|
|
500
586
|
const model = (options && typeof options.model === 'string' && options.model) || td.currentModel || ''
|
|
@@ -620,9 +706,11 @@ export function apply(ctx, config = {}) {
|
|
|
620
706
|
totalDecisions: state.seq,
|
|
621
707
|
modelTiers: state.modelTiers,
|
|
622
708
|
prices,
|
|
623
|
-
mechanism: 'request/header append (
|
|
709
|
+
mechanism: 'request/header append (inbox turn-level + pre-step step-level v1.5.0)',
|
|
624
710
|
lastActual: state.lastActual,
|
|
625
711
|
headerAppends: state.headerAppends,
|
|
712
|
+
stepSwitches: state.counters.stepSwitches,
|
|
713
|
+
stepLog: state.stepLog.slice(-20),
|
|
626
714
|
flywheel: {
|
|
627
715
|
ucb: state.ucb,
|
|
628
716
|
lin: { total: state.lin.total, tierN: state.lin.tierN, tierW: state.lin.tierW },
|
|
@@ -633,7 +721,7 @@ export function apply(ctx, config = {}) {
|
|
|
633
721
|
recentRewards: store.loadJsonl('rewards.jsonl').slice(-20),
|
|
634
722
|
},
|
|
635
723
|
recent: state.decisions.slice(-20),
|
|
636
|
-
note: '切换真实性验证:headerAppends 为本进程追加的 request/header 次数;
|
|
724
|
+
note: '切换真实性验证:headerAppends 为本进程追加的 request/header 次数;stepSwitches 为步骤级升级/降级次数(stepLog 有明细);lastActual 为最近一步真实出网模型',
|
|
637
725
|
}
|
|
638
726
|
},
|
|
639
727
|
})
|
package/dsh/strategies.js
CHANGED
|
@@ -40,11 +40,13 @@ export function recommendRule(cls) {
|
|
|
40
40
|
return { tier: want, reason: 'rule threshold complexity=' + cls.complexity, conf: 0.5 }
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
// UCB:mean + c·√(lnN/n);未探索臂上界∞,多个∞平局时按总拉动数轮转(避免永远选 fast
|
|
44
|
-
|
|
43
|
+
// UCB:mean + c·√(lnN/n);未探索臂上界∞,多个∞平局时按总拉动数轮转(避免永远选 fast)。
|
|
44
|
+
// availableTiers 由调用方传入(当前 provider 有候选模型的档位)——不探索无候选的"幽灵档"
|
|
45
|
+
// (如 mid 无模型时,探索 mid 只会白费并回退到最贵的 strong)。
|
|
46
|
+
export function recommendUcb(cls, ucbState, availableTiers = ['fast', 'mid', 'strong']) {
|
|
45
47
|
const arms = ucbState.arms
|
|
46
48
|
const scores = []
|
|
47
|
-
for (const tier of
|
|
49
|
+
for (const tier of availableTiers) {
|
|
48
50
|
const arm = arms[cls.type + ':' + tier] || { n: 0, mean: 0 }
|
|
49
51
|
const ucb = arm.n > 0 ? arm.mean + 1.2 * Math.sqrt(Math.log(ucbState.total + 1) / arm.n) : Infinity
|
|
50
52
|
scores.push({ tier, ucb, arm })
|
|
@@ -61,11 +63,12 @@ export function recommendUcb(cls, ucbState) {
|
|
|
61
63
|
}
|
|
62
64
|
}
|
|
63
65
|
|
|
64
|
-
// LinUCB(近似实现):每档线性权重 w·f +
|
|
65
|
-
|
|
66
|
+
// LinUCB(近似实现):每档线性权重 w·f + 探索项;梯度更新,探索项随档样本数衰减。
|
|
67
|
+
// availableTiers 同上:只评估有候选模型的档位。
|
|
68
|
+
export function recommendLin(cls, f, linState, availableTiers = ['fast', 'mid', 'strong']) {
|
|
66
69
|
const tn = linState.tierN
|
|
67
70
|
let best = null
|
|
68
|
-
for (const tier of
|
|
71
|
+
for (const tier of availableTiers) {
|
|
69
72
|
const w = linState.tierW[tier] || []
|
|
70
73
|
let dot = 0
|
|
71
74
|
for (let i = 0; i < f.length; i++) dot += (w[i] || 0) * f[i]
|
|
@@ -80,8 +83,9 @@ export function recommendLin(cls, f, linState) {
|
|
|
80
83
|
}
|
|
81
84
|
}
|
|
82
85
|
|
|
83
|
-
// k-NN:欧氏距离取 k=5
|
|
84
|
-
|
|
86
|
+
// k-NN:欧氏距离取 k=5 邻居,按邻居平均奖励选档。
|
|
87
|
+
// availableTiers 同上:只从有候选模型的档位里选最佳。
|
|
88
|
+
export function recommendKnn(cls, f, knnState, availableTiers = ['fast', 'mid', 'strong']) {
|
|
85
89
|
const pool = knnState.pool
|
|
86
90
|
if (pool.length < 5) return { tier: null, reason: 'knn cold (' + pool.length + '/5 seed)', conf: 0 }
|
|
87
91
|
const scored = []
|
|
@@ -98,7 +102,7 @@ export function recommendKnn(cls, f, knnState) {
|
|
|
98
102
|
if (agg[t]) { agg[t].n += 1; agg[t].s += scored[i].e.r }
|
|
99
103
|
}
|
|
100
104
|
let best = null
|
|
101
|
-
for (const tier of
|
|
105
|
+
for (const tier of availableTiers) {
|
|
102
106
|
const a = agg[tier]
|
|
103
107
|
if (!best || (a.n > 0 && a.s / a.n > best.score)) best = { tier, score: a.n > 0 ? a.s / a.n : -1 }
|
|
104
108
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-agentic-router",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "DeepSeek Harness plugin: learning agentic router — rule + UCB + LinUCB + k-NN experts under an EXP3 meta-selector, with a durable quality-reward flywheel
|
|
3
|
+
"version": "1.5.0",
|
|
4
|
+
"description": "DeepSeek Harness plugin: learning agentic router — rule + UCB + LinUCB + k-NN experts under an EXP3 meta-selector, with a durable quality-reward flywheel, REAL model switching via the session request-header precedence chain, and STEP-LEVEL routing (v1.5.0)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"author": "lhl",
|