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 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: 'router' })
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
- // 兜底应用:request 路径决策未应用时,在 header 落盘后补切换
491
- if (td.needsApply && !td.applied) {
492
- let session = null
493
- try {
494
- const sessions = ctx.get('sessions')
495
- if (sessions && typeof sessions.get === 'function') session = sessions.get(sid) || null
496
- } catch (e) { session = null }
497
- if (session) applyDecision(session, key, td, td.rec, td.target)
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 (agent/inbox/claimed)',
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 次数;lastActual 为最近一步真实出网模型;decisions[].applied=true 且 swapped=true 即真实切换成功',
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
- export function recommendUcb(cls, ucbState) {
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 ['fast', 'mid', 'strong']) {
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
- export function recommendLin(cls, f, linState) {
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 ['fast', 'mid', 'strong']) {
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
- export function recommendKnn(cls, f, knnState) {
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 ['fast', 'mid', 'strong']) {
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.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 and REAL model switching via the session request-header precedence chain (v1.4.1, cold-start fix)",
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",