dsh-agentic-router 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 lhl
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.en.md ADDED
@@ -0,0 +1,76 @@
1
+ # dsh-agentic-router
2
+
3
+ > 🌐 Language / 语言切换: **[English](./README.en.md)** · **[简体中文](./README.md)**
4
+
5
+ DeepSeek Harness (DSH) plugin: a **learning agentic router with a data flywheel**.
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.
8
+
9
+ ## Architecture
10
+
11
+ ```
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)
15
+ ```
16
+
17
+ | Expert | Algorithm | Status |
18
+ |---|---|---|
19
+ | rule | deterministic thresholds (complexity ≤0.3→fast, ≥0.7→strong, else mid) | cold-start prior, always in pool |
20
+ | UCB | cluster×tier multi-armed bandit; unexplored arms get ∞, ties rotate | active from day zero |
21
+ | LinUCB | linear feature scoring + exploration bonus, gradient updates | shadow; graduates at 50 samples |
22
+ | k-NN | k=5 nearest-neighbor aggregation over the experience pool | shadow; graduates at 30 experiences |
23
+ | EXP3 | meta-selector, importance-weighted updates | always on; cold-start biased to rule (weight 2.5) |
24
+
25
+ Design rationale and roadmap: [docs/DESIGN.md](./docs/DESIGN.md). The implementation was validated against a live runtime prototype first; eight defects found there are fixed and pinned as regression tests.
26
+
27
+ ## Install
28
+
29
+ ```sh
30
+ dsh plugin --profile web add dsh-agentic-router
31
+ ```
32
+
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.
34
+
35
+ ## Tools
36
+
37
+ | Tool | Purpose |
38
+ |---|---|
39
+ | `agentic_router_stats` | decision log, four-expert recommendations, EXP3 weights, flywheel state |
40
+ | `agentic_router_set_mode` | shadow (default) / active (real swaps) / off (bypass) |
41
+ | `agentic_router_force` | force a model id (active mode only); clear=true to release |
42
+ | `agentic_router_reset` | wipe flywheel learning state, keep mode |
43
+
44
+ ## Data flywheel (persistence)
45
+
46
+ Directory: `${DSH_HOME:-~/.dsh}/storages/dsh-agentic-router/` (override with `AGENTIC_ROUTER_HOME`)
47
+
48
+ - `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**
50
+ - `policy.json` — mode and forced model (atomic write)
51
+
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
55
+
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
59
+
60
+ ## Development
61
+
62
+ ```sh
63
+ npm test # node --test, zero dependencies
64
+ ```
65
+
66
+ ## Publish
67
+
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
+ ```
73
+
74
+ ## License
75
+
76
+ MIT
package/README.md ADDED
@@ -0,0 +1,83 @@
1
+ # dsh-agentic-router
2
+
3
+ > 🌐 语言切换 / Language: **[简体中文](./README.md)** · **[English](./README.en.md)**
4
+
5
+ DeepSeek Harness(DSH)插件:**学习型智能路由(agentic router)+ 数据飞轮**。
6
+
7
+ 监听每次模型请求,按任务类型与复杂度把请求路由到最合适的模型档位;四个专家并行推荐、EXP3 元选择器决定听谁的;每回合按**质量代理信号**(工具失败、模型重试、延迟)计算奖励并回传,路由策略随使用越学越准。全部决策与奖励落盘,可审计、可重放。
8
+
9
+ ## 架构
10
+
11
+ ```
12
+ 用户输入 → 分类(规则,<1ms) → 四专家推荐 → EXP3 元选择 → agent/request 切换模型
13
+ ↑ │
14
+ UCB/LinUCB/k-NN/EXP3 更新 ← 质量代理奖励 ← 回合收尾(工具失败/重试/延迟)
15
+ ```
16
+
17
+ | 专家 | 算法 | 状态 |
18
+ |---|---|---|
19
+ | rule | 确定性阈值表(复杂度 ≤0.3→fast,≥0.7→strong,其余 mid) | 冷启动先验,常驻兜底 |
20
+ | UCB | 簇×档多臂老虎机,未探索臂上界∞、平局轮转 | 零样本即活跃 |
21
+ | LinUCB | 特征线性打分 + 探索项,梯度更新 | 影子,50 样本毕业 |
22
+ | k-NN | 经验池最近邻(k=5)聚合 | 影子,30 经验毕业 |
23
+ | EXP3 | 元选择器,重要性加权更新 | 常驻,冷启动偏 rule(权重 2.5) |
24
+
25
+ 设计细节与路线图见 [docs/DESIGN.md](./docs/DESIGN.md)。本实现源自真实运行时上的动态原型验证(8 个实测缺陷已修复并固化为回归测试)。
26
+
27
+ ## 安装
28
+
29
+ ```sh
30
+ dsh plugin --profile web add dsh-agentic-router
31
+ ```
32
+
33
+ > `--profile` 必填。安装后重启会话(或 profile),工具 schema 才会进入 prompt 组装。默认 **shadow 模式**(只记录与学习,不切换模型),确认策略表现后再切 active。
34
+
35
+ ## 工具
36
+
37
+ | 工具 | 作用 |
38
+ |---|---|
39
+ | `agentic_router_stats` | 查看决策记录、四专家推荐、EXP3 权重、飞轮状态 |
40
+ | `agentic_router_set_mode` | 切换 shadow(默认)/ active(真切换)/ off(旁路) |
41
+ | `agentic_router_force` | 强制指定模型 id(active 生效),clear=true 清除 |
42
+ | `agentic_router_reset` | 清空飞轮学习状态,保留模式 |
43
+
44
+ ## 数据飞轮(落盘)
45
+
46
+ 目录:`${DSH_HOME:-~/.dsh}/storages/dsh-agentic-router/`(`AGENTIC_ROUTER_HOME` 可覆盖)
47
+
48
+ - `decisions.jsonl` — 每条决策:任务类型/复杂度/四专家推荐/元层选择/实际路由/是否切换
49
+ - `rewards.jsonl` — 每条奖励:明细(工具失败/重试/延迟)+ 特征向量,**重启后重放恢复学习状态**
50
+ - `policy.json` — 模式与强制模型(原子写)
51
+
52
+ 奖励公式:干净收尾 1 分起,工具失败 −0.2/次(封顶 0.6)、模型重试 −0.2/次(封顶 0.4)、延迟 >30s −0.1 / >90s −0.3;出错回合记 0。
53
+
54
+ ## 分类器(规则先验,<1ms)
55
+
56
+ 6 类任务(code/write/summarize/research/agentic/qa)+ 复杂度 0~1:
57
+ 类型先验分(code/agentic 0.3,research 0.2,write 0.15…)+ 长度 + 代码块 + 复杂词加分 − 简单词减分("简单/复杂"同时出现不惩罚)。
58
+
59
+ ## 已知边界
60
+
61
+ - 模型档位靠 id 关键词识别(flash→fast、pro→strong);可用模型多于一档时路由才真正切换
62
+ - 奖励信号是"代理":任务成功但质量平庸的情况(尤其模型池内质量差距大时)仍可能被记满分,正式场景建议接入 UI 反馈(messageFeedback)
63
+ - 毕业策略(LinUCB/k-NN)需要数十个回合样本才会进元层池
64
+
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
+ ```
80
+
81
+ ## License
82
+
83
+ MIT
@@ -0,0 +1,4 @@
1
+ # dsh bundle 层:挂载 dsh-agentic-router 插件(dsh/index.js,经 package.json exports)
2
+ - insert:
3
+ - id: agentic-router
4
+ name: 'dsh-agentic-router'
package/docs/DESIGN.md ADDED
@@ -0,0 +1,226 @@
1
+ # DSH Agentic Router 插件设计方案
2
+
3
+ > 目标:在 DeepSeek Harness 里做一个**学习型智能路由**插件——监听用户输入,按任务类型与复杂度把请求路由到最合适的模型 / 子 agent / 工具组合;每次路由的**决策与结果全部落盘**,形成数据飞轮,让路由判断随使用越来越准、越来越快。
4
+
5
+ ## 0. 一句话架构
6
+
7
+ ```
8
+ 用户输入 → 特征提取(快) → 路由器决策 → 改写请求(agent/request waterfall) / 派发子 agent
9
+ ↑ ↓
10
+ 策略(规则表+学习权重) ← 评分与在线学习 ← 结果回传(质量/成本/延迟落盘)
11
+ ```
12
+
13
+ 三个关键设计原则(对齐 DSH 架构文档):
14
+
15
+ 1. **只走官方扩展点**:路由动作通过 `agent/request` / `agent/pre-step` waterfall 实现;决策记录通过 SessionEventMap 扩展进入会话日志("model-visible means logged")。
16
+ 2. **决策可解释、可撤销**:每次路由都写一条带理由的日志事件;提供 `router:force` 人工覆盖命令与开关。
17
+ 3. **先影子后接管**:上线先跑 shadow mode(只记录不干预),数据够之后再接管。
18
+
19
+ ## 1. 挂载点(引用自官方文档的确切签名)
20
+
21
+ ### 1.1 模型级路由:`agent/request`(waterfall)
22
+
23
+ ```ts
24
+ 'agent/request'(this: Scoped<Agent>, payload: {
25
+ agent: Agent; turn: number; step: number; signal: AbortSignal
26
+ }, next: () => Promise<LlmCallConfig>): Promise<LlmCallConfig>
27
+ ```
28
+
29
+ > 官方语义:`await next()` 得到"机器本来会用的冻结调用配置"(首请求为 agent 选项,之后为已记录 header),**返回一个替代配置即可切换模型**;此 waterfall 不能改消息,模型可见内容必须走已记录通道。
30
+
31
+ 这是**模型路由的官方入口**:路由器在这里拿到默认 provider/model,然后返回替换后的 `LlmCallConfig`(换 provider、换 model、换 reasoning 档位、改 maxTokens)。
32
+
33
+ ### 1.2 输入监听与消息改写:`agent/pre-step`(waterfall)
34
+
35
+ ```ts
36
+ 'agent/pre-step'(this: Scoped<Agent>, payload: {
37
+ agent: Agent; messages: UserMessage[]; turn: number; step: number; signal: AbortSignal
38
+ }, next: () => Promise<PreStepDecision>): Promise<PreStepDecision>
39
+ ```
40
+
41
+ > 官方语义:拒绝一个提议 step 或**替换进入它的消息**;`next()` 保留当前消息。它是 request 派生前唯一的串行监听链。
42
+
43
+ 在这里:观察/缓存本轮输入特征、把"路由上下文"注入消息(可选)、极端情况下直接拒掉不适合本 agent 的请求。
44
+
45
+ ### 1.3 补充事件
46
+
47
+ | 事件 | 用途 |
48
+ |---|---|
49
+ | `agent/inbox/claimed`(emit) | 感知新输入被认领,触发特征预计算 |
50
+ | `agent/status` / `step/end` | 拿耗时、token 用量等结果信号 |
51
+ | `subagent/start` / `subagent/end` | 子 agent 级路由的观测点 |
52
+ | `turn/end`(durable) | 每个 turn 结束时做一次**结果评分与落盘** |
53
+
54
+ ## 2. 路由决策链路(每次 turn)
55
+
56
+ ```text
57
+ turn/start
58
+ │ 1. 特征提取(<5ms,纯启发式):
59
+ │ - 任务类型: 代码/写作/问答/检索/agentic(工具编排)/翻译/数学…
60
+ │ - 复杂度: 输入长度、是否含代码块、是否要求多步推理、历史轮次、
61
+ │ 关键词(debug/优化/重构/分析/总结…) → 0~1 分数
62
+ │ - 上下文: agent preset、cwd、挂载工具集、会话历史长度
63
+ │ - 约束: 延迟预算、成本预算、可用模型清单(来自部署配置或
64
+ │ dsh-model-deploy 的容量数据)
65
+ ▼
66
+ │ 2. 路由决策(路由表 + 学习权重)
67
+ │ 策略候选:
68
+ │ A 规则表: 任务类型×复杂度 → 候选路由集合(带成本/延迟先验)
69
+ │ B 经验检索(k-NN): 当前任务 embedding → 历史相似任务中
70
+ │ 结果最好的路由(EvoRoute 思路)
71
+ │ C 置信度低时: 小模型/LLM-judge 裁决一次(带缓存)
72
+ │ 输出: { target: model|agent|tools, reason, confidence }
73
+ ▼
74
+ │ 3. 执行路由
75
+ │ - 模型级: agent/request 返回替换的 LlmCallConfig
76
+ │ - 子agent级: 经 subagent provider 派发到对应 preset 的 agent
77
+ │ - 工具级: 按任务类型增删本 turn 的工具 schema(agent-scoped)
78
+ ▼
79
+ │ 4. 记录(durable)
80
+ │ routing/decision 事件: { taskType, complexity, features, chosen,
81
+ │ alternatives, reason, confidence, mode: shadow|active }
82
+ ▼
83
+ turn/end
84
+ │ 5. 结果评分与回传(数据飞轮)
85
+ │ routing/outcome 事件: { decisionId, success, quality, latencyMs,
86
+ │ costTokens, retryCount, userEdited }
87
+ └──→ 写入本地 flywheel 存储 → 触发在线学习
88
+ ```
89
+
90
+ ## 3. 分类器设计(复杂度 + 任务类型)
91
+
92
+ **第 1 层(默认,零延迟)**:确定性启发式 + 关键词/模式表。覆盖 90% 场景,<5ms。
93
+
94
+ - 任务类型:代码(文件/报错/函数/重构)、创作、问答、总结、翻译、agentic("帮我做/查一下/跑一下")、数据分析
95
+ - 复杂度信号:输入 token 数、代码块占比、工具调用历史、任务动词强度、是否需要多文件、会话历史长度
96
+ - 输出 0~1 复杂度分 + 类型标签(多标签)
97
+
98
+ **第 2 层(低置信度时,可关闭)**:小模型分类器(如 Flash 级)一次 prompt 分类,带结果缓存与超时(>300ms 放弃,回退第 1 层)。
99
+
100
+ **第 3 层(学习型)**:基于飞轮数据定期训练/更新——简单做法是"embedding + k-NN 经验检索"(在线可做),进阶做法是离线训练一个小型路由打分器(周级更新)。
101
+
102
+ ## 4. 路由目标三层模型
103
+
104
+ | 层级 | 动作 | 挂载点 | 适用 |
105
+ |---|---|---|---|
106
+ | L1 模型路由 | 替换 provider/model/reasoning | `agent/request` | 同能力不同成本/质量档位(Flash vs Pro) |
107
+ | L2 子 agent 路由 | 派发给不同 preset 的 agent | subagent provider + `agent/pre-step` | 研究型 vs 编码型 vs 检索型任务 |
108
+ | L3 工具/能力路由 | 增删本 turn 工具 schema | tools 注册(agent-scoped) | 按任务裁剪工具面,降 prompt 成本、提速 |
109
+
110
+ **默认路由表(冷启动先验,随飞轮学习调整)**:
111
+
112
+ | 任务类型 | 复杂度低 | 复杂度高 |
113
+ |---|---|---|
114
+ | 闲聊/简单问答 | Flash 模型 | Pro 模型 |
115
+ | 创作/翻译 | Flash | Pro |
116
+ | 代码/调试 | Flash + 代码工具 | Pro + 代码 agent 子 agent |
117
+ | agentic 编排 | 本 agent 全工具 | Pro + workflow |
118
+ | 研究/检索 | 检索子 agent | 研究子 agent |
119
+
120
+ 每个候选带 `成本(¥/1M tokens)、延迟先验(ms)、能力标签`,由配置声明,飞轮更新其**结果质量后验**。
121
+
122
+ ## 5. 数据飞轮(核心闭环)
123
+
124
+ ### 5.1 记录什么(durable,全部追加)
125
+
126
+ ```jsonc
127
+ // routing/decision(turn 开始时)
128
+ { "taskType": ["code"], "complexity": 0.72, "features": {...},
129
+ "chosen": { "kind": "model", "provider": "…", "model": "…" },
130
+ "alternatives": [...], "reason": "rule#code-high + exp-kNN@0.81",
131
+ "confidence": 0.81, "mode": "shadow" }
132
+
133
+ // routing/outcome(turn 结束时)
134
+ { "decisionId": "...", "success": true, "quality": 0.85,
135
+ "latencyMs": 8420, "costTokens": { "in": 2400, "out": 810 },
136
+ "retryCount": 0, "userEdited": false }
137
+ ```
138
+
139
+ ### 5.2 评分信号(怎么知道"路由好不好")
140
+
141
+ - **成功**:turn 正常结束、无 tool error、无重试
142
+ - **质量代理**:用户是否编辑/重发消息、是否追加追问(正向)、是否中途打断(负向)
143
+ - **效率**:端到端延迟、token 成本
144
+ - 综合分:`score = α·quality + β·(1 - latency/预算) + γ·(1 - cost/预算)`,权重可配
145
+
146
+ ### 5.3 学习机制(从易到难,方案支持三级)
147
+
148
+ 1. **在线 Bandit**(上线即用):每个"任务簇 × 候选路由"维护 UCB/ε-greedy 的收益估计;探索率随样本数衰减。简单、可解释、即时见效。
149
+ 2. **经验检索 k-NN**(EvoRoute 思路):把 (任务特征, 路由, 结果) 存为向量库,新任务检索最相似的历史经验,选历史收益最高的路由。冷启动与 1 并用。
150
+ 3. **离线重训**(周级,可选):用累积数据训练小型打分器/重排序器,替换第 2 层分类器。
151
+
152
+ ### 5.4 飞轮存储
153
+
154
+ - 放在 DSH 数据目录下的插件专属存储(`~/.dsh/storages/dsh-agentic-router/`:`decisions.jsonl` 追加日志 + `policy.json` 可写回的策略权重 + 可选向量索引文件)
155
+ - 全部为本地明文 JSON/JSONL——用户可导出、可审计、可迁移
156
+
157
+ ### 5.5 影子模式(安全启动的关键)
158
+
159
+ `mode: shadow`:决策照算、事件照记,但 `agent/request` 里**返回 `await next()` 原样配置**。跑 N 天/请求数后对比"shadow 路由 vs 实际默认路由"的评分分布,达标后一键切 `active`。
160
+
161
+ ## 6. 存储与事件扩展
162
+
163
+ - 新增 durable 事件:`routing/decision`、`routing/outcome`(扩展 SessionEventMap,走会话日志,UI 可从日志渲染路由卡片)
164
+ - 模型可见的注入:`agent.inject()` 注入一行"本次已由 router 路由至 X(理由:…)",模型可知情
165
+ - 策略文件读写经插件自己的存储工具(幂等、原子写、带版本号,支持回滚)
166
+
167
+ ## 7. 工具与 UI
168
+
169
+ **模型工具**(注册到 `ctx.tools`):
170
+ - `router_explain(turn?)` — 解释最近一次/指定 turn 的路由决策与依据
171
+ - `router_stats` — 各任务簇 × 路由的收益统计(飞轮数据摘要)
172
+ - `router_set_mode(mode, scope)` — shadow/active/off 切换
173
+
174
+ **人工命令**(`ctx.commands`):
175
+ - `/router:force <模型|agent>` — 本次会话强制路由
176
+ - `/router:report` — 导出飞轮报告
177
+
178
+ **Client 侧(可选二期)**:路由卡片(复用 durable 事件渲染)+ 策略配置面板。
179
+
180
+ ## 8. 包结构(对齐 dsh-model-deploy 的打包格式)
181
+
182
+ ```
183
+ dsh-agentic-router/
184
+ ├── package.json # dsh.bundle.patch → cordis.patch.yml
185
+ ├── cordis.patch.yml # insert row: dsh-agentic-router
186
+ ├── dsh/
187
+ │ ├── index.js # apply(): 注册事件监听 + 工具 + 命令
188
+ │ ├── classify.js # 第1/2层分类器(纯函数)
189
+ │ ├── policy.js # 路由表 + bandit/kNN 策略 + 影子模式
190
+ │ ├── store.js # 飞轮存储(jsonl 追加 + 原子写策略文件)
191
+ │ └── events.js # routing/decision、routing/outcome 定义
192
+ ├── test/ # node --test:分类器/策略/存储/端到端 mock
193
+ ├── README.md / README.en.md
194
+ └── LICENSE
195
+ ```
196
+
197
+ `inject: ['tools', 'agents', 'commands']`;事件监听全部经 `ctx.on('agent/request', ...)` 注册(随插件卸载自动回收)。
198
+
199
+ ## 9. 分阶段路线图
200
+
201
+ | 阶段 | 内容 | 验收 |
202
+ |---|---|---|
203
+ | P0 影子模式 | 特征+分类+决策+落盘,不改请求 | 日志出现 decision/outcome 事件;分类延迟 <5ms |
204
+ | P1 规则接管 | agent/request 真正切换模型;force 命令 | 简单问答走 Flash、复杂代码走 Pro,可解释 |
205
+ | P2 数据飞轮 | outcome 评分 + 在线 bandit + 统计 | 两周内成本/延迟下降且质量无回退(影子期数据可回放验证) |
206
+ | P3 子 agent 路由 | subagent 派发 + 工具面裁剪 | 研究型任务自动进检索子 agent |
207
+ | P4 学习升级 | k-NN 经验检索 + 策略热更新 UI | 新任务类型零配置自动学习出合理路由 |
208
+
209
+ ## 10. 风险与护栏
210
+
211
+ - **误路由成本上限**:单会话/turn 成本预算,超限自动降级回默认模型
212
+ - **分类器自身延迟**:>5ms 直接走默认;LLM 分类器带 300ms 超时
213
+ - **可回退**:`/router:force default` 或全局 off;策略文件保留版本,支持一键回滚
214
+ - **隐私**:飞轮数据全部本地落盘,默认不出机
215
+ - **合规**:影子模式与 active 模式都写日志,任何路由可事后审计
216
+
217
+ ## 11. 与 dsh-model-deploy(选型分析器)的联动
218
+
219
+ 选型分析器提供**容量/成本先验**:部署里有哪些模型与硬件、各自 TTFT/吞吐/成本——这些直接作为路由表的候选集与成本/延迟先验;反过来,路由飞轮积累的**真实负载下的质量/延迟数据**可以回灌给选型分析器校准其估算系数。两者合起来就是"**规划(选型)→ 执行(路由)→ 反馈(飞轮)**"的完整闭环。
220
+
221
+ ## 12. 验证方式
222
+
223
+ - 单元测试:分类器、策略、存储(node --test,零依赖)
224
+ - 离线回放:拿历史 session 日志重放,对比 shadow 路由与实际路由的评分分布
225
+ - 影子期 A/B:shadow 评分 vs 默认路由评分,达标才切 active
226
+ - 集成冒烟:mock ctx 注入 agent/request payload,验证 LlmCallConfig 替换与 next() 透传
@@ -0,0 +1,49 @@
1
+ // 分类器:任务类型 + 复杂度(确定性规则,<1ms)
2
+ // 所有规则都来自动态原型实测,回归用例见 test/classify.test.js
3
+
4
+ const QA_IMPERATIVE = /(请|麻烦|帮我).{0,6}(回答|告诉|说)/
5
+ const CODE_RE = /def |function |class |import |require\(|```|\.py|\.ts|\.js|\.go|\.rs\b|bug|error|exception|traceback|报错|代码|函数|接口|脚本/
6
+ const WRITE_RE = /(写|创作|起草|润色|翻译|translate|write|blog|article|文案|邮件|报告)/
7
+ const SUMMARIZE_RE = /(总结|概括|summar|摘要|要点)/
8
+ const RESEARCH_RE = /(查|搜索|检索|research|调研|对比|最新|资料|文档)/
9
+ const AGENTIC_RE = /(帮我|请|做|搭建|构建|部署|实现|写一个|生成|build|setup|deploy|implement|create)/
10
+ const QA_RE = /[??]|什么|为什么|怎么|如何|how|what|why|能否|是否/
11
+ const COMPLEX_RE = /(重构|优化|设计|分析|架构|debug|调试|排查|review|方案|复杂|多步|逐步|implement|design|architecture)/
12
+ const SIMPLE_RE = /(一句|简单|快速|quickly|short|hello|你好|谢谢)/
13
+
14
+ // 任务类型基础复杂度先验(实测调参):代码与 agentic 任务天然更重
15
+ const PRIOR = { code: 0.3, agentic: 0.3, research: 0.2, write: 0.15, summarize: 0.05, qa: 0, other: 0 }
16
+
17
+ export function classify(text) {
18
+ const t = text || ''
19
+ const lower = t.toLowerCase()
20
+ let type = 'other'
21
+ if (QA_IMPERATIVE.test(lower)) type = 'qa'
22
+ else if (CODE_RE.test(lower)) type = 'code'
23
+ else if (WRITE_RE.test(lower)) type = 'write'
24
+ else if (SUMMARIZE_RE.test(lower)) type = 'summarize'
25
+ else if (RESEARCH_RE.test(lower)) type = 'research'
26
+ else if (AGENTIC_RE.test(lower)) type = 'agentic'
27
+ else if (QA_RE.test(lower)) type = 'qa'
28
+
29
+ const complexKw = COMPLEX_RE.test(lower)
30
+ const simpleKw = SIMPLE_RE.test(lower)
31
+ let score = PRIOR[type] || 0
32
+ score += Math.min(t.length / 2000, 1) * 0.2
33
+ score += (t.match(/```/g) || []).length > 0 ? 0.25 : 0
34
+ if (complexKw) score += 0.25
35
+ // 同时出现"简单"与"复杂"关键词时不做简单惩罚(实测踩过的坑)
36
+ if (simpleKw && !complexKw) score -= 0.25
37
+ score = Math.max(0, Math.min(1, score))
38
+ return { type, complexity: Math.round(score * 100) / 100 }
39
+ }
40
+
41
+ const TYPES = ['code', 'write', 'summarize', 'research', 'agentic', 'qa']
42
+
43
+ // LinUCB / k-NN 共享的特征向量:[1, 复杂度, 长度归一, 类型 one-hot ×6]
44
+ export function featuresOf(cls, text) {
45
+ const t = text || ''
46
+ const f = [1, cls.complexity, Math.min(t.length, 4000) / 4000]
47
+ for (const ty of TYPES) f.push(cls.type === ty ? 1 : 0)
48
+ return f
49
+ }
package/dsh/index.js ADDED
@@ -0,0 +1,402 @@
1
+ // dsh-agentic-router —— DeepSeek Harness 插件(Host 侧)
2
+ //
3
+ // 学习型智能路由:监听 agent/pre-step 与 agent/request,按任务类型与复杂度在
4
+ // 模型档位(fast/mid/strong)之间路由;四个专家(规则表 / UCB / LinUCB / k-NN)
5
+ // 并行推荐,EXP3 元选择器决定听谁的;回合结束时按质量代理信号
6
+ // (工具失败 + 模型重试 + 延迟)计算奖励并回传飞轮。
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 注册,随插件卸载自动回收
12
+ // 飞轮数据落盘 ${DSH_HOME:-~/.dsh}/storages/dsh-agentic-router/,重启后重放恢复。
13
+
14
+ import { classify, featuresOf } from './classify.js'
15
+ import {
16
+ tierOf, tierTarget, resolveTier,
17
+ recommendRule, recommendUcb, recommendLin, recommendKnn,
18
+ rewardOf, applyReward,
19
+ } from './strategies.js'
20
+ import { metaPick, DEFAULT_WEIGHTS } from './meta.js'
21
+ import { Store, defaultHome } from './store.js'
22
+
23
+ export const name = 'agentic-router'
24
+ export const inject = ['tools']
25
+
26
+ const LIN_GRADUATION = 50 // LinUCB 毕业样本线
27
+ const KNN_GRADUATION = 30 // k-NN 毕业经验线
28
+
29
+ function freshState() {
30
+ return {
31
+ mode: 'shadow',
32
+ seq: 0,
33
+ decisions: [],
34
+ forced: null,
35
+ pending: new Map(),
36
+ modelTiers: null,
37
+ turnDecision: {},
38
+ openTurns: new Map(),
39
+ ucb: { total: 0, arms: {} },
40
+ lin: {
41
+ total: 0,
42
+ tierN: { fast: 0, mid: 0, strong: 0 },
43
+ tierW: { fast: new Array(9).fill(0), mid: new Array(9).fill(0), strong: new Array(9).fill(0) },
44
+ },
45
+ knn: { pool: [] },
46
+ exp3: { total: 0, weights: { ...DEFAULT_WEIGHTS } },
47
+ }
48
+ }
49
+
50
+ function now() {
51
+ try { return Date.now() } catch { return null }
52
+ }
53
+
54
+ function agentKey(payload) {
55
+ try {
56
+ const a = payload && payload.agent
57
+ if (a) {
58
+ if (typeof a.sessionId === 'string') return a.sessionId
59
+ if (typeof a.id === 'string') return a.id
60
+ const s = a.session
61
+ if (s && typeof s.id === 'string') return s.id
62
+ }
63
+ } catch {}
64
+ return 'agent-' + (payload && payload.turn)
65
+ }
66
+
67
+ function fullKey(payload) { return agentKey(payload) + '/' + payload.turn }
68
+
69
+ function textOfMessages(messages) {
70
+ const parts = []
71
+ if (!Array.isArray(messages)) return ''
72
+ for (const m of messages) {
73
+ if (!m || typeof m !== 'object') continue
74
+ const c = m.content
75
+ if (typeof c === 'string') parts.push(c)
76
+ else if (Array.isArray(c)) {
77
+ for (const b of c) {
78
+ if (b && typeof b === 'object' && b.type === 'text' && typeof b.text === 'string') parts.push(b.text)
79
+ }
80
+ }
81
+ }
82
+ return parts.join('\n').trim()
83
+ }
84
+
85
+ function isErrorResult(r) {
86
+ if (r == null || typeof r !== 'object') return false
87
+ if (r.ok === false || r.success === false) return true
88
+ if (r.status === 'error' || r.status === 'failed' || r.status === 'ERROR') return true
89
+ if (typeof r.error === 'object' && r.error !== null) return true
90
+ if (typeof r.error === 'string' && r.error !== '') return true
91
+ return false
92
+ }
93
+
94
+ function execAgentKey(exec) {
95
+ try {
96
+ if (!exec) return null
97
+ const a = exec.agent
98
+ if (a) {
99
+ if (typeof a.sessionId === 'string') return a.sessionId
100
+ if (typeof a.id === 'string') return a.id
101
+ }
102
+ if (typeof exec.sessionId === 'string') return exec.sessionId
103
+ if (typeof exec.agentId === 'string') return exec.agentId
104
+ } catch {}
105
+ return null
106
+ }
107
+
108
+ async function refreshModelTiers(llm) {
109
+ if (!llm || typeof llm.listProviders !== 'function') return null
110
+ const providers = await llm.listProviders()
111
+ const out = { providers: [], tiers: { fast: [], mid: [], strong: [] } }
112
+ for (const p of providers) {
113
+ const pid = p && (p.id || p.name || p.provider)
114
+ if (!pid) continue
115
+ let models = []
116
+ try { models = await llm.listModels(pid) } catch { models = [] }
117
+ const names = (models || []).map((m) => (m && (m.id || m.model || m.name)) || '').filter(Boolean)
118
+ out.providers.push({ provider: pid, models: names })
119
+ for (const n of names) out.tiers[tierOf(n)].push(pid + '/' + n)
120
+ }
121
+ return out
122
+ }
123
+
124
+ export function apply(ctx, config = {}) {
125
+ const tools = ctx.get('tools')
126
+ const llm = ctx.get('llm')
127
+ const store = new Store(config.home || defaultHome())
128
+ const policy = store.loadPolicy()
129
+
130
+ const state = freshState()
131
+ state.mode = policy.mode || config.mode || 'shadow'
132
+ if (typeof policy.forced === 'string') state.forced = policy.forced
133
+
134
+ // 飞轮恢复:重放 rewards.jsonl 重建 UCB/LinUCB/k-NN/EXP3
135
+ const rewards = store.loadJsonl('rewards.jsonl')
136
+ for (const rec of rewards) {
137
+ if (typeof rec.reward === 'number' && Array.isArray(rec.f)) applyReward(state, rec)
138
+ }
139
+ state.decisions = store.loadJsonl('decisions.jsonl').slice(-60)
140
+ state.seq = state.decisions.length ? state.decisions[state.decisions.length - 1].seq : 0
141
+
142
+ const log = (msg) => console.log('[agentic-router]', msg)
143
+ if (rewards.length) log('replayed ' + rewards.length + ' rewards; ucb.total=' + state.ucb.total)
144
+
145
+ // ---- 事件挂载 ----
146
+ ctx.on('agent/pre-step', (payload, next) => {
147
+ try {
148
+ const text = textOfMessages(payload && payload.messages)
149
+ if (text) state.pending.set(agentKey(payload) + ':' + payload.turn, text)
150
+ } catch (e) {
151
+ log('pre-step capture failed: ' + String(e))
152
+ }
153
+ return next()
154
+ })
155
+
156
+ ctx.on('agent/request', async (payload, next) => {
157
+ const cfg = await next()
158
+ if (!cfg || typeof cfg !== 'object') return cfg
159
+ try {
160
+ const key = fullKey(payload)
161
+ if (state.turnDecision[key]) return cfg
162
+ const pkey = agentKey(payload) + ':' + payload.turn
163
+ const text = state.pending.get(pkey) || ''
164
+ 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
+ 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,
180
+ }
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
+ } catch (e) {
227
+ log('decide failed: ' + String(e))
228
+ }
229
+ return cfg
230
+ })
231
+
232
+ ctx.on('agent/request-error', (payload, next) => {
233
+ try {
234
+ const td = state.turnDecision[fullKey(payload)]
235
+ if (td && !td.finalized) {
236
+ td.retries = (td.retries || 0) + 1
237
+ log('retry on ' + fullKey(payload) + ' count=' + td.retries)
238
+ }
239
+ } catch (e) {
240
+ log('request-error hook failed: ' + String(e))
241
+ }
242
+ return next()
243
+ })
244
+
245
+ ctx.on('tools/result', (exec, result) => {
246
+ try {
247
+ if (!isErrorResult(result)) return
248
+ const ak = execAgentKey(exec)
249
+ if (!ak) return
250
+ const key = state.openTurns.get(ak)
251
+ if (!key) return
252
+ const td = state.turnDecision[key]
253
+ if (td && !td.finalized) {
254
+ td.toolErrors = (td.toolErrors || 0) + 1
255
+ log('tool error on ' + key + ' count=' + td.toolErrors)
256
+ }
257
+ } catch (e) {
258
+ log('tools/result hook failed: ' + String(e))
259
+ }
260
+ })
261
+
262
+ const finalize = (payload, tag) => {
263
+ try {
264
+ const key = fullKey(payload)
265
+ const td = state.turnDecision[key]
266
+ if (!td || td.finalized) return
267
+ td.finalized = true
268
+ const t1 = now()
269
+ const dtMs = t1 != null && td.t0 != null ? t1 - td.t0 : null
270
+ const r = rewardOf(td, tag, dtMs)
271
+ const rec = {
272
+ turn: key,
273
+ reward: r.reward,
274
+ tag,
275
+ breakdown: r.breakdown,
276
+ chosen: td.chosen,
277
+ pool: td.meta ? td.meta.pool : [],
278
+ pChosen: td.meta ? td.meta.ps[td.chosen] || 0 : 0,
279
+ clsType: td.clsType,
280
+ ucbTier: td.recs.ucb.tier,
281
+ linTier: td.recs.lin.tier,
282
+ f: td.f,
283
+ }
284
+ applyReward(state, rec)
285
+ store.appendJsonl('rewards.jsonl', rec)
286
+ const ak = agentKey(payload)
287
+ if (state.openTurns.get(ak) === key) state.openTurns.delete(ak)
288
+ log('reward ' + rec.reward + ' ' + tag + ' on ' + key + ' breakdown=' + JSON.stringify(r.breakdown))
289
+ } catch (e) {
290
+ log('finalize failed: ' + String(e))
291
+ }
292
+ }
293
+
294
+ ctx.on('agent/turn-stopping', (payload) => finalize(payload, 'clean-close'))
295
+ ctx.on('agent/error', (payload) => finalize(payload, 'error'))
296
+
297
+ refreshModelTiers(llm).then((t) => {
298
+ state.modelTiers = t
299
+ log('model tiers: ' + JSON.stringify(t))
300
+ })
301
+
302
+ // ---- 工具 ----
303
+ const OUT = (label) => ({
304
+ schema: { type: 'object', additionalProperties: true },
305
+ render: (_args, value) => [{ type: 'text', text: label + '\n' + JSON.stringify(value, null, 2) }],
306
+ })
307
+ const register = (def) => {
308
+ try {
309
+ tools.register(def)
310
+ log('tool registered: ' + def.name)
311
+ } catch (e) {
312
+ log('tool register FAILED: ' + def.name + ' -> ' + String(e))
313
+ }
314
+ }
315
+
316
+ register({
317
+ name: 'agentic_router_stats',
318
+ description: '查看 agentic router(UCB/LinUCB/k-NN 三策略 + EXP3 元选择器 + 质量代理奖励)的决策记录、策略权重与飞轮学习状态。',
319
+ parameters: { type: 'object', properties: {} },
320
+ output: OUT('agentic router 状态'),
321
+ isConcurrencySafe: () => true,
322
+ async execute() {
323
+ return {
324
+ mode: state.mode,
325
+ forcedModel: state.forced,
326
+ totalDecisions: state.seq,
327
+ modelTiers: state.modelTiers,
328
+ flywheel: {
329
+ ucb: state.ucb,
330
+ lin: { total: state.lin.total, tierN: state.lin.tierN, tierW: state.lin.tierW },
331
+ knnPoolSize: state.knn.pool.length,
332
+ exp3: state.exp3,
333
+ recentRewards: store.loadJsonl('rewards.jsonl').slice(-20),
334
+ },
335
+ recent: state.decisions.slice(-20),
336
+ }
337
+ },
338
+ })
339
+ register({
340
+ name: 'agentic_router_set_mode',
341
+ description: '切换 agentic router 模式:shadow(决策与学习照常,但不切换模型)、active(按元选择器结果真正切换)、off(旁路)。',
342
+ parameters: {
343
+ type: 'object',
344
+ properties: { mode: { type: 'string', enum: ['shadow', 'active', 'off'] } },
345
+ required: ['mode'],
346
+ },
347
+ output: OUT('agentic router 模式'),
348
+ isConcurrencySafe: () => true,
349
+ async execute(args) {
350
+ state.mode = args.mode
351
+ store.savePolicy({ mode: state.mode, forced: state.forced })
352
+ return { mode: state.mode }
353
+ },
354
+ })
355
+ register({
356
+ name: 'agentic_router_force',
357
+ description: '强制后续请求使用指定模型 id(仅 active 模式生效);clear=true 清除强制,恢复规则路由。',
358
+ parameters: {
359
+ type: 'object',
360
+ properties: {
361
+ model: { type: 'string', description: '目标模型 id(见 agentic_router_stats 的 modelTiers)' },
362
+ clear: { type: 'boolean', description: '清除强制' },
363
+ },
364
+ },
365
+ output: OUT('agentic router 强制路由'),
366
+ isConcurrencySafe: () => true,
367
+ async execute(args) {
368
+ if (args && args.clear) {
369
+ state.forced = null
370
+ store.savePolicy({ mode: state.mode, forced: null })
371
+ return { forced: null }
372
+ }
373
+ if (args && typeof args.model === 'string') {
374
+ state.forced = args.model
375
+ store.savePolicy({ mode: state.mode, forced: args.model })
376
+ return { forced: args.model }
377
+ }
378
+ return { forced: state.forced, error: '需要 model 或 clear=true' }
379
+ },
380
+ })
381
+ register({
382
+ name: 'agentic_router_reset',
383
+ description: '清空飞轮学习状态(决策与奖励历史),保留当前模式;用于重新开始学习或清理测试数据。',
384
+ parameters: { type: 'object', properties: {} },
385
+ output: OUT('agentic router 重置'),
386
+ isConcurrencySafe: () => true,
387
+ async execute() {
388
+ store.resetFlywheel()
389
+ state.ucb = { total: 0, arms: {} }
390
+ state.lin = {
391
+ total: 0,
392
+ tierN: { fast: 0, mid: 0, strong: 0 },
393
+ tierW: { fast: new Array(9).fill(0), mid: new Array(9).fill(0), strong: new Array(9).fill(0) },
394
+ }
395
+ state.knn = { pool: [] }
396
+ state.exp3 = { total: 0, weights: { ...DEFAULT_WEIGHTS } }
397
+ state.seq = 0
398
+ state.decisions = []
399
+ return { reset: true, home: store.home }
400
+ },
401
+ })
402
+ }
package/dsh/meta.js ADDED
@@ -0,0 +1,33 @@
1
+ // EXP3 元选择器:把 {UCB, LinUCB, k-NN, rule} 当臂,按权重软最大采样,
2
+ // 被选策略按重要性加权更新(reward / p_chosen)。
3
+
4
+ const DEFAULT_WEIGHTS = { ucb: 1, linucb: 1, knn: 1, rule: 2.5 }
5
+
6
+ export function metaPick(recs, exp3) {
7
+ const weights = exp3.weights
8
+ const pool = ['ucb']
9
+ const byName = { ucb: recs.ucb, rule: recs.rule }
10
+ if (exp3.linGraduated && recs.lin && recs.lin.tier) {
11
+ pool.push('linucb')
12
+ byName.linucb = recs.lin
13
+ }
14
+ if (exp3.knnGraduated && recs.knn && recs.knn.tier) {
15
+ pool.push('knn')
16
+ byName.knn = recs.knn
17
+ }
18
+ pool.push('rule')
19
+
20
+ let total = 0
21
+ for (const n of pool) total += weights[n] || 0
22
+ const ps = {}
23
+ for (const n of pool) ps[n] = (weights[n] || 0) / total
24
+ let r = Math.random()
25
+ let chosen = 'rule'
26
+ for (const n of pool) {
27
+ if (r < ps[n]) { chosen = n; break }
28
+ r -= ps[n]
29
+ }
30
+ return { chosen, ps, pool, byName }
31
+ }
32
+
33
+ export { DEFAULT_WEIGHTS }
package/dsh/store.js ADDED
@@ -0,0 +1,68 @@
1
+ // 飞轮持久化:决策/奖励追加 JSONL,策略(模式/强制模型)原子写 policy.json
2
+ // 默认目录:${DSH_HOME:-~/.dsh}/storages/dsh-agentic-router(可被 config.home 或
3
+ // AGENTIC_ROUTER_HOME 环境变量覆盖)。全部本地明文,可导出可审计。
4
+
5
+ import { mkdirSync, readFileSync, writeFileSync, renameSync, existsSync, appendFileSync, readdirSync } from 'node:fs'
6
+ import { join, dirname } from 'node:path'
7
+ import { homedir } from 'node:os'
8
+
9
+ export function defaultHome() {
10
+ if (process.env.AGENTIC_ROUTER_HOME) return process.env.AGENTIC_ROUTER_HOME
11
+ const base = process.env.DSH_HOME || join(homedir(), '.dsh')
12
+ return join(base, 'storages', 'dsh-agentic-router')
13
+ }
14
+
15
+ export class Store {
16
+ constructor(home) {
17
+ this.home = home || defaultHome()
18
+ mkdirSync(this.home, { recursive: true })
19
+ }
20
+
21
+ policyPath() { return join(this.home, 'policy.json') }
22
+ jsonlPath(name) { return join(this.home, name) }
23
+
24
+ loadPolicy() {
25
+ try {
26
+ if (!existsSync(this.policyPath())) return {}
27
+ return JSON.parse(readFileSync(this.policyPath(), 'utf8'))
28
+ } catch {
29
+ return {}
30
+ }
31
+ }
32
+
33
+ savePolicy(policy) {
34
+ const p = this.policyPath()
35
+ mkdirSync(dirname(p), { recursive: true })
36
+ const tmp = p + '.tmp'
37
+ writeFileSync(tmp, JSON.stringify(policy, null, 2))
38
+ renameSync(tmp, p)
39
+ }
40
+
41
+ appendJsonl(name, obj) {
42
+ appendFileSync(this.jsonlPath(name), JSON.stringify(obj) + '\n')
43
+ }
44
+
45
+ loadJsonl(name) {
46
+ try {
47
+ if (!existsSync(this.jsonlPath(name))) return []
48
+ return readFileSync(this.jsonlPath(name), 'utf8')
49
+ .split('\n')
50
+ .filter((l) => l.trim())
51
+ .map((l) => JSON.parse(l))
52
+ } catch {
53
+ return []
54
+ }
55
+ }
56
+
57
+ // 清空飞轮(重置学习状态),保留 policy
58
+ resetFlywheel() {
59
+ for (const name of ['decisions.jsonl', 'rewards.jsonl']) {
60
+ const p = this.jsonlPath(name)
61
+ if (existsSync(p)) writeFileSync(p, '')
62
+ }
63
+ }
64
+
65
+ listFiles() {
66
+ try { return readdirSync(this.home) } catch { return [] }
67
+ }
68
+ }
@@ -0,0 +1,153 @@
1
+ // 四专家策略 + 奖励计算 + 飞轮更新(纯函数,state 从外部传入)
2
+ // 算法选择(见 docs/DESIGN.md):
3
+ // UCB —— 簇×档臂的多臂老虎机(冷启动上界∞,平局轮转探索)
4
+ // LinUCB —— 特征线性打分 + 探索项(毕业线 50 样本)
5
+ // k-NN —— 经验池最近邻聚合(毕业线 30 经验)
6
+ // rule —— 确定性阈值表(冷启动先验与兜底)
7
+
8
+ export const FAST_RE = /flash|lite|mini|small|fast|air|nano|tiny|light/
9
+ export const STRONG_RE = /pro|max|ultra|plus|large|premium|reason|thinking/
10
+
11
+ export function tierOf(model) {
12
+ if (!model) return 'unknown'
13
+ if (FAST_RE.test(model)) return 'fast'
14
+ if (STRONG_RE.test(model)) return 'strong'
15
+ return 'mid'
16
+ }
17
+
18
+ export function tierTarget(tier, provider, modelTiers) {
19
+ const list = modelTiers && modelTiers.tiers && modelTiers.tiers[tier] ? modelTiers.tiers[tier] : []
20
+ for (const s of list) if (s.indexOf(provider + '/') === 0) return s.slice(provider.length + 1)
21
+ return null
22
+ }
23
+
24
+ // 档位 → 模型 id 的解析;mid 档无候选时向上取 strong(保质量),再取 fast
25
+ export function resolveTier(tier, provider, modelTiers) {
26
+ if (!modelTiers) return { target: null, used: tier, note: 'tiers-not-ready' }
27
+ const target = tierTarget(tier, provider, modelTiers)
28
+ if (target) return { target, used: tier, note: '' }
29
+ if (tier === 'mid') {
30
+ const strong = tierTarget('strong', provider, modelTiers)
31
+ if (strong) return { target: strong, used: 'strong', note: 'mid->strong fallback' }
32
+ const fast = tierTarget('fast', provider, modelTiers)
33
+ if (fast) return { target: fast, used: 'fast', note: 'mid->fast fallback' }
34
+ }
35
+ return { target: null, used: tier, note: 'no-candidate' }
36
+ }
37
+
38
+ export function recommendRule(cls) {
39
+ const want = cls.complexity >= 0.7 ? 'strong' : (cls.complexity <= 0.3 ? 'fast' : 'mid')
40
+ return { tier: want, reason: 'rule threshold complexity=' + cls.complexity, conf: 0.5 }
41
+ }
42
+
43
+ // UCB:mean + c·√(lnN/n);未探索臂上界∞,多个∞平局时按总拉动数轮转(避免永远选 fast)
44
+ export function recommendUcb(cls, ucbState) {
45
+ const arms = ucbState.arms
46
+ const scores = []
47
+ for (const tier of ['fast', 'mid', 'strong']) {
48
+ const arm = arms[cls.type + ':' + tier] || { n: 0, mean: 0 }
49
+ const ucb = arm.n > 0 ? arm.mean + 1.2 * Math.sqrt(Math.log(ucbState.total + 1) / arm.n) : Infinity
50
+ scores.push({ tier, ucb, arm })
51
+ }
52
+ let best = scores[0]
53
+ for (const s of scores) if (s.ucb > best.ucb) best = s
54
+ const infs = scores.filter((s) => s.ucb === Infinity)
55
+ if (infs.length > 1) best = infs[ucbState.total % infs.length]
56
+ return {
57
+ tier: best.tier,
58
+ reason: 'ucb ' + cls.type + ' n=' + best.arm.n + ' mean=' + (Math.round(best.arm.mean * 100) / 100) +
59
+ ' bound=' + (isFinite(best.ucb) ? Math.round(best.ucb * 100) / 100 : 'inf'),
60
+ conf: isFinite(best.ucb) ? 0.6 : 0.2,
61
+ }
62
+ }
63
+
64
+ // LinUCB(近似实现):每档线性权重 w·f + 探索项;梯度更新,探索项随档样本数衰减
65
+ export function recommendLin(cls, f, linState) {
66
+ const tn = linState.tierN
67
+ let best = null
68
+ for (const tier of ['fast', 'mid', 'strong']) {
69
+ const w = linState.tierW[tier] || []
70
+ let dot = 0
71
+ for (let i = 0; i < f.length; i++) dot += (w[i] || 0) * f[i]
72
+ const bonus = tn[tier] > 0 ? 0.8 * Math.sqrt(Math.log(linState.total + 1) / tn[tier]) : 0.3
73
+ const score = dot + bonus
74
+ if (!best || score > best.score) best = { tier, score, dot }
75
+ }
76
+ return {
77
+ tier: best.tier,
78
+ reason: 'linucb score=' + Math.round(best.score * 100) / 100 + ' (dot=' + Math.round(best.dot * 100) / 100 + ')',
79
+ conf: linState.total >= 50 ? 0.7 : 0.1,
80
+ }
81
+ }
82
+
83
+ // k-NN:欧氏距离取 k=5 邻居,按邻居平均奖励选档
84
+ export function recommendKnn(cls, f, knnState) {
85
+ const pool = knnState.pool
86
+ if (pool.length < 5) return { tier: null, reason: 'knn cold (' + pool.length + '/5 seed)', conf: 0 }
87
+ const scored = []
88
+ for (const e of pool) {
89
+ let d = 0
90
+ for (let i = 0; i < f.length; i++) d += (e.f[i] - f[i]) * (e.f[i] - f[i])
91
+ scored.push({ d, e })
92
+ }
93
+ scored.sort((a, b) => a.d - b.d)
94
+ const k = Math.min(5, scored.length)
95
+ const agg = { fast: { n: 0, s: 0 }, mid: { n: 0, s: 0 }, strong: { n: 0, s: 0 } }
96
+ for (let i = 0; i < k; i++) {
97
+ const t = scored[i].e.tier
98
+ if (agg[t]) { agg[t].n += 1; agg[t].s += scored[i].e.r }
99
+ }
100
+ let best = null
101
+ for (const tier of ['fast', 'mid', 'strong']) {
102
+ const a = agg[tier]
103
+ if (!best || (a.n > 0 && a.s / a.n > best.score)) best = { tier, score: a.n > 0 ? a.s / a.n : -1 }
104
+ }
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
+ }
107
+
108
+ // 质量代理奖励:干净收尾 1 分起,扣工具失败(0.2/次,封顶 0.6)、模型重试(0.2/次,封顶 0.4)、延迟(>90s −0.3, >30s −0.1)
109
+ export function rewardOf(td, tag, dtMs) {
110
+ if (tag !== 'clean-close') {
111
+ return { reward: 0, breakdown: { base: 0, toolErrors: td.toolErrors || 0, retries: td.retries || 0, dtMs } }
112
+ }
113
+ let reward = 1
114
+ const toolPenalty = Math.min(0.6, 0.2 * (td.toolErrors || 0))
115
+ const retryPenalty = Math.min(0.4, 0.2 * (td.retries || 0))
116
+ reward -= toolPenalty
117
+ reward -= retryPenalty
118
+ if (dtMs != null) {
119
+ if (dtMs > 90000) reward -= 0.3
120
+ else if (dtMs > 30000) reward -= 0.1
121
+ }
122
+ return {
123
+ reward: Math.max(0, Math.round(reward * 100) / 100),
124
+ breakdown: { base: 1, toolErrors: td.toolErrors || 0, toolPenalty, retries: td.retries || 0, retryPenalty, dtMs },
125
+ }
126
+ }
127
+
128
+ // 把一条奖励记录应用到飞轮(UCB/LinUCB/k-NN/EXP3),replay 与实时更新共用
129
+ export function applyReward(state, rec) {
130
+ const armKey = rec.clsType + ':' + rec.ucbTier
131
+ const arm = state.ucb.arms[armKey] || (state.ucb.arms[armKey] = { n: 0, mean: 0 })
132
+ arm.n += 1
133
+ arm.mean = arm.mean + (rec.reward - arm.mean) / arm.n
134
+ state.ucb.total += 1
135
+
136
+ const lw = state.lin.tierW[rec.linTier] || []
137
+ const lr = 1 / (1 + (state.lin.tierN[rec.linTier] || 0))
138
+ let dot = 0
139
+ for (let i = 0; i < rec.f.length; i++) dot += (lw[i] || 0) * rec.f[i]
140
+ for (let i = 0; i < rec.f.length; i++) lw[i] = (lw[i] || 0) + lr * (rec.reward - dot) * rec.f[i]
141
+ state.lin.tierW[rec.linTier] = lw
142
+ state.lin.tierN[rec.linTier] += 1
143
+ state.lin.total += 1
144
+
145
+ state.knn.pool.push({ f: rec.f, tier: rec.ucbTier, r: rec.reward })
146
+ if (state.knn.pool.length > 400) state.knn.pool.shift()
147
+
148
+ if (rec.pChosen > 0) {
149
+ state.exp3.weights[rec.chosen] = (state.exp3.weights[rec.chosen] || 0) *
150
+ Math.exp(0.3 * (rec.reward / rec.pChosen) / rec.pool.length)
151
+ }
152
+ state.exp3.total += 1
153
+ }
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
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",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "lhl",
8
+ "engines": {
9
+ "node": ">=22.19"
10
+ },
11
+ "keywords": [
12
+ "deepseek-harness",
13
+ "dsh",
14
+ "dsh-plugin",
15
+ "agentic-router",
16
+ "model-routing",
17
+ "bandit",
18
+ "exp3",
19
+ "flywheel",
20
+ "llm"
21
+ ],
22
+ "exports": {
23
+ ".": "./dsh/index.js",
24
+ "./dsh": "./dsh/index.js",
25
+ "./package.json": "./package.json"
26
+ },
27
+ "files": [
28
+ "dsh",
29
+ "cordis.patch.yml",
30
+ "README.md",
31
+ "README.en.md",
32
+ "docs",
33
+ "LICENSE"
34
+ ],
35
+ "scripts": {
36
+ "test": "node --test test/"
37
+ },
38
+ "dsh": {
39
+ "bundle": {
40
+ "patch": "./cordis.patch.yml"
41
+ }
42
+ }
43
+ }