dsh-agentic-router 1.3.0 → 1.4.1

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.en.md CHANGED
@@ -4,14 +4,16 @@
4
4
 
5
5
  DeepSeek Harness (DSH) plugin: a **learning agentic router with a data flywheel**.
6
6
 
7
- It watches every model request, routes it to the most appropriate model tier by task type and complexity, asks four experts in parallel and lets an EXP3 meta-selector decide who to trust. Every turn is scored with **quality-proxy signals** (tool failures, model retries, latency) and the reward feeds back into the strategies — the router gets better as it runs. Every decision and reward is persisted locally: auditable and replayable.
7
+ At the start of every turn (`agent/inbox/claimed`) it routes the request to the most appropriate model tier by task type and complexity, asks four experts in parallel and lets an EXP3 meta-selector decide who to trust. Every turn is scored with **quality-proxy signals** (tool failures, model retries, cost, latency) and the reward feeds back into the strategies — the router gets better as it runs. Every decision and reward is persisted locally: auditable and replayable.
8
+
9
+ **Real switching since v1.4.0**: v1.3.0 and earlier swapped models by returning a replaced config from the `agent/request` waterfall, but DSH's built-in model-selection listener overrides that value, so the swap never took effect. v1.4.0 instead appends a `request/header` event (reason: `router`) to the session log at turn start, going through DSH's official model-selection precedence chain (in-process selection → latest logged session `request/header` → default model).
8
10
 
9
11
  ## Architecture
10
12
 
11
13
  ```
12
- user input → classify (rules, <1ms) → 4 expert recommendations → EXP3 meta-pick → agent/request swap
13
- ↑ │
14
- UCB/LinUCB/k-NN/EXP3 updates ← quality-proxy reward ← turn end (tool errors/retries/latency)
14
+ user input → classify (rules, <1ms) → 4 expert recommendations → EXP3 meta-pick → request/header append (real switch)
15
+ ↑ │
16
+ UCB/LinUCB/k-NN/EXP3 updates ← quality-proxy reward ← turn end (tool errors/retries/cost/latency)
15
17
  ```
16
18
 
17
19
  | Expert | Algorithm | Status |
@@ -30,7 +32,26 @@ Design rationale and roadmap: [docs/DESIGN.md](./docs/DESIGN.md). The implementa
30
32
  dsh plugin --profile web add dsh-agentic-router
31
33
  ```
32
34
 
33
- > `--profile` is mandatory. Restart the session (or profile) after installing so the tool schemas join prompt assembly. Default mode is **shadow** (records and learns, never swaps); switch to active once the policy behaves.
35
+ > `--profile` is mandatory. Restart the web profile after installing so the tool schemas join prompt assembly.
36
+
37
+ ### Switch to real routing (active)
38
+
39
+ Default mode is **shadow** (records and learns, never swaps). To enable real switching, the easiest way is: after restarting, tell the agent in any DSH session:
40
+
41
+ > Switch the agentic router to active mode
42
+
43
+ The agent calls `agentic_router_set_mode` (mode=active) and persists it to
44
+ `~/.dsh/storages/dsh-agentic-router/policy.json` — it stays active across restarts.
45
+
46
+ Alternatively, start active on first load by adding config in the profile's `cordis.patch.yml`:
47
+
48
+ ```yaml
49
+ - id: agentic-router
50
+ config:
51
+ mode: active
52
+ ```
53
+
54
+ (Pick one; once a mode has been saved via the tool, `policy.json` takes precedence over `config.mode`.)
34
55
 
35
56
  ## Tools
36
57
 
@@ -78,19 +99,7 @@ the `agentic_router_set_prices` tool (persisted to policy.json) or at install vi
78
99
  - Feedback only works where the deployment's Web UI actually writes it (this plugin only reads)
79
100
  - LinUCB / k-NN need dozens of turns before they graduate into the meta pool
80
101
 
81
- ## Development
82
-
83
- ```sh
84
- npm test # node --test, zero dependencies
85
- ```
86
-
87
- ## Publish
88
-
89
- ```sh
90
- npm publish # requires npm account
91
- git push origin main # after making the GitHub repo public
92
- # directory listing: add the dsh-plugin topic, or submit at dshmarketplace.dev/submit
93
- ```
102
+ **Development, testing and release flow (maintainers): see [docs/MAINTAINING.md](./docs/MAINTAINING.md).**
94
103
 
95
104
  ## License
96
105
 
package/README.md CHANGED
@@ -4,14 +4,22 @@
4
4
 
5
5
  DeepSeek Harness(DSH)插件:**学习型智能路由(agentic router)+ 数据飞轮**。
6
6
 
7
- 监听每次模型请求,按任务类型与复杂度把请求路由到最合适的模型档位;四个专家并行推荐、EXP3 元选择器决定听谁的;每回合按**质量代理信号**(工具失败、模型重试、延迟)计算奖励并回传,路由策略随使用越学越准。全部决策与奖励落盘,可审计、可重放。
7
+ 每回合开始时(`agent/inbox/claimed`)按任务类型与复杂度把请求路由到最合适的模型档位;
8
+ 四个专家并行推荐、EXP3 元选择器决定听谁的;每回合按**质量代理信号**
9
+ (工具失败、模型重试、成本、延迟)计算奖励并回传,路由策略随使用越学越准。
10
+ 全部决策与奖励落盘,可审计、可重放。
11
+
12
+ **v1.4.0 起切换真实生效**:v1.3.0 及之前通过 `agent/request` 返回替换后的配置切模型,
13
+ 但该返回值会被 DSH 内置的模型选择监听器强制改回,实际不生效;v1.4.0 改为在回合开始
14
+ 向会话日志追加 `request/header` 事件(reason: `router`),走 DSH 官方模型选择优先级链
15
+ (本进程选择 → 会话日志最新 header → 默认模型)真实切换。
8
16
 
9
17
  ## 架构
10
18
 
11
19
  ```
12
- 用户输入 → 分类(规则,<1ms) → 四专家推荐 → EXP3 元选择 → agent/request 切换模型
20
+ 用户输入 → 分类(规则,<1ms) → 四专家推荐 → EXP3 元选择 → request/header 追加(真实切换)
13
21
  ↑ │
14
- UCB/LinUCB/k-NN/EXP3 更新 ← 质量代理奖励 ← 回合收尾(工具失败/重试/延迟)
22
+ UCB/LinUCB/k-NN/EXP3 更新 ← 质量代理奖励 ← 回合收尾(工具失败/重试/成本/延迟)
15
23
  ```
16
24
 
17
25
  | 专家 | 算法 | 状态 |
@@ -22,7 +30,7 @@ DeepSeek Harness(DSH)插件:**学习型智能路由(agentic router)+
22
30
  | k-NN | 经验池最近邻(k=5)聚合 | 影子,30 经验毕业 |
23
31
  | EXP3 | 元选择器,重要性加权更新 | 常驻,冷启动偏 rule(权重 2.5) |
24
32
 
25
- 设计细节与路线图见 [docs/DESIGN.md](./docs/DESIGN.md)。本实现源自真实运行时上的动态原型验证(8 个实测缺陷已修复并固化为回归测试)。
33
+ 设计细节与路线图见 [docs/DESIGN.md](./docs/DESIGN.md)。
26
34
 
27
35
  ## 安装
28
36
 
@@ -30,7 +38,43 @@ DeepSeek Harness(DSH)插件:**学习型智能路由(agentic router)+
30
38
  dsh plugin --profile web add dsh-agentic-router
31
39
  ```
32
40
 
33
- > `--profile` 必填。安装后重启会话(或 profile),工具 schema 才会进入 prompt 组装。默认 **shadow 模式**(只记录与学习,不切换模型),确认策略表现后再切 active。
41
+ > `--profile` 必填。安装后重启 Web,工具 schema 才会进入 prompt 组装。
42
+
43
+ ### 切换到真实切换(active)
44
+
45
+ 默认是 **shadow 模式**(只记录与学习,不切换模型)。切到真实切换,最省事的方式:重启后在任意 DSH 会话里对 agent 说:
46
+
47
+ > 把 agentic router 切成 active 模式
48
+
49
+ agent 会调用 `agentic_router_set_mode`(mode=active),并把模式持久化到
50
+ `~/.dsh/storages/dsh-agentic-router/policy.json`——**重启后依然 active**,无需每次设置。
51
+
52
+ 也可以不走会话、首次启动即 active:在 profile 的 `cordis.patch.yml` 里给
53
+ `agentic-router` 条目加配置:
54
+
55
+ ```yaml
56
+ - id: agentic-router
57
+ config:
58
+ mode: active
59
+ ```
60
+
61
+ (两种方式选其一;一旦用工具存过模式,`policy.json` 的取值优先于 `config.mode`。)
62
+
63
+ ## 切换机制(v1.4.0)
64
+
65
+ DSH 的 agent 每步请求最终由模型选择逻辑决定,读取优先级为:
66
+
67
+ ```
68
+ 本进程选择 → 会话日志最新 request/header → 默认模型
69
+ ```
70
+
71
+ - 直接改 `agent/request` waterfall 的返回值:会被 `dsh-agent` 自带监听器强制改回「选定模型」——无效(v1.3.0 及之前的缺陷)
72
+ - 改 `llm/stream`:agent-loop 请求到达时已深度冻结,无法改写
73
+
74
+ v1.4.0 的正确做法:在 `agent/inbox/claimed`(回合开始、prompt 组装之前)向会话日志追加
75
+ `request/header` 事件,下一轮组装自然读到新模型。`agent/request` 只做只读观测
76
+ (记录每步真实出网模型);插件中途热加载时由 `llm/stream` 在循环自身 header 落盘后补一次应用。
77
+ 切到 fast/mid 档会去掉 `reasoningEffort`,让目标模型走自身默认 effort。
34
78
 
35
79
  ## 工具
36
80
 
@@ -80,21 +124,7 @@ dsh plugin --profile web add dsh-agentic-router
80
124
  - 反馈依赖部署的 Web UI 实际产生数据(本插件只读取,不写入)
81
125
  - 毕业策略(LinUCB/k-NN)需要数十个回合样本才会进元层池
82
126
 
83
- ## 开发
84
-
85
- ```sh
86
- npm test # node --test,零依赖
87
- ```
88
-
89
- 回归用例覆盖实测缺陷:qa 祈使句、mid 档回退、UCB 冷启动轮转、agent 键隔离、奖励 JSON 合法性、飞轮重放恢复等。
90
-
91
- ## 发布
92
-
93
- ```sh
94
- npm publish # 需 npm 账号
95
- git push origin main # GitHub 仓库公开后
96
- # 目录站:给仓库加 dsh-plugin topic 自动收录,或到 dshmarketplace.dev/submit 提交
97
- ```
127
+ **开发、测试与发布流程(维护者)见 [docs/MAINTAINING.md](./docs/MAINTAINING.md)。**
98
128
 
99
129
  ## License
100
130
 
package/docs/DESIGN.md CHANGED
@@ -2,10 +2,12 @@
2
2
 
3
3
  > 目标:在 DeepSeek Harness 里做一个**学习型智能路由**插件——监听用户输入,按任务类型与复杂度把请求路由到最合适的模型 / 子 agent / 工具组合;每次路由的**决策与结果全部落盘**,形成数据飞轮,让路由判断随使用越来越准、越来越快。
4
4
 
5
+ > ⚠️ **v1.4.0 机制修正**:下文 1.1 的 `agent/request` 返回替换配置方案已废弃——实测该返回值会被 `dsh-agent` 内置的模型选择监听器强制改回「选定模型」,不生效。v1.4.0 改为在 `agent/inbox/claimed` 向会话日志追加 `request/header`(reason: `router`),走官方模型选择优先级链真实切换;`agent/request` 降级为只读观测,`llm/stream` 提供中途热加载的兜底应用。实现见 `dsh/index.js` 顶部注释与 README「切换机制」一节。
6
+
5
7
  ## 0. 一句话架构
6
8
 
7
9
  ```
8
- 用户输入 → 特征提取(快) → 路由器决策 → 改写请求(agent/request waterfall) / 派发子 agent
10
+ 用户输入 → 特征提取(快) → 路由器决策 → request/header 追加(真实切换) / 派发子 agent
9
11
  ↑ ↓
10
12
  策略(规则表+学习权重) ← 评分与在线学习 ← 结果回传(质量/成本/延迟落盘)
11
13
  ```
@@ -0,0 +1,74 @@
1
+ # 维护者手册
2
+
3
+ > 读者:本仓库的维护者/贡献者。安装使用者请看根目录 README。
4
+ > 原则:**main 同时是 `github:` 安装源与目录站文档源,不要直接推 main;npm 版本不可变、可精确回滚,是正式发布门禁。**
5
+
6
+ ## 一次性设置
7
+
8
+ ### 1. GitHub 分支保护
9
+
10
+ 仓库 Settings → Branches → Add branch protection rule:
11
+
12
+ - Branch name pattern:`main`
13
+ - ✅ Require a pull request before merging
14
+ - ✅ Require status checks to pass before merging → 选 `ci`
15
+ - ✅ Require linear history(可选,保持历史干净)
16
+
17
+ ### 2. CI 发布令牌
18
+
19
+ `.github/workflows/release.yml` 在 `v*` tag 上自动测试并 `npm publish`,需要一个 npm 令牌:
20
+
21
+ 1. npmjs.com → Access Tokens → Generate New Token → **Granular Access Token**
22
+ 2. 权限选本包 **Read and write**;Token 类型必须选 **Automation**(可绕过 2FA,官方为 CI 设计)
23
+ 3. GitHub 仓库 Settings → Secrets and variables → Actions → New repository secret
24
+ - Name:`NPM_TOKEN`
25
+ - Value:`npm_xxxxxxxx...`
26
+
27
+ ### 3. 目录站收录
28
+
29
+ 仓库 About → Topics 添加 `dsh-plugin`(目录站周期同步自动收录);也可主动到
30
+ <https://dshmarketplace.dev/submit> 提交仓库 URL(人工审核)。
31
+
32
+ ## 开发与测试
33
+
34
+ ```sh
35
+ npm test # node --test,零依赖
36
+ ```
37
+
38
+ 回归用例覆盖实测缺陷:qa 祈使句、mid 档回退、UCB 冷启动轮转、agent 键隔离、
39
+ 奖励 JSON 合法性、飞轮重放恢复、反馈结算时序、多 provider 价格解析等。
40
+
41
+ 背景:核心逻辑先在真实运行时的动态原型上逐阶段验证(P0 监听落盘 → P1 真实切换 →
42
+ P2 三策略飞轮 → P3 质量/成本/反馈奖励),共发现并修复 8 个实测缺陷后才沉淀为本仓库,
43
+ 每个缺陷都对应一条回归测试。改动后请先本地 `npm test` 全绿再开 PR。
44
+
45
+ ## 日常发布流程
46
+
47
+ ```sh
48
+ # 1. 功能分支开发(不要直接在 main 提交)
49
+ git checkout -b feat/xxx
50
+ git push -u origin feat/xxx # 开 PR,ci 检查绿了再合并
51
+
52
+ # 2. 合并到 main 后发布
53
+ git checkout main && git pull
54
+ npm version patch # bump 版本 + 自动打 vX.Y.Z tag + 提交
55
+ git push origin main --follow-tags # 推送后 CI 自动跑测试并发布 npm
56
+
57
+ # 重大变更用 minor(npm version minor),不兼容用 major
58
+ ```
59
+
60
+ ## 发布回滚
61
+
62
+ ```sh
63
+ # npm:版本不可变,出问题直接回滚 tag 指向旧版本
64
+ npm dist-tag add dsh-agentic-router@1.2.0 latest
65
+
66
+ # 源码:直接 revert 再走一遍发布流程
67
+ git revert <坏提交> && npm version patch && git push origin main --follow-tags
68
+ ```
69
+
70
+ ## 版本号约定(SemVer)
71
+
72
+ - **patch**:bug 修复、文档、测试(用户无感知)
73
+ - **minor**:新功能(如新增工具、新奖励信号)
74
+ - **major**:破坏性变更(工具改名、数据格式不兼容)
package/dsh/index.js CHANGED
@@ -1,14 +1,23 @@
1
1
  // dsh-agentic-router —— DeepSeek Harness 插件(Host 侧)
2
2
  //
3
- // 学习型智能路由:监听 agent/pre-step 与 agent/request,按任务类型与复杂度在
4
- // 模型档位(fast/mid/strong)之间路由;四个专家(规则表 / UCB / LinUCB / k-NN)
5
- // 并行推荐,EXP3 元选择器决定听谁的;回合结束时按质量代理信号
6
- // (工具失败 + 模型重试 + 延迟)计算奖励并回传飞轮。
3
+ // 学习型智能路由(v1.4.0 修复版):按任务类型与复杂度在模型档位(fast/mid/strong)
4
+ // 之间路由;四个专家(规则表 / UCB / LinUCB / k-NN)并行推荐,EXP3 元选择器决定
5
+ // 听谁的;回合结束时按质量代理信号(工具失败 + 模型重试 + 成本 + 延迟)计算奖励
6
+ // 并回传飞轮;人类 👍/👎 反馈在结算窗口后修正奖励。
7
7
  //
8
- // 架构依据 docs/architecture.md:
9
- // - "Add a model-facing capability" → ctx.tools 注册 4 个管理工具
10
- // - "Intercept a request" → agent/request waterfall 替换 LlmCallConfig
11
- // - 所有监听经 ctx.on 注册,随插件卸载自动回收
8
+ // ── 切换机制(v1.4.0 修复)────────────────────────────────────────────
9
+ // v1.3.0 及之前通过 `agent/request` waterfall 返回替换后的 LlmCallConfig 来切模型,
10
+ // 但该返回值会被 dsh-agent 内置的模型选择监听器强制改回「选定模型」,实际不生效。
11
+ // 正确做法是走 dsh 官方的模型选择优先级链:
12
+ //
13
+ // 本进程选择 → 会话日志最新 request/header → 默认模型
14
+ //
15
+ // 在 `agent/inbox/claimed`(回合开始、prompt 组装之前)向会话日志追加一条
16
+ // `request/header` 事件(reason: 'router'),下一轮组装就会读到新模型,真正生效。
17
+ // `agent/request` 只做只读观测(记录每步实际出网模型);插件中途热加载时,
18
+ // `llm/stream` 在循环自身 header 落盘后补一次应用(deferred apply)。
19
+ //
20
+ // 其余架构依据 docs/architecture.md;所有监听经 ctx.on 注册,随插件卸载自动回收。
12
21
  // 飞轮数据落盘 ${DSH_HOME:-~/.dsh}/storages/dsh-agentic-router/,重启后重放恢复。
13
22
 
14
23
  import { classify, featuresOf } from './classify.js'
@@ -29,6 +38,22 @@ const KNN_GRADUATION = 30 // k-NN 毕业经验线
29
38
  const SETTLE_DELAY_MS = 45000 // 反馈结算窗口:回合结束 45s 后查询 messageFeedback
30
39
  const SWEEP_MS = 15000 // 结算轮询周期
31
40
 
41
+ // 同步兜底档位表:从 DEFAULT_PRICES 已知的 provider→model 推导档位,
42
+ // 让插件在 llm.listModels 异步加载完成之前也能立即决策与切换(不 await、不延期)。
43
+ // 加载完成后由 refreshModelTiers 用权威目录覆盖。
44
+ function fallbackTiers() {
45
+ const tiers = { fast: [], mid: [], strong: [] }
46
+ const providers = []
47
+ for (const provider of Object.keys(DEFAULT_PRICES)) {
48
+ if (provider === '*') continue
49
+ const models = Object.keys(DEFAULT_PRICES[provider] || {})
50
+ if (!models.length) continue
51
+ providers.push({ provider, models })
52
+ for (const n of models) tiers[tierOf(n)].push(provider + '/' + n)
53
+ }
54
+ return { providers, tiers }
55
+ }
56
+
32
57
  function freshState() {
33
58
  return {
34
59
  mode: 'shadow',
@@ -36,11 +61,13 @@ function freshState() {
36
61
  decisions: [],
37
62
  forced: null,
38
63
  pending: new Map(),
39
- modelTiers: null,
64
+ modelTiers: fallbackTiers(),
40
65
  turnDecision: {},
41
66
  openTurns: new Map(),
42
67
  pendingFeedback: [],
43
68
  recByTurn: new Map(),
69
+ headerAppends: 0,
70
+ lastActual: null,
44
71
  ucb: { total: 0, arms: {} },
45
72
  lin: {
46
73
  total: 0,
@@ -87,6 +114,20 @@ function textOfMessages(messages) {
87
114
  return parts.join('\n').trim()
88
115
  }
89
116
 
117
+ function extractText(message) {
118
+ if (message == null) return ''
119
+ if (typeof message.text === 'string') return message.text
120
+ const blocks = message.content
121
+ if (Array.isArray(blocks)) {
122
+ let out = ''
123
+ for (const b of blocks) {
124
+ if (b && typeof b === 'object' && b.type === 'text' && typeof b.text === 'string') out += b.text + '\n'
125
+ }
126
+ return out
127
+ }
128
+ return ''
129
+ }
130
+
90
131
  function isErrorResult(r) {
91
132
  if (r == null || typeof r !== 'object') return false
92
133
  if (r.ok === false || r.success === false) return true
@@ -110,6 +151,57 @@ function execAgentKey(exec) {
110
151
  return null
111
152
  }
112
153
 
154
+ // 会话句柄:agent 的 session(inbox / request 路径)
155
+ function sessionOf(payload) {
156
+ try {
157
+ const a = payload && payload.agent
158
+ const s = a && a.session
159
+ if (s && typeof s.append === 'function' && typeof s.requestHeader === 'function' && typeof s.id === 'string') return s
160
+ } catch {}
161
+ return null
162
+ }
163
+
164
+ function headerConfig(session) {
165
+ try {
166
+ const h = session.requestHeader()
167
+ if (h && h.config && typeof h.config === 'object') return h.config
168
+ } catch {}
169
+ return null
170
+ }
171
+
172
+ function currentModel(session) {
173
+ const c = headerConfig(session)
174
+ return c && typeof c.model === 'string' ? c.model : null
175
+ }
176
+
177
+ function currentProvider(session) {
178
+ const c = headerConfig(session)
179
+ return c && typeof c.provider === 'string' ? c.provider : ''
180
+ }
181
+
182
+ // 核心切换:向会话日志追加 request/header(reason: 'router'),
183
+ // 下一轮 prompt 组装即读到新模型。fast/mid 档去掉 reasoningEffort,
184
+ // 让目标模型走自身默认 effort(避免 flash 不支持 max 而整步失败)。
185
+ function appendRouterHeader(session, targetModel) {
186
+ try {
187
+ const last = session.requestHeader()
188
+ const header = {}
189
+ if (last && typeof last === 'object') {
190
+ for (const k of Object.keys(last)) header[k] = last[k]
191
+ }
192
+ const prevConfig = header.config && typeof header.config === 'object' ? header.config : {}
193
+ const config = {}
194
+ for (const k of Object.keys(prevConfig)) config[k] = prevConfig[k]
195
+ config.model = targetModel
196
+ if (tierOf(targetModel) !== 'strong') delete config.reasoningEffort
197
+ header.config = config
198
+ session.append('request/header', { header, reason: 'router' })
199
+ return true
200
+ } catch (e) {
201
+ return false
202
+ }
203
+ }
204
+
113
205
  async function refreshModelTiers(llm) {
114
206
  if (!llm || typeof llm.listProviders !== 'function') return null
115
207
  const providers = await llm.listProviders()
@@ -209,7 +301,115 @@ export function apply(ctx, config = {}) {
209
301
  timer.interval(() => { settleFeedback().catch((e) => log('settle sweep failed: ' + String(e))) }, SWEEP_MS)
210
302
  }
211
303
 
304
+ // ---- 决策流水线(inbox 与 request 兜底共用) ----
305
+ // 返回 { key, td, record, target };已决策过本回合则返回 null。
306
+ // 必须同步执行:档位表用 fallbackTiers() 兜底(见 freshState),
307
+ // 不 await、不延期——否则追加 header 会落到 prompt 组装之后,被 loop 覆盖。
308
+ function decideAndRecord(payload, text, current, provider, source) {
309
+ const key = fullKey(payload)
310
+ if (state.turnDecision[key]) return null
311
+ const cls = classify(text)
312
+ const f = featuresOf(cls, text)
313
+ const recs = {
314
+ rule: recommendRule(cls),
315
+ ucb: recommendUcb(cls, state.ucb),
316
+ lin: recommendLin(cls, f, state.lin),
317
+ knn: recommendKnn(cls, f, state.knn),
318
+ }
319
+ if (state.forced) recs.rule = { tier: 'forced', reason: 'forced', conf: 1 }
320
+ const exp3 = {
321
+ ...state.exp3,
322
+ linGraduated: state.lin.total >= LIN_GRADUATION,
323
+ knnGraduated: state.knn.pool.length >= KNN_GRADUATION,
324
+ }
325
+ const meta = metaPick(recs, exp3)
326
+ const chosenRec = meta.byName[meta.chosen] || recs.rule
327
+ const resolved = state.forced
328
+ ? { target: state.forced, used: 'forced', note: 'forced' }
329
+ : resolveTier(chosenRec.tier, provider, state.modelTiers)
330
+ const target = resolved.target
331
+ state.seq += 1
332
+ const td = {
333
+ clsType: cls.type, f, recs, meta, chosen: meta.chosen,
334
+ finalized: false, t0: now(), toolErrors: 0, retries: 0,
335
+ currentModel: current, provider, costWeight, costCap,
336
+ estInputTokens: Math.ceil(text.length / 1.5),
337
+ target, applied: false, needsApply: false, actualModel: current,
338
+ }
339
+ const record = {
340
+ seq: state.seq,
341
+ turn: key,
342
+ mode: state.mode,
343
+ taskType: cls.type,
344
+ complexity: cls.complexity,
345
+ inputPreview: text.slice(0, 100),
346
+ currentModel: current,
347
+ recs: {
348
+ rule: recs.rule.tier + '(' + recs.rule.conf + ')',
349
+ ucb: recs.ucb.tier + '(' + recs.ucb.conf + ')',
350
+ lin: recs.lin.tier + '(' + recs.lin.conf + ')',
351
+ knn: recs.knn.tier + '(' + recs.knn.conf + ')',
352
+ },
353
+ metaChosen: meta.chosen,
354
+ metaProbs: meta.ps,
355
+ metaPool: meta.pool,
356
+ resolvedTier: resolved.used + (resolved.note ? ' [' + resolved.note + ']' : ''),
357
+ targetModel: state.mode === 'active' ? target : null,
358
+ applied: false,
359
+ appliedAt: source,
360
+ swapped: false,
361
+ }
362
+ td.rec = record
363
+ state.turnDecision[key] = td
364
+ state.openTurns.set(agentKey(payload), key)
365
+ return { key, td, record, target }
366
+ }
367
+
368
+ // 决策记录落盘(在 applyDecision 之后调用,保证 applied/swapped 已是最终值)
369
+ function commitRecord(record) {
370
+ store.appendJsonl('decisions.jsonl', record)
371
+ state.decisions.push(record)
372
+ if (state.decisions.length > 200) state.decisions.shift()
373
+ }
374
+
375
+ // 应用切换:active 模式且目标与当前不同 → 追加 request/header。
376
+ // 返回是否真实追加成功。
377
+ function applyDecision(session, key, td, record, target) {
378
+ if (state.mode !== 'active' || !target) return false
379
+ const base = td.actualModel || td.currentModel
380
+ if (base && target === base) return false
381
+ const ok = session ? appendRouterHeader(session, target) : false
382
+ td.applied = ok
383
+ if (record) { record.applied = ok; record.swapped = ok }
384
+ if (ok) {
385
+ state.headerAppends++
386
+ log('SWAP -> ' + target + ' | ' + td.chosen + ' | ' + (td.recs && td.recs[td.chosen] ? td.recs[td.chosen].reason : ''))
387
+ }
388
+ return ok
389
+ }
390
+
212
391
  // ---- 事件挂载 ----
392
+ // 回合开始(prompt 组装之前):捕获用户消息并决策,active 模式立即应用切换。
393
+ // 这是主路径——零延迟,回合第一步就按目标模型执行。
394
+ ctx.on('agent/inbox/claimed', (payload) => {
395
+ try {
396
+ const session = sessionOf(payload)
397
+ const text = extractText(payload && payload.message)
398
+ if (!text) return
399
+ const current = session ? currentModel(session) : null
400
+ const provider = session ? currentProvider(session) : ''
401
+ const r = decideAndRecord(payload, text, current, provider, 'inbox')
402
+ if (!r) return
403
+ applyDecision(session, r.key, r.td, r.record, r.target)
404
+ commitRecord(r.record)
405
+ log('decision #' + state.seq + ' type=' + r.td.clsType + ' c=' + r.td.f[1] +
406
+ ' meta=' + r.td.chosen + ' tier=' + r.record.resolvedTier +
407
+ ' applied=' + r.td.applied + ' target=' + (r.target || '-'))
408
+ } catch (e) {
409
+ log('inbox decide failed: ' + String(e))
410
+ }
411
+ })
412
+
213
413
  ctx.on('agent/pre-step', (payload, next) => {
214
414
  try {
215
415
  const text = textOfMessages(payload && payload.messages)
@@ -220,79 +420,35 @@ export function apply(ctx, config = {}) {
220
420
  return next()
221
421
  })
222
422
 
423
+ // 只读观测 + 兜底决策。观测:记录每步真实出网模型(供 stats 验证切换生效)。
424
+ // 兜底:inbox 未触发(插件中途热加载)时,在首个请求用 pending 文本决策,
425
+ // 但不再替换配置——改由 llm/stream 在循环 header 落盘后补应用。
223
426
  ctx.on('agent/request', async (payload, next) => {
224
427
  const cfg = await next()
225
428
  if (!cfg || typeof cfg !== 'object') return cfg
226
429
  try {
227
430
  if (!timer || typeof timer.interval !== 'function') settleFeedback().catch(() => {})
228
431
  const key = fullKey(payload)
229
- if (state.turnDecision[key]) return cfg
432
+ const model = typeof cfg.model === 'string' ? cfg.model : null
433
+ const td = state.turnDecision[key]
434
+ if (td) {
435
+ if (model) {
436
+ td.actualModel = model
437
+ state.lastActual = { model, turn: payload.turn, step: payload.step, key }
438
+ }
439
+ return cfg
440
+ }
230
441
  const pkey = agentKey(payload) + ':' + payload.turn
231
442
  const text = state.pending.get(pkey) || ''
232
443
  state.pending.delete(pkey)
233
- const cls = classify(text)
234
- const f = featuresOf(cls, text)
235
- const current = typeof cfg.model === 'string' ? cfg.model : '(unknown)'
236
444
  const provider = typeof cfg.provider === 'string' ? cfg.provider : ''
237
- const recs = {
238
- rule: recommendRule(cls),
239
- ucb: recommendUcb(cls, state.ucb),
240
- lin: recommendLin(cls, f, state.lin),
241
- knn: recommendKnn(cls, f, state.knn),
242
- }
243
- if (state.forced) recs.rule = { tier: 'forced', reason: 'forced', conf: 1 }
244
- const exp3 = {
245
- ...state.exp3,
246
- linGraduated: state.lin.total >= LIN_GRADUATION,
247
- knnGraduated: state.knn.pool.length >= KNN_GRADUATION,
248
- }
249
- const meta = metaPick(recs, exp3)
250
- const chosenRec = meta.byName[meta.chosen] || recs.rule
251
- const resolved = state.forced
252
- ? { target: state.forced, used: 'forced', note: 'forced' }
253
- : resolveTier(chosenRec.tier, provider, state.modelTiers)
254
- const target = resolved.target
255
- state.seq += 1
256
- const record = {
257
- seq: state.seq,
258
- turn: key,
259
- mode: state.mode,
260
- taskType: cls.type,
261
- complexity: cls.complexity,
262
- inputPreview: text.slice(0, 100),
263
- currentModel: current,
264
- recs: {
265
- rule: recs.rule.tier + '(' + recs.rule.conf + ')',
266
- ucb: recs.ucb.tier + '(' + recs.ucb.conf + ')',
267
- lin: recs.lin.tier + '(' + recs.lin.conf + ')',
268
- knn: recs.knn.tier + '(' + recs.knn.conf + ')',
269
- },
270
- metaChosen: meta.chosen,
271
- metaProbs: meta.ps,
272
- metaPool: meta.pool,
273
- resolvedTier: resolved.used + (resolved.note ? ' [' + resolved.note + ']' : ''),
274
- targetModel: state.mode === 'active' ? target : null,
275
- swapped: false,
276
- }
277
- state.turnDecision[key] = {
278
- clsType: cls.type, f, recs, meta, chosen: meta.chosen,
279
- finalized: false, t0: now(), toolErrors: 0, retries: 0,
280
- currentModel: current, provider, costWeight, costCap,
281
- estInputTokens: Math.ceil(text.length / 1.5),
282
- }
283
- state.openTurns.set(agentKey(payload), key)
284
- store.appendJsonl('decisions.jsonl', record)
285
- if (state.mode === 'active' && target && target !== current) {
286
- record.swapped = true
287
- state.decisions.push(record)
288
- if (state.decisions.length > 200) state.decisions.shift()
289
- log('SWAP -> ' + target + ' | ' + meta.chosen + ' | ' + chosenRec.reason + ' | ' + resolved.note)
290
- return { ...cfg, model: target }
445
+ const r = decideAndRecord(payload, text, model, provider, 'request')
446
+ if (r) {
447
+ r.td.needsApply = state.mode === 'active' && r.target && r.target !== r.td.currentModel
448
+ commitRecord(r.record)
449
+ if (r.td.needsApply) log('decision #' + state.seq + ' -> apply deferred to llm/stream')
450
+ else log('decision #' + state.seq + ' type=' + r.td.clsType + ' meta=' + r.td.chosen + ' tier=' + r.record.resolvedTier)
291
451
  }
292
- state.decisions.push(record)
293
- if (state.decisions.length > 200) state.decisions.shift()
294
- log('decision #' + state.seq + ' type=' + cls.type + ' c=' + cls.complexity +
295
- ' meta=' + meta.chosen + ' tier=' + resolved.used + ' recs=' + JSON.stringify(record.recs))
296
452
  } catch (e) {
297
453
  log('decide failed: ' + String(e))
298
454
  }
@@ -313,7 +469,8 @@ export function apply(ctx, config = {}) {
313
469
  })
314
470
 
315
471
  // 成本信号:透传每次流式模型调用,累加 usage chunk 到当前 open turn;
316
- // 无 usage 的 provider 用文本长度估算(estimated=true)
472
+ // 无 usage 的 provider 用文本长度估算(estimated=true)。
473
+ // 同时:request 路径的兜底决策在这里补应用切换(循环自身 header 已落盘,无竞态)。
317
474
  ctx.on('llm/stream', function (options, next) {
318
475
  return (async function* () {
319
476
  let usage = null
@@ -330,7 +487,19 @@ export function apply(ctx, config = {}) {
330
487
  const key = sid ? state.openTurns.get(sid) : null
331
488
  const td = key ? state.turnDecision[key] : null
332
489
  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)
498
+ }
333
499
  const u = td.usage || { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, reasoningTokens: 0, estimated: false }
500
+ const model = (options && typeof options.model === 'string' && options.model) || td.currentModel || ''
501
+ const tier = tierOf(model)
502
+ const provider = (options && typeof options.provider === 'string' && options.provider) || td.provider || ''
334
503
  if (usage && typeof usage.inputTokens === 'number') {
335
504
  u.inputTokens += usage.inputTokens
336
505
  u.outputTokens += usage.outputTokens || 0
@@ -343,9 +512,6 @@ export function apply(ctx, config = {}) {
343
512
  u.estimated = true
344
513
  }
345
514
  td.usage = u
346
- const model = typeof td.currentModel === 'string' ? td.currentModel : ''
347
- const tier = tierOf(model)
348
- const provider = (options && typeof options.provider === 'string' && options.provider) || td.provider || ''
349
515
  const rp = resolvePrice(provider, model, tier, prices)
350
516
  td.costYuan = turnCostYuan(u, provider, model, tier, prices)
351
517
  td.pricingSource = rp.source
@@ -419,10 +585,13 @@ export function apply(ctx, config = {}) {
419
585
  ctx.on('agent/turn-stopping', (payload) => finalize(payload, 'clean-close'))
420
586
  ctx.on('agent/error', (payload) => finalize(payload, 'error'))
421
587
 
422
- refreshModelTiers(llm).then((t) => {
423
- state.modelTiers = t
588
+ // 权威档位表异步加载,就绪后覆盖同步兜底表(fallbackTiers 保证首回合即可决策)。
589
+ // 仅在刷新成功时覆盖,避免把兜底表清成 null。
590
+ const tiersReady = refreshModelTiers(llm)
591
+ tiersReady.then((t) => {
592
+ if (t) state.modelTiers = t
424
593
  log('model tiers: ' + JSON.stringify(t))
425
- })
594
+ }).catch((e) => log('model tiers refresh failed: ' + String(e)))
426
595
 
427
596
  // ---- 工具 ----
428
597
  const OUT = (label) => ({
@@ -440,7 +609,7 @@ export function apply(ctx, config = {}) {
440
609
 
441
610
  register({
442
611
  name: 'agentic_router_stats',
443
- description: '查看 agentic router(UCB/LinUCB/k-NN 三策略 + EXP3 元选择器 + 质量代理奖励)的决策记录、策略权重与飞轮学习状态。',
612
+ description: '查看 agentic router(UCB/LinUCB/k-NN 三策略 + EXP3 元选择器 + 质量代理奖励)的决策记录、策略权重、真实切换与飞轮学习状态。',
444
613
  parameters: { type: 'object', properties: {} },
445
614
  output: OUT('agentic router 状态'),
446
615
  isConcurrencySafe: () => true,
@@ -451,6 +620,9 @@ export function apply(ctx, config = {}) {
451
620
  totalDecisions: state.seq,
452
621
  modelTiers: state.modelTiers,
453
622
  prices,
623
+ mechanism: 'request/header append (agent/inbox/claimed)',
624
+ lastActual: state.lastActual,
625
+ headerAppends: state.headerAppends,
454
626
  flywheel: {
455
627
  ucb: state.ucb,
456
628
  lin: { total: state.lin.total, tierN: state.lin.tierN, tierW: state.lin.tierW },
@@ -461,6 +633,7 @@ export function apply(ctx, config = {}) {
461
633
  recentRewards: store.loadJsonl('rewards.jsonl').slice(-20),
462
634
  },
463
635
  recent: state.decisions.slice(-20),
636
+ note: '切换真实性验证:headerAppends 为本进程追加的 request/header 次数;lastActual 为最近一步真实出网模型;decisions[].applied=true 且 swapped=true 即真实切换成功',
464
637
  }
465
638
  },
466
639
  })
@@ -501,6 +674,23 @@ export function apply(ctx, config = {}) {
501
674
  if (args && typeof args.model === 'string') {
502
675
  state.forced = args.model
503
676
  store.savePolicy({ mode: state.mode, forced: args.model, prices })
677
+ // 立即对存活会话应用(下一请求生效)
678
+ if (state.mode === 'active') {
679
+ try {
680
+ const agents = ctx.get('agents')
681
+ if (agents && typeof agents.list === 'function') {
682
+ for (const agent of agents.list()) {
683
+ try {
684
+ const session = sessionOf({ agent })
685
+ if (session && currentModel(session) !== args.model) {
686
+ const ok = appendRouterHeader(session, args.model)
687
+ if (ok) state.headerAppends++
688
+ }
689
+ } catch (e) { /* per-agent best effort */ }
690
+ }
691
+ }
692
+ } catch (e) { /* best effort */ }
693
+ }
504
694
  return { forced: args.model }
505
695
  }
506
696
  return { forced: state.forced, error: '需要 model 或 clear=true' }
@@ -552,6 +742,8 @@ export function apply(ctx, config = {}) {
552
742
  state.exp3 = { total: 0, weights: { ...DEFAULT_WEIGHTS } }
553
743
  state.seq = 0
554
744
  state.decisions = []
745
+ state.headerAppends = 0
746
+ state.lastActual = null
555
747
  return { reset: true, home: store.home }
556
748
  },
557
749
  })
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-agentic-router",
3
- "version": "1.3.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",
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)",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "author": "lhl",