dsh-agentic-router 1.0.0 → 1.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/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
 
@@ -40,36 +61,45 @@ dsh plugin --profile web add dsh-agentic-router
40
61
  | `agentic_router_set_mode` | shadow (default) / active (real swaps) / off (bypass) |
41
62
  | `agentic_router_force` | force a model id (active mode only); clear=true to release |
42
63
  | `agentic_router_reset` | wipe flywheel learning state, keep mode |
64
+ | `agentic_router_set_prices` | set/query per-provider per-model (or per-tier) prices |
43
65
 
44
66
  ## Data flywheel (persistence)
45
67
 
46
68
  Directory: `${DSH_HOME:-~/.dsh}/storages/dsh-agentic-router/` (override with `AGENTIC_ROUTER_HOME`)
47
69
 
48
70
  - `decisions.jsonl` — one line per decision: task type/complexity, all four recommendations, meta pick, actual route, swapped?
49
- - `rewards.jsonl` — one line per reward with breakdown (tool errors/retries/latency) + feature vector; **replayed on startup to restore learning state**
71
+ - `rewards.jsonl` — one line per reward with breakdown (tool errors/retries/latency) + feature vector; **replayed on startup to restore learning state**; human-feedback settlements (`feedback-settle` lines) correct the replayed rewards exactly
72
+ - Human feedback: 45s after a turn closes, the plugin polls `messageFeedback` (the Web UI 👍/👎); a thumbs-down costs −0.5, a thumbs-up adds +0.1, applied as an exact correction to the already-applied reward
50
73
  - `policy.json` — mode and forced model (atomic write)
51
74
 
52
- Reward: clean close starts at 1; tool failures −0.2 each (cap 0.6), model retries −0.2 each (cap 0.4), latency >30s −0.1 / >90s −0.3; errored turns score 0.
53
-
54
- ## Known limits
75
+ Reward: clean close starts at 1; tool failures −0.2 each (cap 0.6), model retries −0.2 each (cap 0.4),
76
+ **cost** min(0.5, 20 × turn cost in CNY), latency >30s −0.1 / >90s −0.3; errored turns score 0.
55
77
 
56
- - Model tiers are recognized by id keywords (flash→fast, pro→strong); real swaps happen only when more than one tier exists
57
- - Rewards are proxies: a mediocre-but-successful answer still scores full marks, so wire up UI feedback (messageFeedback) for production use
58
- - LinUCB / k-NN need dozens of turns before they graduate into the meta pool
78
+ **Cost signal (token billing)**: the plugin wraps `llm/stream` and accumulates each model
79
+ call's `usage` chunk (input/output/cache-read/reasoning tokens); providers without usage
80
+ fall back to length-based estimates (flagged `estimated`).
59
81
 
60
- ## Development
82
+ **Three-level price resolution** (CNY per million tokens): `provider→model` exact price >
83
+ `provider→tier` price > `*` generic tier > conservative fallback. **Unknown providers
84
+ never inherit DeepSeek pricing** — they use the generic tier and carry a `pricingSource`
85
+ audit field. DeepSeek's official pricing ships as default; add other models at runtime via
86
+ the `agentic_router_set_prices` tool (persisted to policy.json) or at install via
87
+ `config.prices`:
61
88
 
62
- ```sh
63
- npm test # node --test, zero dependencies
89
+ ```js
90
+ { 'deepseek-official': { 'deepseek-v4-pro': { in: 1, out: 12 } },
91
+ openai: { 'gpt-x-pro': { in: 15, out: 60 } },
92
+ '*': { fast: { in: 1, out: 4 }, mid: { in: 2, out: 8 }, strong: { in: 3, out: 16 } } }
64
93
  ```
65
94
 
66
- ## Publish
95
+ ## Known limits
96
+
97
+ - Model tiers are recognized by id keywords (flash→fast, pro→strong); real swaps happen only when more than one tier exists
98
+ - Feedback settles after a 45s window (configurable via `settleDelayMs`); unsettled pending entries are lost on restart; late feedback may be attributed to a later turn
99
+ - Feedback only works where the deployment's Web UI actually writes it (this plugin only reads)
100
+ - LinUCB / k-NN need dozens of turns before they graduate into the meta pool
67
101
 
68
- ```sh
69
- npm publish # requires npm account
70
- git push origin main # after making the GitHub repo public
71
- # directory listing: add the dsh-plugin topic, or submit at dshmarketplace.dev/submit
72
- ```
102
+ **Development, testing and release flow (maintainers): see [docs/MAINTAINING.md](./docs/MAINTAINING.md).**
73
103
 
74
104
  ## License
75
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
 
@@ -40,16 +84,33 @@ dsh plugin --profile web add dsh-agentic-router
40
84
  | `agentic_router_set_mode` | 切换 shadow(默认)/ active(真切换)/ off(旁路) |
41
85
  | `agentic_router_force` | 强制指定模型 id(active 生效),clear=true 清除 |
42
86
  | `agentic_router_reset` | 清空飞轮学习状态,保留模式 |
87
+ | `agentic_router_set_prices` | 配置/查询某 provider 某模型(或档位)的价格 |
43
88
 
44
89
  ## 数据飞轮(落盘)
45
90
 
46
91
  目录:`${DSH_HOME:-~/.dsh}/storages/dsh-agentic-router/`(`AGENTIC_ROUTER_HOME` 可覆盖)
47
92
 
48
93
  - `decisions.jsonl` — 每条决策:任务类型/复杂度/四专家推荐/元层选择/实际路由/是否切换
49
- - `rewards.jsonl` — 每条奖励:明细(工具失败/重试/延迟)+ 特征向量,**重启后重放恢复学习状态**
94
+ - `rewards.jsonl` — 每条奖励:明细(工具失败/重试/延迟)+ 特征向量,**重启后重放恢复学习状态**;人类反馈结算(`feedback-settle` 行)在重放时精确修正
95
+ - 人类反馈:回合结束 45s 后查询 `messageFeedback`(Web UI 的 👍/👎),点踩 −0.5、点赞 +0.1,修正已应用的奖励(UCB 均值/EXP3 权重精确回退)
50
96
  - `policy.json` — 模式与强制模型(原子写)
51
97
 
52
- 奖励公式:干净收尾 1 分起,工具失败 −0.2/次(封顶 0.6)、模型重试 −0.2/次(封顶 0.4)、延迟 >30s −0.1 / >90s −0.3;出错回合记 0。
98
+ 奖励公式:干净收尾 1 分起,工具失败 −0.2/次(封顶 0.6)、模型重试 −0.2/次(封顶 0.4)、
99
+ **成本** min(0.5, 20×回合成本元)、延迟 >30s −0.1 / >90s −0.3;出错回合记 0。
100
+
101
+ **成本信号(token 计费)**:透传 `llm/stream`,累加每个模型调用的 `usage` chunk
102
+ (输入/输出/缓存读/推理 token);无 usage 的 provider 按文本长度估算(标 `estimated`)。
103
+
104
+ **价格表三级解析**(元/百万 token):`provider→model` 精确价 > `provider→档位` 价 >
105
+ `*` 通用档位 > 保守兜底。**未知 provider 绝不套用 DeepSeek 价格**,用通用档位并标记
106
+ `pricingSource`(审计字段)。默认含 DeepSeek 官方口径,接入其他模型用
107
+ `agentic_router_set_prices` 工具配价(持久化到 policy.json),或安装时传 `config.prices`:
108
+
109
+ ```js
110
+ { 'deepseek-official': { 'deepseek-v4-pro': { in: 1, out: 12 } },
111
+ openai: { 'gpt-x-pro': { in: 15, out: 60 } },
112
+ '*': { fast: { in: 1, out: 4 }, mid: { in: 2, out: 8 }, strong: { in: 3, out: 16 } } }
113
+ ```
53
114
 
54
115
  ## 分类器(规则先验,<1ms)
55
116
 
@@ -59,24 +120,11 @@ dsh plugin --profile web add dsh-agentic-router
59
120
  ## 已知边界
60
121
 
61
122
  - 模型档位靠 id 关键词识别(flash→fast、pro→strong);可用模型多于一档时路由才真正切换
62
- - 奖励信号是"代理":任务成功但质量平庸的情况(尤其模型池内质量差距大时)仍可能被记满分,正式场景建议接入 UI 反馈(messageFeedback)
123
+ - 反馈结算窗口 45s(可配置 `settleDelayMs`),窗口内无反馈按代理奖励结算;重启会丢失未结算的 pending 条目;晚到的反馈可能归属到其后的回合
124
+ - 反馈依赖部署的 Web UI 实际产生数据(本插件只读取,不写入)
63
125
  - 毕业策略(LinUCB/k-NN)需要数十个回合样本才会进元层池
64
126
 
65
- ## 开发
66
-
67
- ```sh
68
- npm test # node --test,零依赖
69
- ```
70
-
71
- 回归用例覆盖实测缺陷:qa 祈使句、mid 档回退、UCB 冷启动轮转、agent 键隔离、奖励 JSON 合法性、飞轮重放恢复等。
72
-
73
- ## 发布
74
-
75
- ```sh
76
- npm publish # 需 npm 账号
77
- git push origin main # GitHub 仓库公开后
78
- # 目录站:给仓库加 dsh-plugin topic 自动收录,或到 dshmarketplace.dev/submit 提交
79
- ```
127
+ **开发、测试与发布流程(维护者)见 [docs/MAINTAINING.md](./docs/MAINTAINING.md)。**
80
128
 
81
129
  ## License
82
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,21 +1,31 @@
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'
15
24
  import {
16
25
  tierOf, tierTarget, resolveTier,
17
26
  recommendRule, recommendUcb, recommendLin, recommendKnn,
18
- rewardOf, applyReward,
27
+ rewardOf, applyReward, correctReward,
28
+ DEFAULT_PRICES, turnCostYuan, resolvePrice,
19
29
  } from './strategies.js'
20
30
  import { metaPick, DEFAULT_WEIGHTS } from './meta.js'
21
31
  import { Store, defaultHome } from './store.js'
@@ -25,6 +35,8 @@ export const inject = ['tools']
25
35
 
26
36
  const LIN_GRADUATION = 50 // LinUCB 毕业样本线
27
37
  const KNN_GRADUATION = 30 // k-NN 毕业经验线
38
+ const SETTLE_DELAY_MS = 45000 // 反馈结算窗口:回合结束 45s 后查询 messageFeedback
39
+ const SWEEP_MS = 15000 // 结算轮询周期
28
40
 
29
41
  function freshState() {
30
42
  return {
@@ -36,6 +48,10 @@ function freshState() {
36
48
  modelTiers: null,
37
49
  turnDecision: {},
38
50
  openTurns: new Map(),
51
+ pendingFeedback: [],
52
+ recByTurn: new Map(),
53
+ headerAppends: 0,
54
+ lastActual: null,
39
55
  ucb: { total: 0, arms: {} },
40
56
  lin: {
41
57
  total: 0,
@@ -82,6 +98,20 @@ function textOfMessages(messages) {
82
98
  return parts.join('\n').trim()
83
99
  }
84
100
 
101
+ function extractText(message) {
102
+ if (message == null) return ''
103
+ if (typeof message.text === 'string') return message.text
104
+ const blocks = message.content
105
+ if (Array.isArray(blocks)) {
106
+ let out = ''
107
+ for (const b of blocks) {
108
+ if (b && typeof b === 'object' && b.type === 'text' && typeof b.text === 'string') out += b.text + '\n'
109
+ }
110
+ return out
111
+ }
112
+ return ''
113
+ }
114
+
85
115
  function isErrorResult(r) {
86
116
  if (r == null || typeof r !== 'object') return false
87
117
  if (r.ok === false || r.success === false) return true
@@ -105,6 +135,57 @@ function execAgentKey(exec) {
105
135
  return null
106
136
  }
107
137
 
138
+ // 会话句柄:agent 的 session(inbox / request 路径)
139
+ function sessionOf(payload) {
140
+ try {
141
+ const a = payload && payload.agent
142
+ const s = a && a.session
143
+ if (s && typeof s.append === 'function' && typeof s.requestHeader === 'function' && typeof s.id === 'string') return s
144
+ } catch {}
145
+ return null
146
+ }
147
+
148
+ function headerConfig(session) {
149
+ try {
150
+ const h = session.requestHeader()
151
+ if (h && h.config && typeof h.config === 'object') return h.config
152
+ } catch {}
153
+ return null
154
+ }
155
+
156
+ function currentModel(session) {
157
+ const c = headerConfig(session)
158
+ return c && typeof c.model === 'string' ? c.model : null
159
+ }
160
+
161
+ function currentProvider(session) {
162
+ const c = headerConfig(session)
163
+ return c && typeof c.provider === 'string' ? c.provider : ''
164
+ }
165
+
166
+ // 核心切换:向会话日志追加 request/header(reason: 'router'),
167
+ // 下一轮 prompt 组装即读到新模型。fast/mid 档去掉 reasoningEffort,
168
+ // 让目标模型走自身默认 effort(避免 flash 不支持 max 而整步失败)。
169
+ function appendRouterHeader(session, targetModel) {
170
+ try {
171
+ const last = session.requestHeader()
172
+ const header = {}
173
+ if (last && typeof last === 'object') {
174
+ for (const k of Object.keys(last)) header[k] = last[k]
175
+ }
176
+ const prevConfig = header.config && typeof header.config === 'object' ? header.config : {}
177
+ const config = {}
178
+ for (const k of Object.keys(prevConfig)) config[k] = prevConfig[k]
179
+ config.model = targetModel
180
+ if (tierOf(targetModel) !== 'strong') delete config.reasoningEffort
181
+ header.config = config
182
+ session.append('request/header', { header, reason: 'router' })
183
+ return true
184
+ } catch (e) {
185
+ return false
186
+ }
187
+ }
188
+
108
189
  async function refreshModelTiers(llm) {
109
190
  if (!llm || typeof llm.listProviders !== 'function') return null
110
191
  const providers = await llm.listProviders()
@@ -124,25 +205,197 @@ async function refreshModelTiers(llm) {
124
205
  export function apply(ctx, config = {}) {
125
206
  const tools = ctx.get('tools')
126
207
  const llm = ctx.get('llm')
208
+ const mf = ctx.get('messageFeedback')
209
+ const timer = ctx.get('timer')
127
210
  const store = new Store(config.home || defaultHome())
211
+ const settleDelayMs = typeof config.settleDelayMs === 'number' ? config.settleDelayMs : SETTLE_DELAY_MS
128
212
  const policy = store.loadPolicy()
213
+ let prices = (policy.prices && Object.keys(policy.prices).length) ? policy.prices : (config.prices || DEFAULT_PRICES)
214
+ const costWeight = typeof config.costWeight === 'number' ? config.costWeight : 20
215
+ const costCap = typeof config.costCap === 'number' ? config.costCap : 0.5
129
216
 
130
217
  const state = freshState()
131
218
  state.mode = policy.mode || config.mode || 'shadow'
132
219
  if (typeof policy.forced === 'string') state.forced = policy.forced
133
220
 
134
- // 飞轮恢复:重放 rewards.jsonl 重建 UCB/LinUCB/k-NN/EXP3
221
+ // 飞轮恢复:重放 rewards.jsonl 重建 UCB/LinUCB/k-NN/EXP3;
222
+ // feedback-settle 行在基础奖励重放后按 turn 精确修正
135
223
  const rewards = store.loadJsonl('rewards.jsonl')
224
+ const byTurn = new Map()
225
+ for (const rec of rewards) {
226
+ if (rec.type === 'feedback-settle') continue
227
+ if (typeof rec.reward === 'number' && Array.isArray(rec.f)) {
228
+ applyReward(state, rec)
229
+ byTurn.set(rec.turn, rec)
230
+ }
231
+ }
136
232
  for (const rec of rewards) {
137
- if (typeof rec.reward === 'number' && Array.isArray(rec.f)) applyReward(state, rec)
233
+ if (rec.type !== 'feedback-settle') continue
234
+ const baseRec = byTurn.get(rec.turn)
235
+ if (baseRec) correctReward(state, baseRec, rec.newReward)
138
236
  }
237
+ state.recByTurn = byTurn
139
238
  state.decisions = store.loadJsonl('decisions.jsonl').slice(-60)
140
239
  state.seq = state.decisions.length ? state.decisions[state.decisions.length - 1].seq : 0
141
240
 
142
241
  const log = (msg) => console.log('[agentic-router]', msg)
143
242
  if (rewards.length) log('replayed ' + rewards.length + ' rewards; ucb.total=' + state.ucb.total)
144
243
 
244
+ let mfWarned = false
245
+ // 反馈结算:对已过结算窗口的回合查询 messageFeedback,按人类 👍/👎 修正奖励
246
+ async function settleFeedback() {
247
+ if (!mf || typeof mf.list !== 'function') {
248
+ if (!mfWarned) { log('messageFeedback service absent — 反馈奖励停用'); mfWarned = true }
249
+ return
250
+ }
251
+ const tNow = now()
252
+ if (tNow == null) return
253
+ const remaining = []
254
+ for (const p of state.pendingFeedback) {
255
+ if (tNow - p.t0 < settleDelayMs) { remaining.push(p); continue }
256
+ try {
257
+ const res = await mf.list({ sessionId: p.sessionId })
258
+ let neg = 0
259
+ let pos = 0
260
+ if (res && res.ok === true && res.value && Array.isArray(res.value.items)) {
261
+ for (const it of res.value.items) {
262
+ if (typeof it.createdAt !== 'number' || it.createdAt < p.t0) continue
263
+ if (it.rating === 'negative') neg += 1
264
+ else if (it.rating === 'positive') pos += 1
265
+ }
266
+ }
267
+ const newReward = Math.max(0, Math.min(1, Math.round((p.appliedReward - 0.5 * neg + 0.1 * pos) * 100) / 100))
268
+ if (newReward !== p.appliedReward) {
269
+ const baseRec = state.recByTurn.get(p.key)
270
+ if (baseRec) correctReward(state, baseRec, newReward)
271
+ store.appendJsonl('rewards.jsonl', {
272
+ type: 'feedback-settle', turn: p.key, sessionId: p.sessionId,
273
+ negative: neg, positive: pos, applied: p.appliedReward, newReward,
274
+ })
275
+ log('feedback settle ' + p.key + ': ' + p.appliedReward + ' -> ' + newReward + ' (neg=' + neg + ' pos=' + pos + ')')
276
+ }
277
+ } catch (e) {
278
+ log('feedback settle failed: ' + String(e))
279
+ }
280
+ }
281
+ state.pendingFeedback = remaining
282
+ }
283
+
284
+ if (timer && typeof timer.interval === 'function') {
285
+ timer.interval(() => { settleFeedback().catch((e) => log('settle sweep failed: ' + String(e))) }, SWEEP_MS)
286
+ }
287
+
288
+ // ---- 决策流水线(inbox 与 request 兜底共用) ----
289
+ // 返回 { key, td, record, target };已决策过本回合则返回 null。
290
+ // 档位表异步加载;决策前等待就绪(tiers-not-ready 会拿不到 target)。
291
+ async function decideAndRecord(payload, text, current, provider, source) {
292
+ if (!state.modelTiers) {
293
+ try { await tiersReady } catch (e) { /* keep null */ }
294
+ }
295
+ const key = fullKey(payload)
296
+ if (state.turnDecision[key]) return null
297
+ const cls = classify(text)
298
+ const f = featuresOf(cls, text)
299
+ const recs = {
300
+ rule: recommendRule(cls),
301
+ ucb: recommendUcb(cls, state.ucb),
302
+ lin: recommendLin(cls, f, state.lin),
303
+ knn: recommendKnn(cls, f, state.knn),
304
+ }
305
+ if (state.forced) recs.rule = { tier: 'forced', reason: 'forced', conf: 1 }
306
+ const exp3 = {
307
+ ...state.exp3,
308
+ linGraduated: state.lin.total >= LIN_GRADUATION,
309
+ knnGraduated: state.knn.pool.length >= KNN_GRADUATION,
310
+ }
311
+ const meta = metaPick(recs, exp3)
312
+ const chosenRec = meta.byName[meta.chosen] || recs.rule
313
+ const resolved = state.forced
314
+ ? { target: state.forced, used: 'forced', note: 'forced' }
315
+ : resolveTier(chosenRec.tier, provider, state.modelTiers)
316
+ const target = resolved.target
317
+ state.seq += 1
318
+ const td = {
319
+ clsType: cls.type, f, recs, meta, chosen: meta.chosen,
320
+ finalized: false, t0: now(), toolErrors: 0, retries: 0,
321
+ currentModel: current, provider, costWeight, costCap,
322
+ estInputTokens: Math.ceil(text.length / 1.5),
323
+ target, applied: false, needsApply: false, actualModel: current,
324
+ }
325
+ const record = {
326
+ seq: state.seq,
327
+ turn: key,
328
+ mode: state.mode,
329
+ taskType: cls.type,
330
+ complexity: cls.complexity,
331
+ inputPreview: text.slice(0, 100),
332
+ currentModel: current,
333
+ recs: {
334
+ rule: recs.rule.tier + '(' + recs.rule.conf + ')',
335
+ ucb: recs.ucb.tier + '(' + recs.ucb.conf + ')',
336
+ lin: recs.lin.tier + '(' + recs.lin.conf + ')',
337
+ knn: recs.knn.tier + '(' + recs.knn.conf + ')',
338
+ },
339
+ metaChosen: meta.chosen,
340
+ metaProbs: meta.ps,
341
+ metaPool: meta.pool,
342
+ resolvedTier: resolved.used + (resolved.note ? ' [' + resolved.note + ']' : ''),
343
+ targetModel: state.mode === 'active' ? target : null,
344
+ applied: false,
345
+ appliedAt: source,
346
+ swapped: false,
347
+ }
348
+ td.rec = record
349
+ state.turnDecision[key] = td
350
+ state.openTurns.set(agentKey(payload), key)
351
+ return { key, td, record, target }
352
+ }
353
+
354
+ // 决策记录落盘(在 applyDecision 之后调用,保证 applied/swapped 已是最终值)
355
+ function commitRecord(record) {
356
+ store.appendJsonl('decisions.jsonl', record)
357
+ state.decisions.push(record)
358
+ if (state.decisions.length > 200) state.decisions.shift()
359
+ }
360
+
361
+ // 应用切换:active 模式且目标与当前不同 → 追加 request/header。
362
+ // 返回是否真实追加成功。
363
+ function applyDecision(session, key, td, record, target) {
364
+ if (state.mode !== 'active' || !target) return false
365
+ const base = td.actualModel || td.currentModel
366
+ if (base && target === base) return false
367
+ const ok = session ? appendRouterHeader(session, target) : false
368
+ td.applied = ok
369
+ if (record) { record.applied = ok; record.swapped = ok }
370
+ if (ok) {
371
+ state.headerAppends++
372
+ log('SWAP -> ' + target + ' | ' + td.chosen + ' | ' + (td.recs && td.recs[td.chosen] ? td.recs[td.chosen].reason : ''))
373
+ }
374
+ return ok
375
+ }
376
+
145
377
  // ---- 事件挂载 ----
378
+ // 回合开始(prompt 组装之前):捕获用户消息并决策,active 模式立即应用切换。
379
+ // 这是主路径——零延迟,回合第一步就按目标模型执行。
380
+ ctx.on('agent/inbox/claimed', async (payload) => {
381
+ try {
382
+ const session = sessionOf(payload)
383
+ const text = extractText(payload && payload.message)
384
+ if (!text) return
385
+ const current = session ? currentModel(session) : null
386
+ const provider = session ? currentProvider(session) : ''
387
+ const r = await decideAndRecord(payload, text, current, provider, 'inbox')
388
+ if (!r) return
389
+ applyDecision(session, r.key, r.td, r.record, r.target)
390
+ commitRecord(r.record)
391
+ log('decision #' + state.seq + ' type=' + r.td.clsType + ' c=' + r.td.f[1] +
392
+ ' meta=' + r.td.chosen + ' tier=' + r.record.resolvedTier +
393
+ ' applied=' + r.td.applied + ' target=' + (r.target || '-'))
394
+ } catch (e) {
395
+ log('inbox decide failed: ' + String(e))
396
+ }
397
+ })
398
+
146
399
  ctx.on('agent/pre-step', (payload, next) => {
147
400
  try {
148
401
  const text = textOfMessages(payload && payload.messages)
@@ -153,76 +406,35 @@ export function apply(ctx, config = {}) {
153
406
  return next()
154
407
  })
155
408
 
409
+ // 只读观测 + 兜底决策。观测:记录每步真实出网模型(供 stats 验证切换生效)。
410
+ // 兜底:inbox 未触发(插件中途热加载)时,在首个请求用 pending 文本决策,
411
+ // 但不再替换配置——改由 llm/stream 在循环 header 落盘后补应用。
156
412
  ctx.on('agent/request', async (payload, next) => {
157
413
  const cfg = await next()
158
414
  if (!cfg || typeof cfg !== 'object') return cfg
159
415
  try {
416
+ if (!timer || typeof timer.interval !== 'function') settleFeedback().catch(() => {})
160
417
  const key = fullKey(payload)
161
- if (state.turnDecision[key]) return cfg
418
+ const model = typeof cfg.model === 'string' ? cfg.model : null
419
+ const td = state.turnDecision[key]
420
+ if (td) {
421
+ if (model) {
422
+ td.actualModel = model
423
+ state.lastActual = { model, turn: payload.turn, step: payload.step, key }
424
+ }
425
+ return cfg
426
+ }
162
427
  const pkey = agentKey(payload) + ':' + payload.turn
163
428
  const text = state.pending.get(pkey) || ''
164
429
  state.pending.delete(pkey)
165
- const cls = classify(text)
166
- const f = featuresOf(cls, text)
167
- const current = typeof cfg.model === 'string' ? cfg.model : '(unknown)'
168
430
  const provider = typeof cfg.provider === 'string' ? cfg.provider : ''
169
- const recs = {
170
- rule: recommendRule(cls),
171
- ucb: recommendUcb(cls, state.ucb),
172
- lin: recommendLin(cls, f, state.lin),
173
- knn: recommendKnn(cls, f, state.knn),
174
- }
175
- if (state.forced) recs.rule = { tier: 'forced', reason: 'forced', conf: 1 }
176
- const exp3 = {
177
- ...state.exp3,
178
- linGraduated: state.lin.total >= LIN_GRADUATION,
179
- knnGraduated: state.knn.pool.length >= KNN_GRADUATION,
431
+ const r = await decideAndRecord(payload, text, model, provider, 'request')
432
+ if (r) {
433
+ r.td.needsApply = state.mode === 'active' && r.target && r.target !== r.td.currentModel
434
+ commitRecord(r.record)
435
+ if (r.td.needsApply) log('decision #' + state.seq + ' -> apply deferred to llm/stream')
436
+ else log('decision #' + state.seq + ' type=' + r.td.clsType + ' meta=' + r.td.chosen + ' tier=' + r.record.resolvedTier)
180
437
  }
181
- const meta = metaPick(recs, exp3)
182
- const chosenRec = meta.byName[meta.chosen] || recs.rule
183
- const resolved = state.forced
184
- ? { target: state.forced, used: 'forced', note: 'forced' }
185
- : resolveTier(chosenRec.tier, provider, state.modelTiers)
186
- const target = resolved.target
187
- state.seq += 1
188
- const record = {
189
- seq: state.seq,
190
- turn: key,
191
- mode: state.mode,
192
- taskType: cls.type,
193
- complexity: cls.complexity,
194
- inputPreview: text.slice(0, 100),
195
- currentModel: current,
196
- recs: {
197
- rule: recs.rule.tier + '(' + recs.rule.conf + ')',
198
- ucb: recs.ucb.tier + '(' + recs.ucb.conf + ')',
199
- lin: recs.lin.tier + '(' + recs.lin.conf + ')',
200
- knn: recs.knn.tier + '(' + recs.knn.conf + ')',
201
- },
202
- metaChosen: meta.chosen,
203
- metaProbs: meta.ps,
204
- metaPool: meta.pool,
205
- resolvedTier: resolved.used + (resolved.note ? ' [' + resolved.note + ']' : ''),
206
- targetModel: state.mode === 'active' ? target : null,
207
- swapped: false,
208
- }
209
- state.turnDecision[key] = {
210
- clsType: cls.type, f, recs, meta, chosen: meta.chosen,
211
- finalized: false, t0: now(), toolErrors: 0, retries: 0,
212
- }
213
- state.openTurns.set(agentKey(payload), key)
214
- store.appendJsonl('decisions.jsonl', record)
215
- if (state.mode === 'active' && target && target !== current) {
216
- record.swapped = true
217
- state.decisions.push(record)
218
- if (state.decisions.length > 200) state.decisions.shift()
219
- log('SWAP -> ' + target + ' | ' + meta.chosen + ' | ' + chosenRec.reason + ' | ' + resolved.note)
220
- return { ...cfg, model: target }
221
- }
222
- state.decisions.push(record)
223
- if (state.decisions.length > 200) state.decisions.shift()
224
- log('decision #' + state.seq + ' type=' + cls.type + ' c=' + cls.complexity +
225
- ' meta=' + meta.chosen + ' tier=' + resolved.used + ' recs=' + JSON.stringify(record.recs))
226
438
  } catch (e) {
227
439
  log('decide failed: ' + String(e))
228
440
  }
@@ -242,6 +454,62 @@ export function apply(ctx, config = {}) {
242
454
  return next()
243
455
  })
244
456
 
457
+ // 成本信号:透传每次流式模型调用,累加 usage chunk 到当前 open turn;
458
+ // 无 usage 的 provider 用文本长度估算(estimated=true)。
459
+ // 同时:request 路径的兜底决策在这里补应用切换(循环自身 header 已落盘,无竞态)。
460
+ ctx.on('llm/stream', function (options, next) {
461
+ return (async function* () {
462
+ let usage = null
463
+ let outTextLen = 0
464
+ try {
465
+ for await (const chunk of next()) {
466
+ if (chunk && chunk.type === 'usage' && chunk.usage) usage = chunk.usage
467
+ if (chunk && chunk.type === 'text-delta' && typeof chunk.text === 'string') outTextLen += chunk.text.length
468
+ yield chunk
469
+ }
470
+ } finally {
471
+ try {
472
+ const sid = options && (options.sessionId || (options.session && options.session.id))
473
+ const key = sid ? state.openTurns.get(sid) : null
474
+ const td = key ? state.turnDecision[key] : null
475
+ if (td && !td.finalized) {
476
+ // 兜底应用:request 路径决策未应用时,在 header 落盘后补切换
477
+ if (td.needsApply && !td.applied) {
478
+ let session = null
479
+ try {
480
+ const sessions = ctx.get('sessions')
481
+ if (sessions && typeof sessions.get === 'function') session = sessions.get(sid) || null
482
+ } catch (e) { session = null }
483
+ if (session) applyDecision(session, key, td, td.rec, td.target)
484
+ }
485
+ const u = td.usage || { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, reasoningTokens: 0, estimated: false }
486
+ const model = (options && typeof options.model === 'string' && options.model) || td.currentModel || ''
487
+ const tier = tierOf(model)
488
+ const provider = (options && typeof options.provider === 'string' && options.provider) || td.provider || ''
489
+ if (usage && typeof usage.inputTokens === 'number') {
490
+ u.inputTokens += usage.inputTokens
491
+ u.outputTokens += usage.outputTokens || 0
492
+ u.cacheReadTokens += usage.cacheReadTokens || 0
493
+ u.reasoningTokens += usage.reasoningTokens || 0
494
+ u.estimated = false
495
+ } else {
496
+ u.inputTokens += td.estInputTokens || 0
497
+ u.outputTokens += Math.round(outTextLen / 2)
498
+ u.estimated = true
499
+ }
500
+ td.usage = u
501
+ const rp = resolvePrice(provider, model, tier, prices)
502
+ td.costYuan = turnCostYuan(u, provider, model, tier, prices)
503
+ td.pricingSource = rp.source
504
+ log('usage on ' + key + ': in=' + u.inputTokens + ' out=' + u.outputTokens + (u.estimated ? ' (估)' : '') + ' cost=¥' + td.costYuan.toFixed(4) + ' [' + rp.source + ']')
505
+ }
506
+ } catch (e) {
507
+ log('usage hook failed: ' + String(e))
508
+ }
509
+ }
510
+ })()
511
+ })
512
+
245
513
  ctx.on('tools/result', (exec, result) => {
246
514
  try {
247
515
  if (!isErrorResult(result)) return
@@ -283,6 +551,15 @@ export function apply(ctx, config = {}) {
283
551
  }
284
552
  applyReward(state, rec)
285
553
  store.appendJsonl('rewards.jsonl', rec)
554
+ state.recByTurn.set(key, rec)
555
+ if (state.recByTurn.size > 500) {
556
+ const first = state.recByTurn.keys().next().value
557
+ if (first !== undefined) state.recByTurn.delete(first)
558
+ }
559
+ if (mf && typeof mf.list === 'function' && td.t0 != null) {
560
+ state.pendingFeedback.push({ key, sessionId: agentKey(payload), t0: td.t0, appliedReward: rec.reward })
561
+ if (state.pendingFeedback.length > 200) state.pendingFeedback.shift()
562
+ }
286
563
  const ak = agentKey(payload)
287
564
  if (state.openTurns.get(ak) === key) state.openTurns.delete(ak)
288
565
  log('reward ' + rec.reward + ' ' + tag + ' on ' + key + ' breakdown=' + JSON.stringify(r.breakdown))
@@ -294,10 +571,12 @@ export function apply(ctx, config = {}) {
294
571
  ctx.on('agent/turn-stopping', (payload) => finalize(payload, 'clean-close'))
295
572
  ctx.on('agent/error', (payload) => finalize(payload, 'error'))
296
573
 
297
- refreshModelTiers(llm).then((t) => {
574
+ // 档位表异步加载;decideAndRecord 在决策前 await 此 promise
575
+ const tiersReady = refreshModelTiers(llm)
576
+ tiersReady.then((t) => {
298
577
  state.modelTiers = t
299
578
  log('model tiers: ' + JSON.stringify(t))
300
- })
579
+ }).catch((e) => log('model tiers refresh failed: ' + String(e)))
301
580
 
302
581
  // ---- 工具 ----
303
582
  const OUT = (label) => ({
@@ -315,7 +594,7 @@ export function apply(ctx, config = {}) {
315
594
 
316
595
  register({
317
596
  name: 'agentic_router_stats',
318
- description: '查看 agentic router(UCB/LinUCB/k-NN 三策略 + EXP3 元选择器 + 质量代理奖励)的决策记录、策略权重与飞轮学习状态。',
597
+ description: '查看 agentic router(UCB/LinUCB/k-NN 三策略 + EXP3 元选择器 + 质量代理奖励)的决策记录、策略权重、真实切换与飞轮学习状态。',
319
598
  parameters: { type: 'object', properties: {} },
320
599
  output: OUT('agentic router 状态'),
321
600
  isConcurrencySafe: () => true,
@@ -325,14 +604,21 @@ export function apply(ctx, config = {}) {
325
604
  forcedModel: state.forced,
326
605
  totalDecisions: state.seq,
327
606
  modelTiers: state.modelTiers,
607
+ prices,
608
+ mechanism: 'request/header append (agent/inbox/claimed)',
609
+ lastActual: state.lastActual,
610
+ headerAppends: state.headerAppends,
328
611
  flywheel: {
329
612
  ucb: state.ucb,
330
613
  lin: { total: state.lin.total, tierN: state.lin.tierN, tierW: state.lin.tierW },
331
614
  knnPoolSize: state.knn.pool.length,
332
615
  exp3: state.exp3,
616
+ pendingFeedback: state.pendingFeedback.length,
617
+ prices,
333
618
  recentRewards: store.loadJsonl('rewards.jsonl').slice(-20),
334
619
  },
335
620
  recent: state.decisions.slice(-20),
621
+ note: '切换真实性验证:headerAppends 为本进程追加的 request/header 次数;lastActual 为最近一步真实出网模型;decisions[].applied=true 且 swapped=true 即真实切换成功',
336
622
  }
337
623
  },
338
624
  })
@@ -348,7 +634,7 @@ export function apply(ctx, config = {}) {
348
634
  isConcurrencySafe: () => true,
349
635
  async execute(args) {
350
636
  state.mode = args.mode
351
- store.savePolicy({ mode: state.mode, forced: state.forced })
637
+ store.savePolicy({ mode: state.mode, forced: state.forced, prices })
352
638
  return { mode: state.mode }
353
639
  },
354
640
  })
@@ -367,17 +653,62 @@ export function apply(ctx, config = {}) {
367
653
  async execute(args) {
368
654
  if (args && args.clear) {
369
655
  state.forced = null
370
- store.savePolicy({ mode: state.mode, forced: null })
656
+ store.savePolicy({ mode: state.mode, forced: null, prices })
371
657
  return { forced: null }
372
658
  }
373
659
  if (args && typeof args.model === 'string') {
374
660
  state.forced = args.model
375
- store.savePolicy({ mode: state.mode, forced: args.model })
661
+ store.savePolicy({ mode: state.mode, forced: args.model, prices })
662
+ // 立即对存活会话应用(下一请求生效)
663
+ if (state.mode === 'active') {
664
+ try {
665
+ const agents = ctx.get('agents')
666
+ if (agents && typeof agents.list === 'function') {
667
+ for (const agent of agents.list()) {
668
+ try {
669
+ const session = sessionOf({ agent })
670
+ if (session && currentModel(session) !== args.model) {
671
+ const ok = appendRouterHeader(session, args.model)
672
+ if (ok) state.headerAppends++
673
+ }
674
+ } catch (e) { /* per-agent best effort */ }
675
+ }
676
+ }
677
+ } catch (e) { /* best effort */ }
678
+ }
376
679
  return { forced: args.model }
377
680
  }
378
681
  return { forced: state.forced, error: '需要 model 或 clear=true' }
379
682
  },
380
683
  })
684
+ register({
685
+ name: 'agentic_router_set_prices',
686
+ description: '配置/查询某 provider 某模型(或档位)的价格(元/百万 tokens,{in, out})。不带 priceIn/priceOut 时返回当前价格表。接入新模型后用它补价格,未知价格按保守值估算并标记 unknown。',
687
+ parameters: {
688
+ type: 'object',
689
+ properties: {
690
+ provider: { type: 'string', description: 'provider id,如 openai、anthropic、deepseek-official' },
691
+ model: { type: 'string', description: '可选:精确模型 id;省略则设置该 provider 的档位价' },
692
+ tier: { type: 'string', enum: ['fast', 'mid', 'strong'], description: '档位(未给 model 时必填)' },
693
+ priceIn: { type: 'number', description: '输入价(元/百万 tokens),负数视为查询模式' },
694
+ priceOut: { type: 'number', description: '输出价(元/百万 tokens),负数视为查询模式' },
695
+ },
696
+ required: ['provider'],
697
+ },
698
+ output: OUT('agentic router 价格表'),
699
+ isConcurrencySafe: () => true,
700
+ async execute(args) {
701
+ if (typeof args.priceIn !== 'number' || typeof args.priceOut !== 'number' || args.priceIn < 0 || args.priceOut < 0) {
702
+ return { prices, note: '查看模式:传 priceIn/priceOut 与 model 或 tier 进行设置' }
703
+ }
704
+ const byProvider = prices[args.provider] || (prices[args.provider] = {})
705
+ if (args.model) byProvider[args.model] = { in: args.priceIn, out: args.priceOut }
706
+ else if (args.tier) byProvider[args.tier] = { in: args.priceIn, out: args.priceOut }
707
+ else return { error: '需要 model 或 tier', prices }
708
+ store.savePolicy({ mode: state.mode, forced: state.forced, prices })
709
+ return { prices, updated: args.model || (args.provider + '/' + args.tier) }
710
+ },
711
+ })
381
712
  register({
382
713
  name: 'agentic_router_reset',
383
714
  description: '清空飞轮学习状态(决策与奖励历史),保留当前模式;用于重新开始学习或清理测试数据。',
@@ -396,6 +727,8 @@ export function apply(ctx, config = {}) {
396
727
  state.exp3 = { total: 0, weights: { ...DEFAULT_WEIGHTS } }
397
728
  state.seq = 0
398
729
  state.decisions = []
730
+ state.headerAppends = 0
731
+ state.lastActual = null
399
732
  return { reset: true, home: store.home }
400
733
  },
401
734
  })
package/dsh/strategies.js CHANGED
@@ -105,28 +105,76 @@ export function recommendKnn(cls, f, knnState) {
105
105
  return { tier: best.tier, reason: 'knn k=' + k + ' neighbors score=' + Math.round(best.score * 100) / 100, conf: Math.min(0.8, pool.length / 100) }
106
106
  }
107
107
 
108
- // 质量代理奖励:干净收尾 1 分起,扣工具失败(0.2/次,封顶 0.6)、模型重试(0.2/次,封顶 0.4)、延迟(>90s −0.3, >30s −0.1)
108
+ // ---- 成本信号(token 计费) ----
109
+ // 价格表(元/百万 tokens)三级解析:
110
+ // 1) prices[provider][model] 精确模型价(最高优先)
111
+ // 2) prices[provider][tier] 该 provider 的档位价
112
+ // 3) prices['*'][tier] 通用档位兜底(未知 provider 不套用 DeepSeek 价)
113
+ // 4) CONSERVATIVE 保守值 + 标记 unknown,提醒用户补价格
114
+ export const DEFAULT_PRICES = {
115
+ 'deepseek-official': {
116
+ 'deepseek-v4-flash': { in: 0.2, out: 2 },
117
+ 'deepseek-v4-pro': { in: 1, out: 12 },
118
+ },
119
+ '*': {
120
+ fast: { in: 1, out: 4 },
121
+ mid: { in: 2, out: 8 },
122
+ strong: { in: 3, out: 16 },
123
+ },
124
+ }
125
+
126
+ const CONSERVATIVE = { in: 3, out: 16 }
127
+
128
+ // 解析价格并返回来源(透明标注:价格到底从哪来的)
129
+ export function resolvePrice(provider, model, tier, prices) {
130
+ const table = prices || DEFAULT_PRICES
131
+ const byProvider = table[provider]
132
+ if (byProvider && byProvider[model]) return { price: byProvider[model], source: 'provider-model' }
133
+ if (byProvider && byProvider[tier]) return { price: byProvider[tier], source: 'provider-tier' }
134
+ const generic = table['*']
135
+ if (generic && generic[tier]) return { price: generic[tier], source: 'generic-tier' }
136
+ return { price: CONSERVATIVE, source: 'unknown-conservative' }
137
+ }
138
+
139
+ // 单回合成本(元):缓存读计入输入,推理 token 计入输出
140
+ export function turnCostYuan(usage, provider, model, tier, prices) {
141
+ if (!usage) return 0
142
+ const { price } = resolvePrice(provider, model, tier, prices)
143
+ const input = (usage.inputTokens || 0) + (usage.cacheReadTokens || 0)
144
+ const output = (usage.outputTokens || 0) + (usage.reasoningTokens || 0)
145
+ return (price.in * input + price.out * output) / 1e6
146
+ }
147
+
148
+ // 质量代理奖励:干净收尾 1 分起,扣工具失败(0.2/次,封顶 0.6)、模型重试(0.2/次,封顶 0.4)、
149
+ // 延迟(>90s −0.3, >30s −0.1)、成本(min(cap, weight × 元),默认 weight=20 即每 0.01 元 −0.2)
109
150
  export function rewardOf(td, tag, dtMs) {
110
151
  if (tag !== 'clean-close') {
111
- return { reward: 0, breakdown: { base: 0, toolErrors: td.toolErrors || 0, retries: td.retries || 0, dtMs } }
152
+ return { reward: 0, breakdown: { base: 0, toolErrors: td.toolErrors || 0, retries: td.retries || 0, dtMs, costYuan: td.costYuan || 0, costPenalty: 0, pricingSource: td.pricingSource || 'n/a' } }
112
153
  }
113
154
  let reward = 1
114
155
  const toolPenalty = Math.min(0.6, 0.2 * (td.toolErrors || 0))
115
156
  const retryPenalty = Math.min(0.4, 0.2 * (td.retries || 0))
157
+ const costCap = typeof td.costCap === 'number' ? td.costCap : 0.5
158
+ const costWeight = typeof td.costWeight === 'number' ? td.costWeight : 20
159
+ const costYuan = typeof td.costYuan === 'number' ? td.costYuan : 0
160
+ const costPenalty = Math.min(costCap, costWeight * costYuan)
116
161
  reward -= toolPenalty
117
162
  reward -= retryPenalty
163
+ reward -= costPenalty
118
164
  if (dtMs != null) {
119
165
  if (dtMs > 90000) reward -= 0.3
120
166
  else if (dtMs > 30000) reward -= 0.1
121
167
  }
122
168
  return {
123
169
  reward: Math.max(0, Math.round(reward * 100) / 100),
124
- breakdown: { base: 1, toolErrors: td.toolErrors || 0, toolPenalty, retries: td.retries || 0, retryPenalty, dtMs },
170
+ breakdown: { base: 1, toolErrors: td.toolErrors || 0, toolPenalty, retries: td.retries || 0, retryPenalty, costYuan, costPenalty, dtMs, pricingSource: td.pricingSource || 'n/a' },
125
171
  }
126
172
  }
127
173
 
128
174
  // 把一条奖励记录应用到飞轮(UCB/LinUCB/k-NN/EXP3),replay 与实时更新共用
129
175
  export function applyReward(state, rec) {
176
+ // 记录本次线性更新的学习率,供反馈结算修正精确回退
177
+ rec.linLr = 1 / (1 + (state.lin.tierN[rec.linTier] || 0))
130
178
  const armKey = rec.clsType + ':' + rec.ucbTier
131
179
  const arm = state.ucb.arms[armKey] || (state.ucb.arms[armKey] = { n: 0, mean: 0 })
132
180
  arm.n += 1
@@ -151,3 +199,30 @@ export function applyReward(state, rec) {
151
199
  }
152
200
  state.exp3.total += 1
153
201
  }
202
+
203
+ // 反馈结算修正:把已应用奖励改为 newReward(人类在反馈窗口内点了 👍/👎 之后)
204
+ // UCB 均值修正(n 不变);LinUCB 用原始学习率做精确梯度回退;k-NN 追加修正经验;
205
+ // EXP3 按乘性更新做精确逆修正。
206
+ export function correctReward(state, rec, newReward) {
207
+ const delta = newReward - rec.reward
208
+ if (!delta) return { delta: 0 }
209
+
210
+ const arm = state.ucb.arms[rec.clsType + ':' + rec.ucbTier]
211
+ if (arm && arm.n > 0) arm.mean = arm.mean + delta / arm.n
212
+
213
+ const lw = state.lin.tierW[rec.linTier]
214
+ if (lw && Array.isArray(rec.f)) {
215
+ const lr = typeof rec.linLr === 'number' ? rec.linLr : 1 / (1 + (state.lin.tierN[rec.linTier] || 0))
216
+ for (let i = 0; i < rec.f.length; i++) lw[i] = (lw[i] || 0) + lr * delta * rec.f[i]
217
+ }
218
+
219
+ if (state.knn && Array.isArray(rec.f)) state.knn.pool.push({ f: rec.f, tier: rec.ucbTier, r: newReward })
220
+
221
+ if (rec.pChosen > 0 && rec.pool && rec.pool.length > 0) {
222
+ state.exp3.weights[rec.chosen] = (state.exp3.weights[rec.chosen] || 0) *
223
+ Math.exp(0.3 * delta / (rec.pChosen * rec.pool.length))
224
+ }
225
+
226
+ rec.reward = newReward
227
+ return { delta }
228
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-agentic-router",
3
- "version": "1.0.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.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 and REAL model switching via the session request-header precedence chain (v1.4.0)",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "author": "lhl",
@@ -33,7 +33,7 @@
33
33
  "LICENSE"
34
34
  ],
35
35
  "scripts": {
36
- "test": "node --test test/"
36
+ "test": "node --test"
37
37
  },
38
38
  "dsh": {
39
39
  "bundle": {