dsh-auto-model-routing 0.4.2 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +10 -15
  3. package/README.zh.md +10 -15
  4. package/docs/SCREENSHOTS.md +2 -2
  5. package/docs/auto-routing.gif +0 -0
  6. package/docs/feedback-evaluation.md +4 -4
  7. package/docs/host-capability-report.md +2 -0
  8. package/docs/jev-policy-v2-validation.md +3 -1
  9. package/docs/magpie-live-acceptance.md +2 -0
  10. package/docs/policy-v2-development.md +5 -3
  11. package/docs/policy-v2-real-evaluation-plan.md +2 -0
  12. package/docs/settings.gif +0 -0
  13. package/lib/client/control.d.ts +2 -2
  14. package/lib/client/control.js +10 -20
  15. package/lib/client/index.d.ts +0 -1
  16. package/lib/client/index.js +9 -6
  17. package/lib/client/label.d.ts +7 -10
  18. package/lib/client/label.js +17 -30
  19. package/lib/client/settings-section.d.ts +2 -2
  20. package/lib/client/settings-section.js +7 -69
  21. package/lib/client/strategy-details.js +3 -4
  22. package/lib/client.js +165 -481
  23. package/lib/context.d.ts +1 -3
  24. package/lib/context.js +3 -4
  25. package/lib/effort.d.ts +2 -10
  26. package/lib/effort.js +1 -31
  27. package/lib/endpoints.d.ts +11 -10
  28. package/lib/endpoints.js +29 -46
  29. package/lib/feedback.d.ts +60 -7
  30. package/lib/feedback.js +15 -5
  31. package/lib/index.d.ts +4 -0
  32. package/lib/index.js +17 -86
  33. package/lib/jev.d.ts +17 -29
  34. package/lib/jev.js +6 -34
  35. package/lib/policy-host.d.ts +6 -8
  36. package/lib/policy-host.js +39 -38
  37. package/lib/policy-store.d.ts +9 -20
  38. package/lib/policy-store.js +28 -16
  39. package/lib/routing.d.ts +20 -0
  40. package/lib/routing.js +51 -0
  41. package/lib/rubric.json +15 -6
  42. package/lib/settings-contract.d.ts +5 -21
  43. package/lib/settings-contract.js +3 -2
  44. package/lib/settings-host.d.ts +4 -8
  45. package/lib/settings-host.js +9 -10
  46. package/lib/settings-validation.d.ts +1 -7
  47. package/lib/settings-validation.js +0 -51
  48. package/lib/settings.d.ts +3 -8
  49. package/lib/settings.js +18 -177
  50. package/lib/state.d.ts +2 -4
  51. package/lib/state.js +2 -7
  52. package/lib/strategies/claude.d.ts +21 -0
  53. package/lib/strategies/claude.js +8 -0
  54. package/lib/strategies/codex.d.ts +21 -0
  55. package/lib/strategies/codex.js +9 -0
  56. package/lib/strategies/index.d.ts +22 -0
  57. package/lib/strategies/index.js +85 -0
  58. package/lib/strategies/types.d.ts +14 -0
  59. package/lib/strategies/types.js +1 -0
  60. package/lib/subagent-tool.d.ts +7 -7
  61. package/lib/subagent-tool.js +33 -76
  62. package/lib/support.d.ts +7 -8
  63. package/lib/support.js +9 -13
  64. package/lib/usage-report.d.ts +2 -2
  65. package/lib/usage-report.js +25 -17
  66. package/package.json +3 -4
  67. package/skills/auto-model-routing-report/SKILL.md +4 -3
  68. package/skills/subagent-model-routing/SKILL.md +14 -30
  69. package/lib/client/route-picker.d.ts +0 -21
  70. package/lib/client/route-picker.js +0 -75
  71. package/lib/judgements.d.ts +0 -41
  72. package/lib/judgements.js +0 -55
  73. package/lib/policy-v2-rubric.json +0 -21
  74. package/lib/policy-v2.d.ts +0 -21
  75. package/lib/policy-v2.js +0 -54
  76. package/lib/router.d.ts +0 -68
  77. package/lib/router.js +0 -153
  78. package/scripts/label-report.mjs +0 -50
package/lib/state.js CHANGED
@@ -2,7 +2,8 @@ import { mkdirSync, readFileSync, writeFileSync, renameSync } from 'node:fs';
2
2
  import { resolve, dirname } from 'node:path';
3
3
  import { createHash } from 'node:crypto';
4
4
  import { z } from 'zod';
5
- // Legacy files also carried `restore`/`anchor`; they are ignored (non-strict) and dropped on the next write.
5
+ // Older files also carried `restore`/`anchor` (ignored, non-strict, dropped on the next write) and
6
+ // `previous` (a retired tier name: still accepted and kept as is, never written anew).
6
7
  const stateSchema = z.object({
7
8
  mode: z.enum(['AUTO', 'MANUAL']),
8
9
  previous: z.enum(['simple', 'standard', 'complex', 'escalated']).optional(),
@@ -56,12 +57,6 @@ export class RoutingState {
56
57
  const state = this.read(id);
57
58
  return this.save(id, { mode: 'MANUAL', ...(state.previous ? { previous: state.previous } : {}), disabled: { category, at } });
58
59
  }
59
- /** Keep the latest user-turn tier (name only) so a restart still sends it to Jev as context. */
60
- remember(id, tier) {
61
- const state = this.read(id);
62
- if (state.previous !== tier)
63
- this.save(id, { ...state, previous: tier });
64
- }
65
60
  save(id, state) {
66
61
  this.store.set(id, structuredClone(state));
67
62
  return structuredClone(state);
@@ -0,0 +1,21 @@
1
+ export declare const claude: {
2
+ readonly id: "claude";
3
+ readonly label: "Claude";
4
+ readonly modelPrefixes: readonly ["claude-"];
5
+ readonly transports: {
6
+ readonly claude: "";
7
+ readonly 'dsh-magpie-connect': "claude/";
8
+ readonly 'dsh-magpie-connector': "claude/";
9
+ };
10
+ readonly effortStarts: {
11
+ readonly 'claude-opus-5-5': "medium";
12
+ readonly 'claude-sonnet-5-5': "medium";
13
+ readonly 'claude-haiku-5-5': "medium";
14
+ readonly 'claude-fable-5-1': "high";
15
+ };
16
+ readonly childModels: {
17
+ readonly bounded: "claude-haiku-5-5";
18
+ readonly analytical: "claude-sonnet-5-5";
19
+ readonly frontier: "claude-opus-5-5";
20
+ };
21
+ };
@@ -0,0 +1,8 @@
1
+ export const claude = {
2
+ id: 'claude',
3
+ label: 'Claude',
4
+ modelPrefixes: ['claude-'],
5
+ transports: { claude: '', 'dsh-magpie-connect': 'claude/', 'dsh-magpie-connector': 'claude/' },
6
+ effortStarts: { 'claude-opus-5-5': 'medium', 'claude-sonnet-5-5': 'medium', 'claude-haiku-5-5': 'medium', 'claude-fable-5-1': 'high' },
7
+ childModels: { bounded: 'claude-haiku-5-5', analytical: 'claude-sonnet-5-5', frontier: 'claude-opus-5-5' },
8
+ };
@@ -0,0 +1,21 @@
1
+ /** GPT family; the id stays `codex` (the local transport name). */
2
+ export declare const codex: {
3
+ readonly id: "codex";
4
+ readonly label: "GPT";
5
+ readonly modelPrefixes: readonly ["gpt-"];
6
+ readonly transports: {
7
+ readonly codex: "";
8
+ readonly 'dsh-magpie-connect': "codex/";
9
+ readonly 'dsh-magpie-connector': "codex/";
10
+ };
11
+ readonly effortStarts: {
12
+ readonly 'gpt-6.1-sol': "medium";
13
+ readonly 'gpt-6-luna': "high";
14
+ readonly 'gpt-6-astra': "low";
15
+ };
16
+ readonly childModels: {
17
+ readonly bounded: "gpt-6-luna";
18
+ readonly analytical: "gpt-6.1-sol";
19
+ readonly frontier: "gpt-6.1-sol";
20
+ };
21
+ };
@@ -0,0 +1,9 @@
1
+ /** GPT family; the id stays `codex` (the local transport name). */
2
+ export const codex = {
3
+ id: 'codex',
4
+ label: 'GPT',
5
+ modelPrefixes: ['gpt-'],
6
+ transports: { codex: '', 'dsh-magpie-connect': 'codex/', 'dsh-magpie-connector': 'codex/' },
7
+ effortStarts: { 'gpt-6.1-sol': 'medium', 'gpt-6-luna': 'high', 'gpt-6-astra': 'low' },
8
+ childModels: { bounded: 'gpt-6-luna', analytical: 'gpt-6.1-sol', frontier: 'gpt-6.1-sol' },
9
+ };
@@ -0,0 +1,22 @@
1
+ import type { Capability, FamilyStrategy, StartEffort } from './types.js';
2
+ export type { Capability, FamilyStrategy, StartEffort } from './types.js';
3
+ export interface Registry {
4
+ readonly strategies: readonly FamilyStrategy[];
5
+ /** Main-model support decision; undefined means AUTO does not manage this model. */
6
+ familyOfMainModel(model: string | undefined): FamilyStrategy | undefined;
7
+ /** Family of a child model on an exact transport provider. */
8
+ familyOfChild(provider: string, model: string): FamilyStrategy | undefined;
9
+ /** Child model id for a family on a transport: transport prefix + capability slot. */
10
+ childModel(familyId: string, provider: string, capability: Capability): string | undefined;
11
+ effortStart(model: string): StartEffort | undefined;
12
+ hasProvider(provider: string): boolean;
13
+ get(familyId: string): FamilyStrategy | undefined;
14
+ providers(): string[];
15
+ ids(): string[];
16
+ labels(): string[];
17
+ }
18
+ /** Validates the whole list up front; any conflict throws and no partial registry exists. */
19
+ export declare function createRegistry(input: readonly FamilyStrategy[]): Registry;
20
+ export declare const BUILTIN_STRATEGIES: readonly FamilyStrategy[];
21
+ /** The registry every module uses unless a test injects strategies through `apply(ctx, { strategies })`. */
22
+ export declare const registry: Registry;
@@ -0,0 +1,85 @@
1
+ import { claude } from './claude.js';
2
+ import { codex } from './codex.js';
3
+ const CAPABILITIES = ['bounded', 'analytical', 'frontier'];
4
+ const START_EFFORTS = ['low', 'medium', 'high', 'xhigh'];
5
+ const bare = (model) => model.slice(model.lastIndexOf('/') + 1);
6
+ const overlaps = (a, b) => a.startsWith(b) || b.startsWith(a);
7
+ /** Validates the whole list up front; any conflict throws and no partial registry exists. */
8
+ export function createRegistry(input) {
9
+ const strategies = input.map(s => structuredClone(s));
10
+ const ids = new Set();
11
+ const starts = new Map();
12
+ const prefixes = [];
13
+ const childPrefixes = new Map();
14
+ for (const s of strategies) {
15
+ if (!s.id)
16
+ throw new Error('策略缺少 id');
17
+ if (ids.has(s.id))
18
+ throw new Error(`策略 id 重复:${s.id}`);
19
+ ids.add(s.id);
20
+ for (const prefix of s.modelPrefixes) {
21
+ const lower = prefix.toLowerCase();
22
+ if (!lower)
23
+ throw new Error(`策略 ${s.id} 的模型前缀为空`);
24
+ const clash = prefixes.find(p => overlaps(p.prefix.toLowerCase(), lower));
25
+ if (clash)
26
+ throw new Error(`模型前缀重叠:${prefix}(${s.id})与 ${clash.prefix}(${clash.id})`);
27
+ prefixes.push({ prefix, id: s.id });
28
+ }
29
+ for (const [model, effort] of Object.entries(s.effortStarts)) {
30
+ if (!START_EFFORTS.includes(effort))
31
+ throw new Error(`策略 ${s.id} 的起点 effort 非法:${model}=${String(effort)}`);
32
+ if (starts.has(model))
33
+ throw new Error(`起点键重复:${model}(${starts.get(model)} 与 ${s.id})`);
34
+ starts.set(model, s.id);
35
+ }
36
+ for (const capability of CAPABILITIES)
37
+ if (typeof s.childModels?.[capability] !== 'string' || !s.childModels[capability])
38
+ throw new Error(`策略 ${s.id} 缺少能力槽:${capability}`);
39
+ for (const [provider, prefix] of Object.entries(s.transports)) {
40
+ const existing = childPrefixes.get(provider) ?? [];
41
+ const clash = existing.find(p => p.prefix === '' || prefix === '' ? true : overlaps(p.prefix, prefix));
42
+ if (clash) {
43
+ if (clash.prefix === '' || prefix === '')
44
+ throw new Error(`provider ${provider} 的空前缀只能属于一个策略(${clash.id} 与 ${s.id} 冲突)`);
45
+ throw new Error(`provider ${provider} 下子模型前缀重叠:${prefix}(${s.id})与 ${clash.prefix}(${clash.id})`);
46
+ }
47
+ childPrefixes.set(provider, [...existing, { prefix, id: s.id }]);
48
+ }
49
+ }
50
+ const byId = new Map(strategies.map(s => [s.id, s]));
51
+ return Object.freeze({
52
+ strategies,
53
+ familyOfMainModel: (model) => {
54
+ if (typeof model !== 'string' || !model)
55
+ return undefined;
56
+ const name = bare(model).toLowerCase();
57
+ return strategies.find(s => s.modelPrefixes.some(p => name.startsWith(p.toLowerCase())));
58
+ },
59
+ familyOfChild: (provider, model) => {
60
+ const entries = childPrefixes.get(provider) ?? [];
61
+ const hit = entries.find(e => e.prefix !== '' && model.startsWith(e.prefix)) ?? (entries.length === 1 && entries[0].prefix === '' ? entries[0] : undefined);
62
+ return hit ? byId.get(hit.id) : undefined;
63
+ },
64
+ childModel: (familyId, provider, capability) => {
65
+ const s = byId.get(familyId);
66
+ const prefix = s?.transports[provider];
67
+ return s && prefix !== undefined ? `${prefix}${s.childModels[capability]}` : undefined;
68
+ },
69
+ effortStart: (model) => {
70
+ const name = bare(model);
71
+ for (const s of strategies)
72
+ if (Object.hasOwn(s.effortStarts, name))
73
+ return s.effortStarts[name];
74
+ return undefined;
75
+ },
76
+ hasProvider: (provider) => childPrefixes.has(provider),
77
+ get: (familyId) => byId.get(familyId),
78
+ providers: () => [...childPrefixes.keys()],
79
+ ids: () => strategies.map(s => s.id),
80
+ labels: () => strategies.map(s => s.label),
81
+ });
82
+ }
83
+ export const BUILTIN_STRATEGIES = [claude, codex];
84
+ /** The registry every module uses unless a test injects strategies through `apply(ctx, { strategies })`. */
85
+ export const registry = createRegistry(BUILTIN_STRATEGIES);
@@ -0,0 +1,14 @@
1
+ export type Capability = 'bounded' | 'analytical' | 'frontier';
2
+ export type StartEffort = 'low' | 'medium' | 'high' | 'xhigh';
3
+ /** Everything AUTO knows about one model family. Pure data: engine rules (depth offsets, caps) stay out. */
4
+ export interface FamilyStrategy {
5
+ readonly id: string;
6
+ readonly label: string;
7
+ /** Main-model support: matched case-insensitively against the id after its last `/`. */
8
+ readonly modelPrefixes: readonly string[];
9
+ /** Transport provider → model-id prefix for this family's child models on that transport. */
10
+ readonly transports: Readonly<Record<string, string>>;
11
+ /** Exact bare model id → recommended starting effort. */
12
+ readonly effortStarts: Readonly<Record<string, StartEffort>>;
13
+ readonly childModels: Readonly<Record<Capability, string>>;
14
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -1,7 +1,7 @@
1
- import { type Settings, type RoutingCatalog } from './settings-contract.js';
2
- import { type JevClient } from './jev.js';
3
- import type { Judgement } from './router.js';
1
+ import type { Settings, RoutingCatalog } from './settings-contract.js';
2
+ import type { JevClient } from './jev.js';
4
3
  import type { RoutingState } from './state.js';
4
+ import { type Registry } from './strategies/index.js';
5
5
  interface SessionLike {
6
6
  id: string;
7
7
  session: {
@@ -17,10 +17,10 @@ export interface ToolDeps {
17
17
  get(id: string): SessionLike | undefined;
18
18
  };
19
19
  state: RoutingState;
20
- readSettings?: () => Settings;
21
- catalog?: () => RoutingCatalog | Promise<RoutingCatalog>;
22
- judged?: (sessionId: string, value: Omit<Judgement, 'id'>) => void;
20
+ readSettings: () => Settings;
21
+ catalog: () => RoutingCatalog | Promise<RoutingCatalog>;
22
+ registry?: Registry;
23
23
  }
24
- /** Sub-agent decision tool: same Jev judgment as the main session, only while the root session is AUTO. Writes no state. */
24
+ /** Preview only: the same Jev judgment AUTO uses, while the root session is AUTO. Writes no state. */
25
25
  export declare function routeSubagentTool(jev: JevClient, deps: ToolDeps): import("@deepseek-ai/dsh-tools").ToolDefinition;
26
26
  export {};
@@ -1,12 +1,7 @@
1
1
  import { defineTool } from '@deepseek-ai/dsh-tools';
2
- import { CHILD_MODELS, mapEffort, tiers } from './effort.js';
3
- import { explicitChildRoute, SettingsValidationError } from './settings.js';
4
- import { DEFAULT_SETTINGS } from './settings-contract.js';
5
- import { validateChildRoute } from './settings-validation.js';
6
- import { CATEGORY_HINTS } from './jev.js';
7
2
  import { buildState } from './context.js';
8
- import { choosePolicyRoute, magpieProvider, modelFamily, POLICY_PROVIDERS } from './policy-v2.js';
9
- import { policyVersion } from './jev.js';
3
+ import { choosePolicyRoute, modelFamily } from './routing.js';
4
+ import { registry as builtin } from './strategies/index.js';
10
5
  /** The ordinary session a caller belongs to; undefined when any link is no longer live (treated as MANUAL). */
11
6
  function rootOf(caller, agents) {
12
7
  let current = caller;
@@ -18,20 +13,21 @@ function rootOf(caller, agents) {
18
13
  }
19
14
  return undefined;
20
15
  }
21
- const MANUAL_TEXT = '主会话为 MANUAL:不判档,按官方默认委派;只传用户或 lead 指定的值(例如指定的模型),不传其它 provider/model/reasoning_effort。';
22
- /** Sub-agent decision tool: same Jev judgment as the main session, only while the root session is AUTO. Writes no state. */
16
+ const MANUAL_TEXT = '主会话为 MANUAL:不判断,按官方默认委派;只传用户或 lead 指定的值(例如指定的模型),不传其它 provider/model/reasoning_effort。';
17
+ /** Preview only: the same Jev judgment AUTO uses, while the root session is AUTO. Writes no state. */
23
18
  export function routeSubagentTool(jev, deps) {
19
+ const registry = deps.registry ?? builtin;
24
20
  return defineTool({
25
21
  name: 'route_subagent',
26
- description: 'When policy-v2 is selected, this tool is an optional preview only: use the official fresh creation tool directly for exact-child automatic routing, do not turn preview output into explicit user constraints; fork is unsupported. Under the legacy policy: Call before every sub-agent delegation, whatever the task size. Picks provider/model/reasoning_effort with the Jev decision model; pass provider, model and reasoning_effort explicitly to the sub-agent tool, or the sub-agent inherits this session\'s effort. Sends the task text (redacted, up to 8000 characters) to OpenRouter/TypeSafe only while the main session is AUTO; otherwise returns routed=false: delegate with the official defaults. If the user or lead already chose the sub-agent model, pass it as model: only reasoning_effort is routed and the model is kept. On error: pause delegation and ask the user; never guess a tier.',
27
- timeoutMs: 5000,
22
+ description: 'Optional preview of the AUTO sub-agent route: returns a recommendation only, never an applied route. Automatic routing happens when the official fresh creation tool creates the exact child, before its first request; use that tool directly and do not turn this recommendation into an explicit user constraint. Fork is unsupported. Sends the task text (redacted, up to 8000 characters) to the decision service chosen in settings only while the main session is AUTO; otherwise returns routed=false: delegate with the official defaults. If the user or lead already chose the model or effort, pass it: only the missing dimension is judged. On error: pause delegation and ask the user; never guess or switch models.',
23
+ timeoutMs: 10000,
28
24
  parameters: {
29
25
  task: { type: 'string', required: true, description: 'The task the sub-agent will do.' },
30
- provider: { type: 'string', required: true, enum: [...POLICY_PROVIDERS], description: 'Exact transport provider. Magpie is available only under policy-v2.' },
31
- model: { type: 'string', description: 'Sub-agent model already chosen by the user or lead; when given, only reasoning_effort is routed.' },
32
- reasoning_effort: { type: 'string', description: 'Explicit effort constraint for policy-v2; preserved and validated, never silently replaced.' },
33
- family: { type: 'string', enum: ['claude', 'codex'], description: 'Magpie automatic capability family; required when no model is explicitly supplied.' },
34
- path: { type: 'string', enum: ['ordinary', 'fork', 'teammate'], description: 'policy-v2 creation path; fork remains unsupported until real history compatibility is verified.' },
26
+ provider: { type: 'string', required: true, enum: registry.providers(), description: 'Exact transport provider of the sub-agent.' },
27
+ model: { type: 'string', description: 'Sub-agent model already chosen by the user or lead; when given, only reasoning_effort is judged.' },
28
+ reasoning_effort: { type: 'string', description: 'Effort already chosen by the user or lead; preserved and validated, never silently replaced.' },
29
+ family: { type: 'string', enum: registry.ids(), description: 'Model family for an automatic model on a transport shared by several families; required there when no model is supplied.' },
30
+ path: { type: 'string', enum: ['ordinary', 'fork', 'teammate'], description: 'Creation path; fork is unsupported.' },
35
31
  },
36
32
  output: {
37
33
  schema: {
@@ -40,82 +36,43 @@ export function routeSubagentTool(jev, deps) {
40
36
  properties: {
41
37
  routed: { type: 'boolean' },
42
38
  status: { type: 'string' },
43
- policyVersion: { type: 'string' },
44
39
  capability: { type: 'string' },
45
40
  depth: { type: 'string' },
46
41
  reason: { type: 'string' },
47
- tier: { type: 'string' },
48
42
  provider: { type: 'string' },
49
43
  model: { type: 'string' },
50
44
  reasoning_effort: { type: 'string' },
51
45
  },
52
46
  },
53
- render: (_args, v) => [{ type: 'text', text: v.status ? (v.status === 'recommended'
54
- ? `policy-v2 recommended only (not applied): ${v.provider}/${v.model}/${v.reasoning_effort}. Use the official creation tool; AUTO judges and binds the exact child before its first request. Do not copy this recommendation as a new explicit user constraint. Query the child routing status for applied evidence.`
55
- : `policy-v2 ${v.status}: ${v.reason}. 暂停委派并询问用户;不将推荐冒充实际应用。`)
56
- : v.routed === false
57
- ? MANUAL_TEXT
58
- : `Delegate with provider=${v.provider} model=${v.model} reasoning_effort=${v.reasoning_effort} (tier ${v.tier}); label the description [${v.model}·${v.reasoning_effort}].` }],
47
+ render: (_args, v) => [{ type: 'text', text: v.status === 'recommended'
48
+ ? `Recommended only (not applied): ${v.provider}/${v.model}/${v.reasoning_effort}. Use the official creation tool; AUTO judges and binds the exact child before its first request. Do not copy this recommendation as a new explicit user constraint. Query the child routing status for applied evidence.`
49
+ : v.status ? `${v.status}: ${v.reason}. 暂停委派并询问用户;不将推荐冒充实际应用。`
50
+ : MANUAL_TEXT }],
59
51
  },
60
52
  execute: async (args, exec) => {
61
- const caller = exec.agent;
62
- const root = rootOf(caller, deps.agents);
53
+ const root = rootOf(exec.agent, deps.agents);
63
54
  // MANUAL (or disabled, or an unreachable root): no judgement and nothing leaves the machine.
64
55
  if (!root || deps.state.read(root.id).mode !== 'AUTO')
65
56
  return { routed: false, reason: 'manual' };
66
- if (deps.readSettings?.().policy === 'policy-v2') {
67
- if (args.path === 'fork')
68
- return { routed: false, status: 'unsupported', policyVersion, reason: 'fork 真实历史兼容尚未验证;选择 fresh 或由用户关闭试验后继承' };
69
- const explicit = { ...(args.model ? { model: args.model } : {}), ...(args.reasoning_effort ? { reasoningEffort: args.reasoning_effort } : {}) };
70
- const result = explicit.model && explicit.reasoningEffort ? { kind: 'ok', depth: 'baseline', capability: 'bounded' }
71
- : await jev.judgePolicy(buildState({ text: args.task }), exec.signal, 'child', explicit.model ? 'depth' : explicit.reasoningEffort ? 'capability' : 'both');
72
- if (result.kind !== 'ok')
73
- throw new Error('policy-v2 Jev 判断失败或取消;暂停委派并询问用户');
74
- if (result.capability === 'insufficient_context')
75
- return { routed: false, status: 'needs-context', policyVersion, reason: '请补充任务目标与范围' };
76
- const route = choosePolicyRoute(args.provider, result.capability ?? 'bounded', result.depth, await deps.catalog(), deps.readSettings().effortCap, explicit, args.family ?? (args.model ? modelFamily(args.provider, args.model) : undefined));
77
- return { status: 'recommended', policyVersion, ...(result.capability ? { capability: result.capability } : {}), depth: result.depth, provider: route.provider, model: route.model, reasoning_effort: route.reasoningEffort };
78
- }
79
- if (magpieProvider(args.provider))
80
- throw new Error('Magpie 路由需显式选择 policy-v2;原策略不变');
81
- const result = await jev.judge(buildState({ text: args.task }), exec.signal);
82
- if (result.kind === 'cancelled')
83
- throw new Error('route_subagent 已取消。');
84
- if (result.kind === 'failed')
85
- throw new Error(`Jev 判档失败:${CATEGORY_HINTS[result.category]}(${result.category})。请暂停委派并询问用户,不要自行猜测档位或换用其它模型。`);
86
- const provider = args.provider;
87
- const defaultModel = CHILD_MODELS[provider][tiers.indexOf(result.tier)];
88
- let model;
89
- let effort;
57
+ if (args.path === 'fork')
58
+ return { routed: false, status: 'unsupported', reason: 'fork 的真实历史兼容尚未验证;请改用 fresh' };
59
+ const explicit = { ...(args.model ? { model: args.model } : {}), ...(args.reasoning_effort ? { reasoningEffort: args.reasoning_effort } : {}) };
60
+ const result = explicit.model && explicit.reasoningEffort ? { kind: 'ok', depth: 'baseline', capability: 'bounded' }
61
+ : await jev.judgePolicy(buildState({ text: args.task }), exec.signal, 'child', explicit.model ? 'depth' : explicit.reasoningEffort ? 'capability' : 'both');
62
+ if (result.kind !== 'ok')
63
+ throw new Error('Jev 判断失败或已取消;暂停委派并询问用户');
64
+ if (result.capability === 'insufficient_context')
65
+ return { routed: false, status: 'needs-context', reason: '请补充任务目标与范围' };
66
+ let route;
90
67
  try {
91
- // Legacy callers without settings/catalog keep the original table; the Host always supplies both.
92
- const catalog = deps.catalog ? await deps.catalog() : {
93
- models: deps.readSettings ? [] : [{ provider, model: defaultModel, name: defaultModel, reasoningEfforts: ['low', 'medium', 'high', 'xhigh'] }],
94
- childAllowlist: { status: 'enabled', allowedModels: deps.readSettings ? [] : [{ provider, model: defaultModel }] },
95
- };
96
- if (exec.signal.aborted)
97
- throw new Error('cancelled');
98
- const settings = deps.readSettings?.() ?? DEFAULT_SETTINGS;
99
- // A caller-chosen model skips the tier→model table entirely (childRoutes included); only effort is routed.
100
- const explicit = args.model ? undefined : explicitChildRoute(settings, provider, result.tier, catalog);
101
- model = args.model || (explicit?.model ?? defaultModel);
102
- const supported = catalog.models.find(entry => entry.provider === provider && entry.model === model)?.reasoningEfforts ?? [];
103
- const mapped = explicit?.reasoningEffort ?? mapEffort(model, result.tier, supported, settings.effortCap);
104
- if (!mapped || validateChildRoute(catalog, provider, { model, reasoningEffort: mapped }, settings.effortCap).length)
105
- throw new SettingsValidationError([]);
106
- effort = mapped;
107
- }
108
- catch {
109
- if (exec.signal.aborted)
110
- throw new Error('route_subagent 已取消。');
111
- throw new Error('自动模型路由设置无效。请暂停委派并询问用户,修复设置后重试,不要自行换路由。');
68
+ route = choosePolicyRoute(args.provider, result.capability ?? 'bounded', result.depth, await deps.catalog(), deps.readSettings().effortCap, explicit, args.family ?? (args.model ? modelFamily(args.provider, args.model, registry) : undefined), registry);
112
69
  }
113
- // Shown on the caller's chip; a child caller has none, so only ordinary callers are recorded.
114
- if (caller === root) {
115
- const { kind: _, ...measure } = result;
116
- deps.judged?.(root.id, { source: 'subagent', model, effort, ...measure });
70
+ catch (error) {
71
+ // No usable route: never substitute another model or effort.
72
+ const reason = error instanceof Error ? error.message : String(error);
73
+ throw new Error(reason.includes('暂停委派并询问用户') ? reason : `${reason};暂停委派并询问用户`);
117
74
  }
118
- return { tier: result.tier, provider, model, reasoning_effort: effort };
75
+ return { status: 'recommended', ...(result.capability ? { capability: result.capability } : {}), depth: result.depth, provider: route.provider, model: route.model, reasoning_effort: route.reasoningEffort };
119
76
  },
120
77
  });
121
78
  }
package/lib/support.d.ts CHANGED
@@ -1,21 +1,20 @@
1
1
  import type { Context } from '@deepseek-ai/cordis';
2
2
  import type { Agent } from '@deepseek-ai/dsh-agent';
3
+ import { type Registry } from './strategies/index.js';
3
4
  /** One main-session model selection as the AUTO support gate needs it. */
4
5
  export interface MainSelection {
5
6
  provider: string;
6
7
  model: string;
7
8
  }
8
9
  /**
9
- * AUTO only manages the Claude / GPT model families.
10
- *
11
- * The transport prefix (`claude/...`, `codex/...` on Magpie) is not part of the
12
- * family name, so it is stripped before matching. Every other family — DeepSeek,
13
- * GLM, Grok, Gemini, Kimi, Qwen, ... — is explicitly out of scope and locks the
14
- * session to MANUAL instead of being guessed at.
10
+ * AUTO only manages models whose family has a registered strategy. The transport
11
+ * prefix (e.g. a gateway's `<family>/`) is not part of the family name, so only the
12
+ * part after the last `/` is matched. Other families lock the session to MANUAL
13
+ * instead of being guessed at.
15
14
  */
16
- export declare function autoSupportedModel(model: string | undefined): boolean;
15
+ export declare function autoSupportedModel(model: string | undefined, registry?: Registry): boolean;
17
16
  /** An unknown selection must never lock a session: only a proven mismatch does. */
18
- export declare function mainSupported(selection: MainSelection | undefined): boolean;
17
+ export declare function mainSupported(selection: MainSelection | undefined, registry?: Registry): boolean;
19
18
  /**
20
19
  * The main session's current model selection, most-authoritative signal first:
21
20
  * the official pending pick (`model/selection` projection), then the last actual
package/lib/support.js CHANGED
@@ -1,20 +1,16 @@
1
+ import { registry as builtin } from './strategies/index.js';
1
2
  /**
2
- * AUTO only manages the Claude / GPT model families.
3
- *
4
- * The transport prefix (`claude/...`, `codex/...` on Magpie) is not part of the
5
- * family name, so it is stripped before matching. Every other family — DeepSeek,
6
- * GLM, Grok, Gemini, Kimi, Qwen, ... — is explicitly out of scope and locks the
7
- * session to MANUAL instead of being guessed at.
3
+ * AUTO only manages models whose family has a registered strategy. The transport
4
+ * prefix (e.g. a gateway's `<family>/`) is not part of the family name, so only the
5
+ * part after the last `/` is matched. Other families lock the session to MANUAL
6
+ * instead of being guessed at.
8
7
  */
9
- export function autoSupportedModel(model) {
10
- if (typeof model !== 'string' || model.length === 0)
11
- return false;
12
- const bare = model.includes('/') ? model.slice(model.lastIndexOf('/') + 1) : model;
13
- return /^(?:claude|gpt)-/i.test(bare);
8
+ export function autoSupportedModel(model, registry = builtin) {
9
+ return registry.familyOfMainModel(model) !== undefined;
14
10
  }
15
11
  /** An unknown selection must never lock a session: only a proven mismatch does. */
16
- export function mainSupported(selection) {
17
- return selection === undefined || autoSupportedModel(selection.model);
12
+ export function mainSupported(selection, registry = builtin) {
13
+ return selection === undefined || autoSupportedModel(selection.model, registry);
18
14
  }
19
15
  /**
20
16
  * The main session's current model selection, most-authoritative signal first:
@@ -2,7 +2,6 @@ type Failure = 'decode-failed' | 'tail-incomplete' | 'parse-failed' | 'missing-l
2
2
  export declare function buildReport(home: string, options?: {
3
3
  since?: string;
4
4
  }): {
5
- schema: number;
6
5
  since: string | null;
7
6
  failures: Record<Failure, number>;
8
7
  excluded: {
@@ -16,7 +15,8 @@ export declare function buildReport(home: string, options?: {
16
15
  autoSessionsWithoutRecord: number;
17
16
  };
18
17
  groups: {
19
- version: string;
18
+ group: string;
19
+ historical: boolean;
20
20
  subgroups: {
21
21
  model: string;
22
22
  depth: string;
@@ -184,6 +184,14 @@ function median(values) {
184
184
  const s = [...values].sort((a, b) => a - b), mid = Math.floor(s.length / 2);
185
185
  return s.length % 2 ? s[mid] : (s[mid - 1] + s[mid]) / 2;
186
186
  }
187
+ /** Group key: the strategy family; for snapshots written before 0.5.0 the original fields, verbatim (historical); else `unknown`. */
188
+ function groupOf(snapshot) {
189
+ if (snapshot?.strategy)
190
+ return { group: snapshot.strategy, historical: false };
191
+ if (snapshot?.policyVersion !== undefined || snapshot?.mappingVersion !== undefined)
192
+ return { group: `${snapshot.policyVersion ?? ''}/${snapshot.mappingVersion ?? ''}`, historical: true };
193
+ return { group: 'unknown', historical: false };
194
+ }
187
195
  export function buildReport(home, options = {}) {
188
196
  const failures = { 'decode-failed': 0, 'tail-incomplete': 0, 'parse-failed': 0, 'missing-log': 0 };
189
197
  const state = join(home, 'state/auto-model-routing');
@@ -193,13 +201,14 @@ export function buildReport(home, options = {}) {
193
201
  lines(feedbackFile, failures); // counts unparsable lines; FeedbackBook skips them silently
194
202
  const book = new FeedbackBook(feedbackFile);
195
203
  const bindings = book.entries();
196
- const legacyFirst = new Map();
204
+ // Historical four-tier ledger: read only to locate each session's first AUTO record.
205
+ const tierFirst = new Map();
197
206
  for (const line of lines(join(state, 'judgements.jsonl'), failures)) {
198
207
  if (typeof line?.sessionId !== 'string' || !Number.isInteger(line.turn) || typeof line.tier !== 'string')
199
208
  continue;
200
- legacyFirst.set(line.sessionId, Math.min(legacyFirst.get(line.sessionId) ?? Infinity, line.turn));
209
+ tierFirst.set(line.sessionId, Math.min(tierFirst.get(line.sessionId) ?? Infinity, line.turn));
201
210
  }
202
- const ledgerSessions = [...new Set([...bindings.map(b => b.sessionId), ...legacyFirst.keys()])].sort();
211
+ const ledgerSessions = [...new Set([...bindings.map(b => b.sessionId), ...tierFirst.keys()])].sort();
203
212
  const paths = logPaths(home);
204
213
  const excluded = { child: 0, seeded: 0 };
205
214
  const decisions = new Map();
@@ -233,18 +242,18 @@ export function buildReport(home, options = {}) {
233
242
  next = log.userStarts.get(turn);
234
243
  }
235
244
  const linked = new Set();
236
- let v2First;
245
+ let bindingFirst;
237
246
  for (const call of log.calls) {
238
247
  const b = book.lookup(session, call.id);
239
248
  if (!b)
240
249
  continue;
241
250
  linked.add(call.id);
242
251
  if (call.userTurn !== undefined)
243
- v2First = Math.min(v2First ?? Infinity, call.userTurn);
252
+ bindingFirst = Math.min(bindingFirst ?? Infinity, call.userTurn);
244
253
  const key = JSON.stringify([session, b.decisionId]);
245
254
  let d = decisions.get(key);
246
255
  if (!d) {
247
- d = { session, id: b.decisionId, version: b.snapshot ? `${b.snapshot.policyVersion}/${b.snapshot.mappingVersion}` : 'unknown', model: b.model, depth: b.depth, effort: b.effort, calls: [], feedback: new Set() };
256
+ d = { session, id: b.decisionId, ...groupOf(b.snapshot), model: b.model, depth: b.depth, effort: b.effort, calls: [], feedback: new Set() };
248
257
  decisions.set(key, d);
249
258
  }
250
259
  d.calls.push(call);
@@ -254,11 +263,11 @@ export function buildReport(home, options = {}) {
254
263
  d.feedback.add(b.feedback);
255
264
  }
256
265
  unlinkedBindings += bindings.filter(b => b.sessionId === session && !linked.has(b.messageId)).length;
257
- // Coverage: user turns from the first v2 record on, with calls in range but none linked.
258
- if (v2First !== undefined) {
266
+ // Coverage: user turns from the first allocation binding on (any snapshot format), with calls in range but none linked.
267
+ if (bindingFirst !== undefined) {
259
268
  const turns = new Map();
260
269
  for (const call of log.calls) {
261
- if (call.userTurn === undefined || call.userTurn < v2First || !after(call.time))
270
+ if (call.userTurn === undefined || call.userTurn < bindingFirst || !after(call.time))
262
271
  continue;
263
272
  turns.set(call.userTurn, (turns.get(call.userTurn) ?? false) || linked.has(call.id));
264
273
  }
@@ -266,7 +275,7 @@ export function buildReport(home, options = {}) {
266
275
  turnsWithoutRecord += [...turns.values()].filter(v => !v).length;
267
276
  }
268
277
  // Reference: calls before the earliest record in either ledger (effort only, never tokens).
269
- const first = Math.min(legacyFirst.get(session) ?? Infinity, v2First ?? Infinity);
278
+ const first = Math.min(tierFirst.get(session) ?? Infinity, bindingFirst ?? Infinity);
270
279
  for (const call of log.calls) {
271
280
  if (call.userTurn === undefined || call.userTurn >= first || !after(call.time) || !call.model)
272
281
  continue;
@@ -279,15 +288,15 @@ export function buildReport(home, options = {}) {
279
288
  const kept = [...decisions.values()].filter(d => after(Math.min(...d.calls.map(c => c.time))));
280
289
  for (const d of kept)
281
290
  bump((autoEffort[d.model] ??= {}), d.effort, d.calls.length);
282
- const versions = new Map();
291
+ const byGroup = new Map();
283
292
  for (const d of kept) {
284
- const byVersion = versions.get(d.version) ?? new Map();
285
- versions.set(d.version, byVersion);
293
+ const entry = byGroup.get(d.group) ?? { historical: d.historical, subs: new Map() };
294
+ byGroup.set(d.group, entry);
286
295
  const key = JSON.stringify([d.model, d.depth]);
287
- byVersion.set(key, [...(byVersion.get(key) ?? []), d]);
296
+ entry.subs.set(key, [...(entry.subs.get(key) ?? []), d]);
288
297
  }
289
- const groups = [...versions].sort(([a], [b]) => a.localeCompare(b)).map(([version, subs]) => ({
290
- version,
298
+ const groups = [...byGroup].sort(([a], [b]) => a.localeCompare(b)).map(([group, { historical, subs }]) => ({
299
+ group, historical,
291
300
  subgroups: [...subs].sort(([a], [b]) => a.localeCompare(b)).map(([key, list]) => {
292
301
  const [model, depth] = JSON.parse(key);
293
302
  const calls = list.flatMap(d => d.calls);
@@ -359,7 +368,6 @@ export function buildReport(home, options = {}) {
359
368
  autoSessionsWithoutRecord++;
360
369
  }
361
370
  return {
362
- schema: 1,
363
371
  since: since?.label ?? null,
364
372
  failures, excluded,
365
373
  coverage: { userTurns, turnsWithoutRecord, unlinkedBindings, autoSessionsWithoutRecord },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-auto-model-routing",
3
- "version": "0.4.2",
3
+ "version": "0.5.1",
4
4
  "description": "DeepSeek Harness plugin: independent Jev capability and effort decisions, enforced fresh-child routes, decision visibility and allocation feedback",
5
5
  "license": "MIT",
6
6
  "author": "prgrmrwy",
@@ -41,8 +41,7 @@
41
41
  "CHANGELOG.md",
42
42
  "skills",
43
43
  "docs",
44
- "screenshots.json",
45
- "scripts/label-report.mjs"
44
+ "screenshots.json"
46
45
  ],
47
46
  "dsh": {
48
47
  "bundle": {
@@ -59,7 +58,7 @@
59
58
  }
60
59
  },
61
60
  "scripts": {
62
- "build": "tsc -p tsconfig.json && tsdown",
61
+ "build": "node -e \"require('node:fs').rmSync('lib',{recursive:true,force:true})\" && tsc -p tsconfig.json && tsdown",
63
62
  "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.test.json",
64
63
  "test": "vitest run",
65
64
  "prepare": "npm run build",