dsh-auto-model-routing 0.5.1 → 0.5.3

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/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.3
4
+
5
+ - Replace both README demo GIFs with representative PNG screenshots (an applied AUTO decision with its distribution; decision service settings with a connection test), because the DSH plugin store does not show GIFs by default. No runtime behavior changes.
6
+
7
+ ## 0.5.2
8
+
9
+ - README follows the standard plugin layout: demos side by side, then the architecture diagram, then the introduction; a closing section links the ohmydsh source, the MIT license and the issue tracker.
10
+ - The release workflow now also creates the GitHub Release for each version and attaches the exact npm tarball. No runtime behavior changes.
11
+
3
12
  ## 0.5.1
4
13
 
5
14
  - Replace both retired four-tier GIFs with high-resolution recordings of the current AUTO interaction, applied decision distribution and settings/strategy UI, using isolated mock services and synthetic tasks.
package/README.md CHANGED
@@ -3,13 +3,13 @@
3
3
  English · [简体中文](README.zh.md)
4
4
 
5
5
  <p align="center">
6
- <img src="docs/auto-routing.gif" alt="AUTO: switch modes, apply effort and inspect the decision distribution" width="49%">
7
- <img src="docs/settings.gif" alt="Settings: decision service, connection test, defaults and strategy details" width="49%">
6
+ <img src="docs/auto-routing.png" alt="AUTO applied a higher effort to the main session; the panel shows the returned decision distribution" width="49%">
7
+ <img src="docs/settings.png" alt="Settings: decision service, compatible endpoint and a successful connection test" width="49%">
8
8
  </p>
9
9
 
10
- *Left: AUTO interaction and applied decision. Right: current settings and strategy details. Recorded from an isolated DSH host with local mock Jev/model responses and synthetic tasks.*
10
+ *Left: an applied AUTO decision with its effort distribution. Right: decision service settings with a successful connection test. Captured from an isolated DSH host with local mock Jev/model responses and synthetic tasks.*
11
11
 
12
- [Repository](https://github.com/prgrmrwy/dsh-auto-model-routing) · [Releases](https://github.com/prgrmrwy/dsh-auto-model-routing/releases) · [npm](https://www.npmjs.com/package/dsh-auto-model-routing)
12
+ ![AUTO architecture — independent judgments, validated requests and allocation feedback](docs/architecture-v2.en.svg)
13
13
 
14
14
  <!-- problem -->
15
15
  **Add AUTO mode to DSH: automatic effort for the main agent, automatic model + effort for sub-agents.** Stop adjusting reasoning investment every turn or choosing a model for every delegated task.
@@ -93,12 +93,11 @@ AUTO applies the selected effort to the current main request and awaits official
93
93
  - **Local state:** configuration is under `$DSH_HOME/plugins/dsh-auto-model-routing/`; mode, child routes and feedback are under `$DSH_HOME/state/auto-model-routing/`. Feedback stores resource metadata, IDs, the strategy family, returned distributions and timestamps—not task/reply text. Request event IDs allow later usage correlation; costs are not fabricated when unavailable. See [feedback and evaluation](docs/feedback-evaluation.md).
94
94
  - **No automatic learning.** Human allocation feedback is observational. It does not change routing or prove quality/cost benefits. Old messages without decision bindings are not backfilled; old records with missing evaluation fields remain unknown.
95
95
  - **Upgrade to 0.5.0:** there is one AUTO strategy and no version setting. Existing sessions keep their stored mode; new chats still default to MANUAL unless configured otherwise. Retired `policy` / `mainEfforts` / `childRoutes` keys in the plugin configuration are ignored, shown as no longer effective in Settings, and removed on the next save. Child decisions and feedback recorded by 0.4.x stay readable. To go back, pin the published `dsh-auto-model-routing@0.4.2` (see [CHANGELOG](CHANGELOG.md) for the data boundary).
96
+
96
97
  ## Architecture
97
98
  <!-- section: architecture -->
98
99
 
99
- ![AUTO architecture — independent judgments, validated requests and allocation feedback](docs/architecture-v2.en.svg)
100
-
101
- Main failures disable AUTO; child failures stop dispatch. Explicit model/effort constraints are preserved. The diagram shows the AUTO paths; MANUAL makes no new judgments and fork is unsupported. Feedback does not automatically alter routing.
100
+ The diagram at the top shows the AUTO paths. Main failures disable AUTO; child failures stop dispatch. Explicit model/effort constraints are preserved. MANUAL makes no new judgments and fork is unsupported. Feedback does not automatically alter routing.
102
101
 
103
102
  Entry: [index.ts](src/index.ts). Enforcement: [policy-host.ts](src/policy-host.ts). Actual rubric: [rubric.json](src/rubric.json). Family strategies (supported models, transports, effort starting points, capability slots): [strategies](src/strategies/index.ts); routing: [routing.ts](src/routing.ts). UI: [client](src/client/index.ts). Coverage and limitations (historical records): [development verification](docs/policy-v2-development.md), [real Magpie acceptance](docs/magpie-live-acceptance.md).
104
103
 
@@ -112,9 +111,7 @@ npm run build
112
111
 
113
112
  Tests use the official AgentLoop with fake LLM streams and mock/loopback Jev responses, not paid model calls. To observe AUTO decisions and their actual token usage, ask the agent to use the `auto-model-routing-report` skill (on demand only; it runs the read-only `lib/usage-report.js`). It is observational, not a savings measurement. Evaluation schema and deduplication rules are in [feedback-evaluation.md](docs/feedback-evaluation.md).
114
113
 
115
- Development stays in the source monorepo; this repository is its release mirror. The package builds and loads independently using its declared dependencies, bundled skill and official DSH services; no monorepo checkout or monorepo instruction file is required. External design references are optional background, not runtime inputs.
116
-
117
- The [current specification](https://github.com/prgrmrwy/ohmydsh/blob/main/openspec/specs/auto-model-routing/spec.md) lives in the source monorepo. The bundled [delegation skill](skills/subagent-model-routing/SKILL.md) supplies guidance; enforcement belongs to the official host hooks, not the prompt. No measured task-quality or token-savings claim is made.
114
+ The package builds and loads independently using its declared dependencies, bundled skill and official DSH services; no monorepo checkout or monorepo instruction file is required. External design references are optional background, not runtime inputs. The bundled [delegation skill](skills/subagent-model-routing/SKILL.md) supplies guidance; enforcement belongs to the official host hooks, not the prompt. No measured task-quality or token-savings claim is made.
118
115
 
119
116
  ## Removal
120
117
  <!-- section: removal -->
@@ -124,3 +121,9 @@ dsh plugin --profile web remove dsh-auto-model-routing
124
121
  ```
125
122
 
126
123
  Restart DSH and refresh the page. The plugin's skill/tools and request hooks disappear; settings and logs remain. Without the plugin, child replay is no longer enforced and host restoration uses the original creation configuration. In [ohmydsh](https://github.com/prgrmrwy/ohmydsh), disable the local entry and rebuild instead.
124
+
125
+ ## Source, license and contributing
126
+
127
+ This plugin is developed in [ohmydsh](https://github.com/prgrmrwy/ohmydsh) (`packages/auto-model-routing`); [this repository](https://github.com/prgrmrwy/dsh-auto-model-routing) is its release mirror, published automatically to [npm](https://www.npmjs.com/package/dsh-auto-model-routing) and [GitHub Releases](https://github.com/prgrmrwy/dsh-auto-model-routing/releases). The [current specification](https://github.com/prgrmrwy/ohmydsh/blob/main/openspec/specs/auto-model-routing/spec.md) lives there too.
128
+
129
+ [MIT](LICENSE). Issues and pull requests are welcome at [prgrmrwy/dsh-auto-model-routing](https://github.com/prgrmrwy/dsh-auto-model-routing/issues) — including support for model families beyond Claude and GPT.
package/README.zh.md CHANGED
@@ -3,13 +3,13 @@
3
3
  [English](README.md) · 简体中文
4
4
 
5
5
  <p align="center">
6
- <img src="docs/auto-routing.gif" alt="AUTO:切换模式、应用推理投入并查看决策分布" width="49%">
7
- <img src="docs/settings.gif" alt="设置:决策服务、连接测试、默认值与策略说明" width="49%">
6
+ <img src="docs/auto-routing.png" alt="AUTO 已为主会话应用更高推理投入,面板显示返回的决策分布" width="49%">
7
+ <img src="docs/settings.png" alt="设置:决策服务、兼容端点与连接测试成功" width="49%">
8
8
  </p>
9
9
 
10
- *左:AUTO 交互与已应用的决策;右:当前设置页与策略说明。录制来自隔离 DSH 宿主,使用本地模拟 Jev/模型响应与合成任务。*
10
+ *左:已应用的 AUTO 决策与投入分布;右:决策服务设置与连接测试成功。截图来自隔离 DSH 宿主,使用本地模拟 Jev/模型响应与合成任务。*
11
11
 
12
- [插件仓库](https://github.com/prgrmrwy/dsh-auto-model-routing) · [版本发布](https://github.com/prgrmrwy/dsh-auto-model-routing/releases) · [npm](https://www.npmjs.com/package/dsh-auto-model-routing)
12
+ ![AUTO 架构:独立判断、请求校验与分配反馈](docs/architecture-v2.zh.svg)
13
13
 
14
14
  <!-- problem -->
15
15
  **为 DSH 增加 AUTO 模式:主 Agent 自动调 effort,子 Agent 自动选 model + effort。** 不必每轮手动选择推理投入,也不必为每个子任务手动挑模型。
@@ -97,9 +97,7 @@ AUTO 将选定 effort 应用于本次主请求;effort 改变时,等待官方
97
97
  ## 架构
98
98
  <!-- section: architecture -->
99
99
 
100
- ![AUTO 架构:独立判断、请求校验与分配反馈](docs/architecture-v2.zh.svg)
101
-
102
- 主判断失败停用 AUTO,子失败停止派发;保留显式 model/effort。图示为 AUTO 路径,MANUAL 不做新判断,fork 暂不支持。反馈不会自动改变路由。
100
+ 顶部的架构图为 AUTO 路径。主判断失败停用 AUTO,子失败停止派发;保留显式 model/effort。MANUAL 不做新判断,fork 暂不支持。反馈不会自动改变路由。
103
101
 
104
102
  入口:[index.ts](src/index.ts)。执行接入:[policy-host.ts](src/policy-host.ts)。实际判据:[rubric.json](src/rubric.json)。家族策略(支持的模型、transport、effort 起点、能力槽):[strategies](src/strategies/index.ts);路由计算:[routing.ts](src/routing.ts)。界面:[client](src/client/index.ts)。覆盖边界(历史记录):[开发验证](docs/policy-v2-development.md)、[真实 Magpie 验收](docs/magpie-live-acceptance.md)。
105
103
 
@@ -113,9 +111,7 @@ npm run build
113
111
 
114
112
  测试使用官方 AgentLoop、假 LLM 流和 mock/回环 Jev,不产生真实模型费用。如需观察 AUTO 决策及实际 token 用量,请明确让 agent 使用 `auto-model-routing-report` skill(仅按需调用,运行只读的 `lib/usage-report.js`);它是观察统计,不衡量节省;新数据结构与去重口径见[feedback-evaluation.md](docs/feedback-evaluation.md)。
115
113
 
116
- 继续在主仓开发,本仓库仅作发布镜像。插件使用自身声明的依赖、包内 skill 和官方 DSH 服务即可独立构建与加载,不要求检出主仓,也不要求安装主仓环境指令。外部设计链接仅供背景阅读,不是运行输入。
117
-
118
- [当前规范](https://github.com/prgrmrwy/ohmydsh/blob/main/openspec/specs/auto-model-routing/spec.md)在源码 monorepo 中维护。包内[委派 skill](skills/subagent-model-routing/SKILL.md)提供指引,执行保障来自官方宿主钩子,而不是提示词。暂无完整任务质量或 token 节省的量化结论。
114
+ 插件使用自身声明的依赖、包内 skill 和官方 DSH 服务即可独立构建与加载,不要求检出主仓,也不要求安装主仓环境指令。外部设计链接仅供背景阅读,不是运行输入。包内[委派 skill](skills/subagent-model-routing/SKILL.md)提供指引,执行保障来自官方宿主钩子,而不是提示词。暂无完整任务质量或 token 节省的量化结论。
119
115
 
120
116
  ## 移除
121
117
  <!-- section: removal -->
@@ -125,3 +121,9 @@ dsh plugin --profile web remove dsh-auto-model-routing
125
121
  ```
126
122
 
127
123
  重启 DSH 并刷新页面。插件的 skill、工具和请求钩子撤销,配置与日志保留。关闭插件后不再执行子路由恢复,宿主使用原始创建配置。在 [ohmydsh](https://github.com/prgrmrwy/ohmydsh) 中则禁用 local 条目并重新构建。
124
+
125
+ ## 来源、许可与参与
126
+
127
+ 本插件在 [ohmydsh](https://github.com/prgrmrwy/ohmydsh)(`packages/auto-model-routing`)中持续开发,[本仓库](https://github.com/prgrmrwy/dsh-auto-model-routing)是发布镜像,自动发布到 [npm](https://www.npmjs.com/package/dsh-auto-model-routing) 与 [GitHub Releases](https://github.com/prgrmrwy/dsh-auto-model-routing/releases)。[当前规范](https://github.com/prgrmrwy/ohmydsh/blob/main/openspec/specs/auto-model-routing/spec.md)也在主仓维护。
128
+
129
+ 采用 [MIT](LICENSE) 许可。欢迎到 [prgrmrwy/dsh-auto-model-routing](https://github.com/prgrmrwy/dsh-auto-model-routing/issues) 提 issue 和 PR,包括支持 Claude、GPT 以外的模型家族。
@@ -2,5 +2,5 @@
2
2
 
3
3
  | file | kind | source | date | reviewer | verdict | note |
4
4
  | --- | --- | --- | --- | --- | --- | --- |
5
- | auto-routing.gif | screenshot | isolated DSH_HOME under /tmp, port 3199; current plugin UI with local loopback mock Jev and model streaming responses; Playwright/CDP lossless 2x screenshots, global-palette GIF | 2026-10-28 | lead | pass (reviewed source frames: synthetic task/reply, complete conversation and decision panel; no real credentials or user data) | 1840x1600, 207702 bytes; MANUAL → AUTO, same-model medium → high, applied decision with returned distribution, back to MANUAL |
6
- | settings.gif | screenshot | same isolated host; lossless 2x screenshots box-filtered to 1137x1070 | 2026-10-28 | lead | pass (reviewed source frames: full settings dialog, empty key input, synthetic loopback endpoint; no real credentials) | 385416 bytes; decision service menu, successful mock connection test, default mode, effort cap, save and expand current strategy details; replaces the retired four-tier demo |
5
+ | auto-routing.png | screenshot | frame 5 of the 2026-10-28 recording (isolated DSH_HOME under /tmp, port 3199; current plugin UI with local loopback mock Jev and model streaming responses; Playwright/CDP lossless 2x screenshots), exported losslessly from the global-palette GIF | 2026-10-29 | lead | pass (reviewed frame: synthetic task/reply and decision panel; no real credentials or user data) | 1840x1600, 245191 bytes; applied same-model medium → high decision with the returned effort distribution; replaces auto-routing.gif because the plugin store does not show GIFs by default |
6
+ | settings.png | screenshot | frame 3 of the same 2026-10-28 recording (lossless 2x screenshots box-filtered to 1137x1070), exported losslessly from the GIF | 2026-10-29 | lead | pass (reviewed frame: settings dialog, empty key input, synthetic loopback endpoint; no real credentials) | 198719 bytes; decision service, decision model, compatible endpoint and a successful mock connection test; replaces settings.gif |
Binary file
Binary file
@@ -7,6 +7,12 @@ interface AutoSupport {
7
7
  model?: string;
8
8
  }
9
9
  interface View {
10
+ childNotice?: {
11
+ id: number;
12
+ childId: string;
13
+ reason: string;
14
+ at: number;
15
+ };
10
16
  mode: 'AUTO' | 'MANUAL';
11
17
  depth?: string;
12
18
  effort?: string;
@@ -38,6 +44,6 @@ export declare function RoutingChip({ sessionId, rpc, initialView, initiallyOpen
38
44
  onMouseLeave: () => void;
39
45
  onFocus: () => void;
40
46
  onBlur: () => void;
41
- }, HTMLSpanElement>;
47
+ }, HTMLSpanElement> | null;
42
48
  export declare function apply(ctx: Context): void;
43
49
  export {};
@@ -39,11 +39,13 @@ const CSS = `
39
39
  function SwapIcon() {
40
40
  return h('svg', { className: 'amr-icon amr-swap', width: 14, height: 14, viewBox: '0 0 16 16', fill: 'none', stroke: 'currentColor', strokeWidth: 1.4, strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': true }, h('path', { d: 'M3 5.5h9.5M10 3l2.5 2.5L10 8' }), h('path', { d: 'M13 10.5H3.5M6 8l-2.5 2.5L6 13' }));
41
41
  }
42
+ const noticeText = (notice) => `最近子任务未路由:${notice.reason}(按 DSH 默认运行)`;
42
43
  export function RoutingChip({ sessionId, rpc, initialView, initiallyOpen = false, initialToast, subscribeSelection }) {
43
44
  const [view, setView] = useState(initialView);
44
45
  const [open, setOpen] = useState(initiallyOpen);
45
46
  const [busy, setBusy] = useState(false);
46
47
  const [error, setError] = useState('');
48
+ const [notApplicable, setNotApplicable] = useState(false);
47
49
  const timer = useRef(undefined);
48
50
  const [toast, setToast] = useState(initialToast);
49
51
  // The composer hides the right-side controls while the agent runs (when Jev decides), so the hint is
@@ -79,6 +81,25 @@ export function RoutingChip({ sessionId, rpc, initialView, initiallyOpen = false
79
81
  remember();
80
82
  setToast(next);
81
83
  }, [view, sessionId]);
84
+ const seenNotice = useRef(undefined);
85
+ const noticePrimed = useRef(false);
86
+ useEffect(() => {
87
+ if (!view || viewSession.current !== sessionId)
88
+ return;
89
+ const notice = view.childNotice;
90
+ if (!noticePrimed.current) {
91
+ noticePrimed.current = true;
92
+ seenNotice.current = notice?.id;
93
+ return;
94
+ }
95
+ if (!notice || notice.id === seenNotice.current)
96
+ return;
97
+ seenNotice.current = notice.id;
98
+ if (!view.decisionToast)
99
+ return;
100
+ remember();
101
+ setToast({ id: -notice.id, lines: [noticeText(notice)] });
102
+ }, [view, sessionId]);
82
103
  // Hiding depends only on the toast shown, so later state changes cannot cancel it.
83
104
  useEffect(() => {
84
105
  if (!toast)
@@ -111,19 +132,34 @@ export function RoutingChip({ sessionId, rpc, initialView, initiallyOpen = false
111
132
  useEffect(() => {
112
133
  let active = true;
113
134
  primed.current = false;
135
+ noticePrimed.current = false;
114
136
  seen.current = undefined;
115
137
  viewSession.current = undefined;
116
138
  setToast(undefined);
117
- const refresh = () => { void callRouting(rpc, sessionId).then(value => { if (active) {
118
- viewSession.current = sessionId;
119
- setView(value);
120
- setError('');
121
- ensureSubscription();
122
- } }, reason => { if (active)
123
- setError(String(reason)); }); };
139
+ let poll;
140
+ const refresh = () => {
141
+ void callRouting(rpc, sessionId).then(value => {
142
+ if (!active)
143
+ return;
144
+ // Child sessions have no AUTO chip; their origin never changes, so stop polling for good.
145
+ if (value?.applicable === false) {
146
+ setNotApplicable(true);
147
+ clearInterval(poll);
148
+ subscription.current?.stop();
149
+ subscription.current = undefined;
150
+ return;
151
+ }
152
+ viewSession.current = sessionId;
153
+ setView(value);
154
+ setError('');
155
+ ensureSubscription();
156
+ }, reason => { if (active)
157
+ setError(String(reason)); });
158
+ };
124
159
  refreshRef.current = refresh;
160
+ setNotApplicable(false);
125
161
  refresh();
126
- const poll = setInterval(refresh, 3000);
162
+ poll = setInterval(refresh, 3000);
127
163
  return () => {
128
164
  active = false;
129
165
  clearInterval(poll);
@@ -180,8 +216,11 @@ export function RoutingChip({ sessionId, rpc, initialView, initiallyOpen = false
180
216
  h('p', { key: 'depth', className: 'amr-muted' }, `AUTO · ${view?.depth ?? '尚无判断'};主模型不变,推理投入按请求应用;子任务自动选配。`),
181
217
  off && h('p', { key: 'disabled', role: 'status', className: 'amr-error' }, `Jev 停用(${off.category}):${hint}。${new Date(off.at).toLocaleString()}。点击 AUTO 重试。`),
182
218
  h('p', { key: 'data', className: 'amr-muted' }, 'AUTO 会把脱敏任务文本发送给设置中所选的决策服务判断。'),
219
+ view?.childNotice && h('p', { key: 'notice', role: 'status', className: 'amr-muted' }, noticeText(view.childNotice)),
183
220
  error && h('p', { key: 'error', role: 'alert', className: 'amr-error' }, error),
184
221
  ];
222
+ if (notApplicable)
223
+ return null;
185
224
  return h('span', { ref: (el) => { rootRef.current = el; remember(); }, className: 'amr-root', onMouseEnter: on('enter'), onMouseLeave: on('leave'), onFocus: on('focus'), onBlur: on('blur') }, h('style', null, CSS), h('button', {
186
225
  type: 'button', className: 'amr-trigger', disabled: busy || !view, onClick: () => { if (locked)
187
226
  return; void toggle(); },
package/lib/client.js CHANGED
@@ -312,11 +312,6 @@ window.__ModuleLoader__.load({
312
312
  id: "claude",
313
313
  label: "Claude",
314
314
  modelPrefixes: ["claude-"],
315
- transports: {
316
- claude: "",
317
- "dsh-magpie-connect": "claude/",
318
- "dsh-magpie-connector": "claude/"
319
- },
320
315
  effortStarts: {
321
316
  "claude-opus-5-5": "medium",
322
317
  "claude-sonnet-5-5": "medium",
@@ -335,11 +330,6 @@ window.__ModuleLoader__.load({
335
330
  id: "codex",
336
331
  label: "GPT",
337
332
  modelPrefixes: ["gpt-"],
338
- transports: {
339
- codex: "",
340
- "dsh-magpie-connect": "codex/",
341
- "dsh-magpie-connector": "codex/"
342
- },
343
333
  effortStarts: {
344
334
  "gpt-6.1-sol": "medium",
345
335
  "gpt-6-luna": "high",
@@ -364,6 +354,14 @@ window.__ModuleLoader__.load({
364
354
  "xhigh"
365
355
  ];
366
356
  const bare = (model) => model.slice(model.lastIndexOf("/") + 1);
357
+ const prefixOf = (model) => model.slice(0, Math.max(0, model.lastIndexOf("/")));
358
+ const KEYS = /* @__PURE__ */ new Set([
359
+ "id",
360
+ "label",
361
+ "modelPrefixes",
362
+ "effortStarts",
363
+ "childModels"
364
+ ]);
367
365
  const overlaps = (a, b) => a.startsWith(b) || b.startsWith(a);
368
366
  /** Validates the whole list up front; any conflict throws and no partial registry exists. */
369
367
  function createRegistry(input) {
@@ -371,9 +369,9 @@ window.__ModuleLoader__.load({
371
369
  const ids = /* @__PURE__ */ new Set();
372
370
  const starts = /* @__PURE__ */ new Map();
373
371
  const prefixes = [];
374
- const childPrefixes = /* @__PURE__ */ new Map();
375
372
  for (const s of strategies) {
376
373
  if (!s.id) throw new Error("策略缺少 id");
374
+ for (const key of Object.keys(s)) if (!KEYS.has(key)) throw new Error(`策略 ${s.id} 含未知键:${key}`);
377
375
  if (ids.has(s.id)) throw new Error(`策略 id 重复:${s.id}`);
378
376
  ids.add(s.id);
379
377
  for (const prefix of s.modelPrefixes) {
@@ -391,18 +389,10 @@ window.__ModuleLoader__.load({
391
389
  if (starts.has(model)) throw new Error(`起点键重复:${model}(${starts.get(model)} 与 ${s.id})`);
392
390
  starts.set(model, s.id);
393
391
  }
394
- for (const capability of CAPABILITIES) if (typeof s.childModels?.[capability] !== "string" || !s.childModels[capability]) throw new Error(`策略 ${s.id} 缺少能力槽:${capability}`);
395
- for (const [provider, prefix] of Object.entries(s.transports)) {
396
- const existing = childPrefixes.get(provider) ?? [];
397
- const clash = existing.find((p) => p.prefix === "" || prefix === "" ? true : overlaps(p.prefix, prefix));
398
- if (clash) {
399
- if (clash.prefix === "" || prefix === "") throw new Error(`provider ${provider} 的空前缀只能属于一个策略(${clash.id} 与 ${s.id} 冲突)`);
400
- throw new Error(`provider ${provider} 下子模型前缀重叠:${prefix}(${s.id})与 ${clash.prefix}(${clash.id})`);
401
- }
402
- childPrefixes.set(provider, [...existing, {
403
- prefix,
404
- id: s.id
405
- }]);
392
+ for (const capability of CAPABILITIES) {
393
+ const slot = s.childModels?.[capability];
394
+ if (typeof slot !== "string" || !slot) throw new Error(`策略 ${s.id} 缺少能力槽:${capability}`);
395
+ if (slot.includes("/")) throw new Error(`策略 ${s.id} 的能力槽不能含 /:${slot}`);
406
396
  }
407
397
  }
408
398
  const byId = new Map(strategies.map((s) => [s.id, s]));
@@ -413,23 +403,17 @@ window.__ModuleLoader__.load({
413
403
  const name = bare(model).toLowerCase();
414
404
  return strategies.find((s) => s.modelPrefixes.some((p) => name.startsWith(p.toLowerCase())));
415
405
  },
416
- familyOfChild: (provider, model) => {
417
- const entries = childPrefixes.get(provider) ?? [];
418
- const hit = entries.find((e) => e.prefix !== "" && model.startsWith(e.prefix)) ?? (entries.length === 1 && entries[0].prefix === "" ? entries[0] : void 0);
419
- return hit ? byId.get(hit.id) : void 0;
420
- },
421
- childModel: (familyId, provider, capability) => {
422
- const s = byId.get(familyId);
423
- const prefix = s?.transports[provider];
424
- return s && prefix !== void 0 ? `${prefix}${s.childModels[capability]}` : void 0;
406
+ resolveSlot: (familyId, capability, parentModel, models) => {
407
+ const slot = byId.get(familyId)?.childModels[capability]?.toLowerCase();
408
+ if (!slot) return void 0;
409
+ const hits = models.filter((m) => bare(m).toLowerCase() === slot && prefixOf(m) === prefixOf(parentModel));
410
+ return hits.length === 1 ? hits[0] : void 0;
425
411
  },
426
412
  effortStart: (model) => {
427
413
  const name = bare(model);
428
414
  for (const s of strategies) if (Object.hasOwn(s.effortStarts, name)) return s.effortStarts[name];
429
415
  },
430
- hasProvider: (provider) => childPrefixes.has(provider),
431
416
  get: (familyId) => byId.get(familyId),
432
- providers: () => [...childPrefixes.keys()],
433
417
  ids: () => strategies.map((s) => s.id),
434
418
  labels: () => strategies.map((s) => s.label)
435
419
  });
@@ -842,11 +826,13 @@ window.__ModuleLoader__.load({
842
826
  "aria-hidden": true
843
827
  }, (0, react.createElement)("path", { d: "M3 5.5h9.5M10 3l2.5 2.5L10 8" }), (0, react.createElement)("path", { d: "M13 10.5H3.5M6 8l-2.5 2.5L6 13" }));
844
828
  }
829
+ const noticeText = (notice) => `最近子任务未路由:${notice.reason}(按 DSH 默认运行)`;
845
830
  function RoutingChip({ sessionId, rpc, initialView, initiallyOpen = false, initialToast, subscribeSelection }) {
846
831
  const [view, setView] = (0, react.useState)(initialView);
847
832
  const [open, setOpen] = (0, react.useState)(initiallyOpen);
848
833
  const [busy, setBusy] = (0, react.useState)(false);
849
834
  const [error, setError] = (0, react.useState)("");
835
+ const [notApplicable, setNotApplicable] = (0, react.useState)(false);
850
836
  const timer = (0, react.useRef)(void 0);
851
837
  const [toast, setToast] = (0, react.useState)(initialToast);
852
838
  const rootRef = (0, react.useRef)(null);
@@ -876,6 +862,25 @@ window.__ModuleLoader__.load({
876
862
  remember();
877
863
  setToast(next);
878
864
  }, [view, sessionId]);
865
+ const seenNotice = (0, react.useRef)(void 0);
866
+ const noticePrimed = (0, react.useRef)(false);
867
+ (0, react.useEffect)(() => {
868
+ if (!view || viewSession.current !== sessionId) return;
869
+ const notice = view.childNotice;
870
+ if (!noticePrimed.current) {
871
+ noticePrimed.current = true;
872
+ seenNotice.current = notice?.id;
873
+ return;
874
+ }
875
+ if (!notice || notice.id === seenNotice.current) return;
876
+ seenNotice.current = notice.id;
877
+ if (!view.decisionToast) return;
878
+ remember();
879
+ setToast({
880
+ id: -notice.id,
881
+ lines: [noticeText(notice)]
882
+ });
883
+ }, [view, sessionId]);
879
884
  (0, react.useEffect)(() => {
880
885
  if (!toast) return;
881
886
  const hide = setTimeout(() => setToast(void 0), TOAST_MS);
@@ -910,24 +915,33 @@ window.__ModuleLoader__.load({
910
915
  (0, react.useEffect)(() => {
911
916
  let active = true;
912
917
  primed.current = false;
918
+ noticePrimed.current = false;
913
919
  seen.current = void 0;
914
920
  viewSession.current = void 0;
915
921
  setToast(void 0);
922
+ let poll;
916
923
  const refresh = () => {
917
924
  callRouting(rpc, sessionId).then((value) => {
918
- if (active) {
919
- viewSession.current = sessionId;
920
- setView(value);
921
- setError("");
922
- ensureSubscription();
925
+ if (!active) return;
926
+ if (value?.applicable === false) {
927
+ setNotApplicable(true);
928
+ clearInterval(poll);
929
+ subscription.current?.stop();
930
+ subscription.current = void 0;
931
+ return;
923
932
  }
933
+ viewSession.current = sessionId;
934
+ setView(value);
935
+ setError("");
936
+ ensureSubscription();
924
937
  }, (reason) => {
925
938
  if (active) setError(String(reason));
926
939
  });
927
940
  };
928
941
  refreshRef.current = refresh;
942
+ setNotApplicable(false);
929
943
  refresh();
930
- const poll = setInterval(refresh, 3e3);
944
+ poll = setInterval(refresh, 3e3);
931
945
  return () => {
932
946
  active = false;
933
947
  clearInterval(poll);
@@ -1006,12 +1020,18 @@ window.__ModuleLoader__.load({
1006
1020
  key: "data",
1007
1021
  className: "amr-muted"
1008
1022
  }, "AUTO 会把脱敏任务文本发送给设置中所选的决策服务判断。"),
1023
+ view?.childNotice && (0, react.createElement)("p", {
1024
+ key: "notice",
1025
+ role: "status",
1026
+ className: "amr-muted"
1027
+ }, noticeText(view.childNotice)),
1009
1028
  error && (0, react.createElement)("p", {
1010
1029
  key: "error",
1011
1030
  role: "alert",
1012
1031
  className: "amr-error"
1013
1032
  }, error)
1014
1033
  ];
1034
+ if (notApplicable) return null;
1015
1035
  return (0, react.createElement)("span", {
1016
1036
  ref: (el) => {
1017
1037
  rootRef.current = el;
package/lib/index.js CHANGED
@@ -11,6 +11,7 @@ import { policyHost } from './policy-host.js';
11
11
  import { PolicyStore } from './policy-store.js';
12
12
  import { currentMainSelection, mainSupported } from './support.js';
13
13
  import { createRegistry, registry as builtin } from './strategies/index.js';
14
+ const SCOPE_NOTE = '按官方工具 ALS 上下文、同父与 startup 绑定;不能区分同窗口插件旁支,旁支可能一并受管。';
14
15
  export const name = 'dsh-auto-model-routing';
15
16
  /** Child decision records. The historical directory name is kept on purpose: records are not migrated. */
16
17
  export const CHILD_DECISIONS_DIR = 'state/auto-model-routing/policy-v2';
@@ -19,7 +20,7 @@ export function apply(ctx, config = {}) {
19
20
  const home = config.home ?? process.env.DSH_HOME ?? resolve(homedir(), '.dsh');
20
21
  const registry = config.strategies ? createRegistry(config.strategies) : builtin;
21
22
  const settings = new SettingsStore(resolve(home, 'plugins/dsh-auto-model-routing/config.json'));
22
- const hostSettings = settingsHost(ctx, settings, registry);
23
+ const hostSettings = settingsHost(ctx, settings);
23
24
  const jev = new JevClient({ get: hostSettings.credential }, config.jevBase, config.jevTimeoutMs ?? (() => settings.read().settings.jevTimeoutMs), config.jevBase ? undefined : () => settings.read().settings.decisionService);
24
25
  const stateFiles = new FileStateStore(resolve(home, 'state/auto-model-routing/sessions'));
25
26
  const state = new RoutingState(stateFiles);
@@ -27,7 +28,7 @@ export function apply(ctx, config = {}) {
27
28
  // Official writer is available only while the session controller is mounted.
28
29
  let selectModel;
29
30
  const policy = policyHost(ctx, { feedback, jev, state, selectModel: async (request) => { if (!selectModel)
30
- throw new Error('session controller is unavailable'); await selectModel(request); }, store: new PolicyStore(resolve(home, CHILD_DECISIONS_DIR), registry), settings: () => settings.read().settings, catalog: () => hostSettings.catalog(), registry });
31
+ throw new Error('session controller is unavailable'); await selectModel(request); }, store: new PolicyStore(resolve(home, CHILD_DECISIONS_DIR), registry), settings: () => settings.read().settings, catalog: provider => hostSettings.catalog(provider), registry });
31
32
  const toastOn = () => settings.read().settings.decisionToast;
32
33
  ctx.effect(() => ctx.tools.register(routeSubagentTool(jev, { agents: ctx.agents, state, readSettings: () => settings.read().settings, catalog: hostSettings.catalog, registry })));
33
34
  ctx.on('agent/created', ({ agent, source }) => {
@@ -57,6 +58,57 @@ export function apply(ctx, config = {}) {
57
58
  child.effect(() => () => { selectModel = undefined; });
58
59
  });
59
60
  ctx.inject(['connection', 'sessionController'], child => {
61
+ const modeRequest = async (operation, payload) => {
62
+ const resolveAgent = async (id) => {
63
+ const result = await child.sessionController.resolveAgent(id);
64
+ return 'agent' in result ? ref(result.agent) : undefined;
65
+ };
66
+ // Child sessions have no AUTO chip: answer before any support check, lock or decoration.
67
+ if (operation === 'state' && typeof payload?.sessionId === 'string') {
68
+ const target = await resolveAgent(payload.sessionId);
69
+ if (target?.origin === 'subagent')
70
+ return Response.json({ applicable: false });
71
+ }
72
+ let manualDecision;
73
+ if (operation === 'setMode' && typeof payload?.sessionId === 'string') {
74
+ const id = payload.sessionId, mode = payload.mode;
75
+ if ((mode === 'AUTO' || mode === 'MANUAL') && state.read(id).mode !== mode && await resolveAgent(id)) {
76
+ const decisionId = policy.appliedDecision(id);
77
+ if (mode === 'MANUAL' && decisionId !== undefined)
78
+ manualDecision = { sessionId: id, decisionId };
79
+ policy.invalidate(id);
80
+ }
81
+ }
82
+ const response = await endpoint(operation, payload, resolveAgent, state);
83
+ if (!response.ok)
84
+ return response;
85
+ if (manualDecision) {
86
+ try {
87
+ feedback.fact(manualDecision.sessionId, manualDecision.decisionId, 'manual-switch');
88
+ }
89
+ catch {
90
+ ctx.logger.warn('auto-model-routing: could not record MANUAL switch');
91
+ }
92
+ }
93
+ const sessionId = payload.sessionId;
94
+ const resolved = await child.sessionController.resolveAgent(sessionId).catch(() => undefined);
95
+ const selection = resolved && 'agent' in resolved ? currentMainSelection(ctx, resolved.agent) : undefined;
96
+ const supported = mainSupported(selection, registry);
97
+ if (!supported && state.read(sessionId).mode === 'AUTO') {
98
+ state.setMode(sessionId, 'MANUAL');
99
+ policy.invalidate(sessionId);
100
+ }
101
+ const { previous: _previous, ...persisted } = await response.json();
102
+ return Response.json({ ...persisted, mode: state.read(sessionId).mode, ...policy.mainStatus(sessionId), ...(policy.childNotice(sessionId) ? { childNotice: policy.childNotice(sessionId) } : {}), ...(toastOn() ? { decisionToast: true } : {}), autoSupport: { supported, ...(selection ? { provider: selection.provider, model: selection.model } : {}) } });
103
+ };
104
+ child.provide('autoModelRouting', { call: async (operation, payload) => {
105
+ if (operation !== 'state' && operation !== 'setMode')
106
+ throw new Error('Unsupported routing operation');
107
+ const response = await modeRequest(operation, payload);
108
+ if (!response.ok)
109
+ throw new Error('Routing mode request rejected');
110
+ return response.json();
111
+ } });
60
112
  for (const operation of ['state', 'setMode', 'label', 'observe', 'settings', 'saveSettings', 'setKey', 'testDecision', 'childRouting']) {
61
113
  child.effect(() => child.connection.fetch.register({
62
114
  path: `/api/auto-model-routing.${operation}`,
@@ -77,54 +129,15 @@ export function apply(ctx, config = {}) {
77
129
  const result = await child.sessionController.resolveAgent(id);
78
130
  return 'agent' in result ? ref(result.agent) : undefined;
79
131
  };
80
- const resolveSessionAgent = async (id) => {
81
- const result = await child.sessionController.resolveAgent(id);
82
- return 'agent' in result ? result.agent : undefined;
83
- };
84
132
  const payload = enveloped ? wire.payload : body;
85
- let manualDecision;
86
- if (operation === 'setMode' && typeof payload?.sessionId === 'string') {
87
- const id = payload.sessionId;
88
- const mode = payload.mode;
89
- if ((mode === 'AUTO' || mode === 'MANUAL') && state.read(id).mode !== mode && await resolveAgent(id)) {
90
- const decisionId = policy.appliedDecision(id);
91
- if (mode === 'MANUAL' && decisionId !== undefined)
92
- manualDecision = { sessionId: id, decisionId };
93
- policy.invalidate(id);
94
- }
95
- }
96
133
  const response = operation === 'childRouting'
97
134
  ? (typeof payload?.sessionId === 'string'
98
- ? Response.json(policy.status(payload.sessionId) ?? { status: 'unsupported', reason: '没有已绑定的子任务决策' })
135
+ ? Response.json((record => record ? { ...record, scopeNote: SCOPE_NOTE } : { status: 'unsupported', reason: '没有已绑定的子任务决策' })(policy.status(payload.sessionId)))
99
136
  : Response.json({ error: 'invalid request' }, { status: 400 }))
100
137
  : operation === 'settings' || operation === 'saveSettings' || operation === 'setKey' || operation === 'testDecision'
101
138
  ? await hostSettings.endpoint(operation, payload)
102
139
  : operation === 'observe' ? await observeEndpoint(payload, resolveAgent, feedback, () => ctx.logger.warn('auto-model-routing: could not record exposure'))
103
- : operation === 'label' ? await labelEndpoint(payload, resolveAgent, feedback) : await endpoint(operation, payload, resolveAgent, state);
104
- if (manualDecision && response.ok) {
105
- try {
106
- feedback.fact(manualDecision.sessionId, manualDecision.decisionId, 'manual-switch');
107
- }
108
- catch {
109
- ctx.logger.warn('auto-model-routing: could not record MANUAL switch');
110
- }
111
- }
112
- if ((operation === 'state' || operation === 'setMode') && response.ok) {
113
- const sessionId = payload.sessionId;
114
- const agent = await resolveSessionAgent(sessionId).catch(() => undefined);
115
- const selection = agent ? currentMainSelection(ctx, agent) : undefined;
116
- const supported = mainSupported(selection, registry);
117
- // Leaving AUTO is persisted: a pick outside the registered families must not keep judging turns.
118
- if (!supported && state.read(sessionId).mode === 'AUTO') {
119
- state.setMode(sessionId, 'MANUAL');
120
- policy.invalidate(sessionId);
121
- }
122
- const value = await response.json();
123
- const main = policy.mainStatus(sessionId);
124
- const { previous: _previous, ...persisted } = value;
125
- const result = { ...persisted, mode: state.read(sessionId).mode, ...main, ...(toastOn() ? { decisionToast: true } : {}), autoSupport: { supported, ...(selection ? { provider: selection.provider, model: selection.model } : {}) } };
126
- return Response.json(enveloped ? { type: 'server-response', rpcId: wire.rpcId, result: { ok: true, value: result } } : result);
127
- }
140
+ : operation === 'label' ? await labelEndpoint(payload, resolveAgent, feedback) : await modeRequest(operation, payload);
128
141
  if (!enveloped)
129
142
  return response;
130
143
  const value = await response.json();
@@ -17,16 +17,22 @@ export declare function policyHost(ctx: Context, deps: {
17
17
  state: RoutingState;
18
18
  store: PolicyStore;
19
19
  settings: () => Settings;
20
- catalog: () => Promise<RoutingCatalog>;
20
+ catalog: (provider?: string) => Promise<RoutingCatalog>;
21
21
  registry?: Registry;
22
22
  }): {
23
23
  appliedDecision: (id: string) => number | undefined;
24
24
  invalidate: (id: string) => void;
25
+ childNotice: (rootId: string) => {
26
+ id: number;
27
+ childId: string;
28
+ reason: string;
29
+ at: number;
30
+ } | undefined;
25
31
  status: (id: string) => {
26
32
  childId: string;
27
33
  parentId: string;
28
34
  rootId: string;
29
- status: "failed" | "recommended" | "unsupported" | "needs-context" | "applied";
35
+ status: "failed" | "recommended" | "needs-context" | "unsupported" | "applied";
30
36
  path: "ordinary" | "fork" | "teammate";
31
37
  explicit: {
32
38
  provider: string;
@@ -34,6 +40,11 @@ export declare function policyHost(ctx: Context, deps: {
34
40
  reasoningEffort?: string | undefined;
35
41
  };
36
42
  family?: string | undefined;
43
+ parentSelection?: {
44
+ provider: string;
45
+ model?: string | undefined;
46
+ reasoningEffort?: string | undefined;
47
+ } | undefined;
37
48
  decision?: {
38
49
  id: number;
39
50
  source: "main" | "subagent";