From b721c94dcbae8167d3fc1ce36ccf2b506f2ff9e9 Mon Sep 17 00:00:00 2001 From: bchanot Date: Thu, 8 Oct 2026 16:26:57 +0200 Subject: [PATCH] =?UTF-8?q?feat(mods):=20model-router=20mod,=20wave=201-A?= =?UTF-8?q?=20=E2=80=94=20per-request=20model/effort=20routing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Function-hooks plugin under mods/model-router: routes effort (and, behind a flag, the model) of every main-loop request, sets built-in sub-agents' model at spawn with full ids, answers Skill(effort-*) itself (single writer, no pairing rule), exposes the route tool and /route, validates the optional ~/.claude/model-router.json. 11 plugin tests, validate + tsc clean. Contract .claude/tasks/contracts/2026-10-08-model-router-w1a-1533.md. --- mods/model-router/.claude-plugin/plugin.json | 8 + mods/model-router/hooks/hooks.json | 1 + mods/model-router/hooks/register.test.ts | 202 +++++ mods/model-router/hooks/register.ts | 834 +++++++++++++++++++ 4 files changed, 1045 insertions(+) create mode 100644 mods/model-router/.claude-plugin/plugin.json create mode 100644 mods/model-router/hooks/hooks.json create mode 100644 mods/model-router/hooks/register.test.ts create mode 100644 mods/model-router/hooks/register.ts diff --git a/mods/model-router/.claude-plugin/plugin.json b/mods/model-router/.claude-plugin/plugin.json new file mode 100644 index 0000000..548269f --- /dev/null +++ b/mods/model-router/.claude-plugin/plugin.json @@ -0,0 +1,8 @@ +{ + "name": "model-router", + "version": "0.1.0", + "description": "Routes every model request (main loop and sub-agents) to the model and effort its phase deserves, from a user config, a route tool, /route and skill or prompt rules.", + "author": { + "name": "bchanot" + } +} diff --git a/mods/model-router/hooks/hooks.json b/mods/model-router/hooks/hooks.json new file mode 100644 index 0000000..5fcba7c --- /dev/null +++ b/mods/model-router/hooks/hooks.json @@ -0,0 +1 @@ +{ "modules": ["./register.ts"] } diff --git a/mods/model-router/hooks/register.test.ts b/mods/model-router/hooks/register.test.ts new file mode 100644 index 0000000..5c0c634 --- /dev/null +++ b/mods/model-router/hooks/register.test.ts @@ -0,0 +1,202 @@ +import { test, expect } from 'claude-code/testing' +import type { Engine } from 'claude-code/testing' +import type { On } from 'claude-code' + +/** Fires session.start so the mod loads its config and registers /route. */ +async function boot($: Engine, on: On): Promise { + on('session.start', ($, e) => ({ cwd: e.cwd })) + await $.session.start({ cwd: '/tmp', surface: null, isInteractive: false }) +} + +/** Runs `/route ` as the user would type it. */ +async function route($: Engine, args: string): Promise { + const out = await $.command.run({ + command: 'route', + args, + origin: { kind: 'composer' }, + presentation: { isFullscreen: false, columns: 80 }, + }) + return out.text ?? '' +} + +/** The `main:` line of `show`: the phases listing holds every id and level. */ +function mainLine(text: string): string { + return text.split('\n').find(l => l.startsWith('main:')) ?? '' +} + +const spawnInput = (model?: string) => ({ + tool_use_id: 't1', + prompt: 'x', + description: 'd', + subagentType: 'Explore', + provider: { plugin: 'engine', tier: 'core' as const }, + parentModel: 'claude-fable-5-1', + background: false, + fork: false, + ...(model === undefined ? {} : { model }), +}) + +test('Skill(effort-low) is answered without next, route shows low', async ( + $, on) => { + let reached = false + on('tool.call', { tool: 'Skill' }, () => { + reached = true + return { result: { success: true, commandName: 'bottom' } } + }) + await boot($, on) + const out = await $.tool.call({ tool: 'Skill', skill: 'effort-low' }) + expect(out).toMatchObject({ + result: { success: true, commandName: 'effort-low' }, + }) + expect(reached).toBe(false) + const line = mainLine(await route($, 'show')) + expect(line).toContain('skill effort-low') + expect(line).toContain('effort low') +}) + +test('route tool with phase orchestrate sets medium on main', async ( + $, on) => { + await boot($, on) + const out = await $.tool.call({ + tool: 'mcp__model-router__route', + phase: 'orchestrate', + }) + expect(out).toHaveProperty('result') + const line = mainLine(await route($, 'show')) + expect(line).toContain('model orchestrate') + expect(line).toContain('effort medium') +}) + +test('/route clear drops the route', async ($, on) => { + await boot($, on) + await $.tool.call({ tool: 'mcp__model-router__route', phase: 'plan' }) + expect(mainLine(await route($, 'show'))).toContain('model plan') + await route($, 'clear') + expect(mainLine(await route($, 'show'))).toContain('session defaults') +}) + +test('/route bogus names the phases', async ($, on) => { + await boot($, on) + const text = await route($, 'bogus') + expect(text).toContain('unknown') + for (const phase of ['plan', 'judge', 'explore', 'mechanical']) { + expect(text).toContain(phase) + } +}) + +test('ultrathink in a prompt sets escalate on main', async ($, on) => { + on('prompt.submit', ($, e) => ({ text: e.text })) + await boot($, on) + await $.prompt.submit({ + text: 'ultrathink please', + wait: false, + origin: { kind: 'composer' }, + }) + expect(mainLine(await route($, 'show'))).toContain('prompt escalate') +}) + +test('/route model: alias resolved, id passed, typo refused', async ( + $, on) => { + await boot($, on) + const alias = await route($, 'model=sonnet') + expect(mainLine(alias)).toContain('claude-sonnet-5-5') + const id = await route($, 'model=claude-x-9') + expect(mainLine(id)).toContain('claude-x-9') + expect(await route($, 'model=sonet')).toContain('unknown') +}) + +test('Explore spawn gets the sonnet id; an explicit model wins', async ( + $, on) => { + const seen: (string | undefined)[] = [] + on('agent.spawn', ($, e) => { + seen.push(e.model) + return { model: e.model ?? e.parentModel, agentId: 'a1' } + }) + await boot($, on) + await $.agent.spawn(spawnInput()) + await $.agent.spawn(spawnInput('opus')) + expect(seen).toEqual(['claude-sonnet-5-5', 'opus']) +}) + +test('a sticky /route wins over a model-declared route', async ($, on) => { + await boot($, on) + await route($, 'judge') + const out = await $.tool.call({ + tool: 'mcp__model-router__route', + phase: 'mechanical', + }) + expect(JSON.stringify(out)).toContain('sticky') + expect(mainLine(await route($, 'show'))).toContain('user judge') +}) + +test('/route off makes the route tool a no-op', async ($, on) => { + await boot($, on) + await route($, 'off') + const out = await $.tool.call({ + tool: 'mcp__model-router__route', + phase: 'plan', + }) + expect(JSON.stringify(out)).toContain('off') + expect(mainLine(await route($, 'show'))).toContain('session defaults') +}) + +type Seen = { model: string; effort: unknown } + +/** A bottom turn.step hook that records what reaches the model. */ +function recordSteps(on: On, seen: Seen[]): void { + on('turn.step', async function* ($, e) { + seen.push({ model: e.model, effort: e.effort }) + return { + turnId: e.turnId, + index: e.index, + answer: '', + toolUses: [], + stopReason: 'end_turn' as const, + usage: null, + } + }) +} + +const stepInput = (agentId?: string) => ({ + turnId: 'u1', + index: 0, + model: 'claude-fable-5-1', + effort: 'low' as const, + messageCount: 1, + ...(agentId === undefined ? {} : { agentId }), +}) + +/** Streams one turn.step to its end; the hooks run as the chunks flow. */ +async function runStep( + $: Engine, + input: ReturnType, +): Promise { + const stream = $.turn.step(input) + for await (const _chunk of stream) { + // chunks are not under test + } + await stream.result +} + +test('the main step carries the routed effort, model untouched', async ( + $, on) => { + const seen: Seen[] = [] + recordSteps(on, seen) + await boot($, on) + await $.tool.call({ tool: 'mcp__model-router__route', phase: 'plan' }) + await runStep($, stepInput()) + expect(seen).toEqual([{ model: 'claude-fable-5-1', effort: 'xhigh' }]) +}) + +test('a tabled agent steps at its table effort', async ($, on) => { + const seen: Seen[] = [] + recordSteps(on, seen) + on('agent.spawn', ($, e) => ({ + model: e.model ?? e.parentModel, + agentId: 'a1', + })) + await boot($, on) + await $.agent.spawn(spawnInput()) + await runStep($, stepInput('a1')) + expect(seen).toEqual([{ model: 'claude-fable-5-1', effort: 'medium' }]) +}) diff --git a/mods/model-router/hooks/register.ts b/mods/model-router/hooks/register.ts new file mode 100644 index 0000000..9bf4ae4 --- /dev/null +++ b/mods/model-router/hooks/register.ts @@ -0,0 +1,834 @@ +// model-router: routes each model request (main loop and sub-agents) to the +// model and effort its phase deserves. Phases, agents, skills and prompt +// rules come from DEFAULT_CONFIG, overridable by ~/.claude/model-router.json. +// One writer per concern: the Agent tool's own params are never rewritten, +// the spawn hook sets an agent's model once, turn.step sets efforts. +import type { EngineInterface, On, Register, TurnStepInput } from 'claude-code' + +type Api = EngineInterface +type Level = 'low' | 'medium' | 'high' | 'xhigh' | 'max' +type Route = { model?: string; effort?: Level } // model: alias or full id +type PromptRule = { pattern: string; phase: string } +type Config = { + models: Record // alias -> full id + windows: Record // full id -> context window (tokens) + phases: Record + agents: Record // built-in subagentType -> phase + skills: Record // skill name -> phase + prompt: PromptRule[] + mainModelSwitch: boolean + verbose: boolean + spinner: boolean +} +type Rule = { re: RegExp; phase: string } +type Source = 'user' | 'model' | 'skill' | 'prompt' | 'slash' +type Routed = { phase: string; route: Route; source: Source } +type Loop = { + effort?: Level + model?: string // routed by the table or an in-agent call + spawnModel: string // the engine's model at spawn + frozen: boolean // fork or workflow agent: never re-modelled + explicitModel: boolean // Agent call gave a model: axis frozen + explicitEffort: boolean // Agent call gave an effort: axis frozen +} +type State = { + cfg: Config + rules: Rule[] + source: string // 'defaults' or the override path + userMain: Routed | null // /route by the user, sticky until /route clear + turnMain: Routed | null // tool, skill, slash, prompt; dropped at turn end + pendingPrompt: Routed | null // typed mid-turn, promoted next turn + loops: Map // agentId -> that loop's routing + explicitEffort: Map // Agent tool_use_id -> effort param + skillCalls: number // Skill tool calls in flight + off: boolean // /route off: every hook passes through + lastMain: string // "model/effort" of the last main step (spinner) + windowWarned: boolean // context-window warning already logged this turn +} +type Log = (text: string) => void +type StepIn = Readonly +type Plan = { model: string; effort: StepIn['effort'] } +type RouteInput = { + phase?: unknown + effort?: unknown + clear?: unknown + agentId?: string +} +type Picked = { phase: string; route: Route } + +const LEVELS: readonly Level[] = ['low', 'medium', 'high', 'xhigh', 'max'] +const MODEL_ID = /^claude-[a-z0-9.-]+$/ +const TOOL = 'mcp__model-router__route' +const EFFORT_SKILL = /^effort-(low|medium|high|xhigh|max)$/ +const OVERRIDE = '.claude/model-router.json' +const HAIKU = 'claude-haiku' + +const DEFAULT_CONFIG: Config = { + models: { + haiku: 'claude-haiku-4-5-20251001', + sonnet: 'claude-sonnet-5-5', + opus: 'claude-opus-5-5', + fable: 'claude-fable-5-1', + }, + windows: { 'claude-haiku-4-5-20251001': 200000 }, + phases: { + plan: { effort: 'xhigh' }, + reflect: { effort: 'high' }, + orchestrate: { effort: 'medium' }, + escalate: { effort: 'max' }, + judge: { model: 'opus', effort: 'xhigh' }, + implement: { model: 'sonnet', effort: 'medium' }, + write: { model: 'sonnet', effort: 'medium' }, + verify: { model: 'sonnet', effort: 'xhigh' }, + explore: { model: 'sonnet', effort: 'medium' }, + mechanical: { model: 'haiku', effort: 'low' }, + }, + // Built-ins only: repo agents keep their frontmatter pin (wave 2). + agents: { Explore: 'explore', Plan: 'judge' }, + skills: {}, + prompt: [{ pattern: '\\bultrathink\\b', phase: 'escalate' }], + mainModelSwitch: false, + verbose: false, + spinner: true, +} + +// ---- config ---------------------------------------------------------- + +const isRecord = (v: unknown): v is Record => + typeof v === 'object' && v !== null && !Array.isArray(v) +const isLevel = (v: unknown): v is Level => LEVELS.some(l => l === v) +const isFullId = (v: unknown): v is string => + typeof v === 'string' && MODEL_ID.test(v) +const hasKey = (table: object, key: string): boolean => + Object.hasOwn(table, key) + +function isModelName(models: Record, v: unknown): v is string { + return typeof v === 'string' && (hasKey(models, v) || isFullId(v)) +} + +function resolveModel(cfg: Config, name: string): string { + return hasKey(cfg.models, name) ? (cfg.models[name] ?? name) : name +} + +function phaseRoute(cfg: Config, phase: string): Route | undefined { + return hasKey(cfg.phases, phase) ? cfg.phases[phase] : undefined +} + +/** Merges a user table over a default one, dropping invalid entries. */ +function mergeTable( + base: Record, + user: unknown, + name: string, + accept: (key: string, value: unknown) => T | undefined, + log: Log, +): Record { + const out = { ...base } + if (!isRecord(user)) return out + for (const [key, value] of Object.entries(user)) { + const ok = key === '__proto__' ? undefined : accept(key, value) + if (ok === undefined) log(`model-router: config ${name}.${key} ignored`) + else out[key] = ok + } + return out +} + +const acceptModel = (_key: string, v: unknown): string | undefined => + isFullId(v) ? v : undefined + +const acceptWindow = (key: string, v: unknown): number | undefined => + isFullId(key) && typeof v === 'number' && Number.isInteger(v) && v > 0 + ? v + : undefined + +function acceptPhase(models: Record) { + return (_key: string, v: unknown): Route | undefined => { + if (!isRecord(v)) return undefined + const route: Route = {} + if (v.effort !== undefined) { + if (!isLevel(v.effort)) return undefined + route.effort = v.effort + } + if (v.model !== undefined) { + if (!isModelName(models, v.model)) return undefined + route.model = v.model + } + return route.effort || route.model ? route : undefined + } +} + +function acceptPhaseRef(phases: Record) { + return (_key: string, v: unknown): string | undefined => + typeof v === 'string' && hasKey(phases, v) ? v : undefined +} + +function compiles(pattern: string): boolean { + try { + new RegExp(pattern, 'i') + return true + } catch { + return false + } +} + +function acceptRule(phases: Record, v: unknown) { + if (!isRecord(v)) return undefined + const { pattern, phase } = v + if (typeof pattern !== 'string' || typeof phase !== 'string') return undefined + return hasKey(phases, phase) && compiles(pattern) + ? { pattern, phase } + : undefined +} + +/** A user prompt array replaces the default rules; bad rules are dropped. */ +function mergePrompt( + base: PromptRule[], + user: unknown, + phases: Record, + log: Log, +): PromptRule[] { + if (!Array.isArray(user)) return base + const rules: PromptRule[] = [] + for (const item of user as unknown[]) { + const rule = acceptRule(phases, item) + if (rule) rules.push(rule) + else log('model-router: config prompt rule ignored') + } + return rules +} + +const pickBool = (v: unknown, fallback: boolean): boolean => + typeof v === 'boolean' ? v : fallback + +/** Defaults overlaid with the user's entries, each validated first. */ +function mergeConfig(user: unknown, log: Log): Config { + const base = structuredClone(DEFAULT_CONFIG) + if (!isRecord(user)) return base + const models = mergeTable( + base.models, user.models, 'models', acceptModel, log) + const phases = mergeTable( + base.phases, user.phases, 'phases', acceptPhase(models), log) + const ref = acceptPhaseRef(phases) + return { + models, + windows: mergeTable( + base.windows, user.windows, 'windows', acceptWindow, log), + phases, + agents: mergeTable(base.agents, user.agents, 'agents', ref, log), + skills: mergeTable(base.skills, user.skills, 'skills', ref, log), + prompt: mergePrompt(base.prompt, user.prompt, phases, log), + mainModelSwitch: pickBool(user.mainModelSwitch, base.mainModelSwitch), + verbose: pickBool(user.verbose, base.verbose), + spinner: pickBool(user.spinner, base.spinner), + } +} + +async function readOverride( + $: Api, + log: Log, +): Promise<{ path: string; data: unknown } | undefined> { + try { + const home = await $.env.get('HOME') + if (!home) return undefined + const path = `${home}/${OVERRIDE}` + if (!(await $.fs.exists(path))) return undefined + return { path, data: JSON.parse(await $.fs.read(path)) } + } catch (err) { + log(`model-router: ${OVERRIDE} unreadable (${String(err)}); defaults`) + return undefined + } +} + +/** Never throws; a failed read or parse leaves the defaults. */ +async function loadConfig( + $: Api, + log: Log, +): Promise<{ cfg: Config; source: string }> { + const found = await readOverride($, log) + if (!found) return { cfg: mergeConfig(undefined, log), source: 'defaults' } + return { cfg: mergeConfig(found.data, log), source: found.path } +} + +function compileRules(cfg: Config): Rule[] { + return cfg.prompt.map(r => ({ + re: new RegExp(r.pattern, 'i'), + phase: r.phase, + })) +} + +// ---- state ----------------------------------------------------------- + +function newState(cfg: Config, source: string): State { + return { + cfg, + rules: compileRules(cfg), + source, + userMain: null, + turnMain: null, + pendingPrompt: null, + loops: new Map(), + explicitEffort: new Map(), + skillCalls: 0, + off: false, + lastMain: '', + windowWarned: false, + } +} + +/** The main loop's effective route: user /route > latest turn route. */ +const mainRoute = (st: State): Routed | null => st.userMain ?? st.turnMain + +function loopOf(st: State, agentId: string): Loop { + const known = st.loops.get(agentId) + if (known) return known + const fresh: Loop = { + spawnModel: '', + frozen: false, + explicitModel: false, + explicitEffort: false, + } + st.loops.set(agentId, fresh) + return fresh +} + +/** Writes a route on an agent loop, never on an axis given explicitly. */ +function writeLoop(loop: Loop, route: Route): void { + if (!loop.explicitEffort) loop.effort = route.effort + if (!loop.explicitModel) loop.model = route.model +} + +function clearRoutes(st: State): void { + st.userMain = null + st.turnMain = null + st.pendingPrompt = null +} + +// ---- text ------------------------------------------------------------ + +const modelText = (cfg: Config, model: string | undefined): string => + model === undefined ? '-' : resolveModel(cfg, model) + +function mainText(st: State): string { + const r = mainRoute(st) + if (!r) return 'main: session defaults' + const model = modelText(st.cfg, r.route.model) + return `main: ${r.source} ${r.phase} · model ${model} · effort ${ + r.route.effort ?? '-'}` +} + +function phasesText(cfg: Config): string { + const entries = Object.entries(cfg.phases).map(([name, r]) => + `${name}=${r.model ? resolveModel(cfg, r.model) : 'session'}/${ + r.effort ?? 'session'}`) + return `phases: ${entries.join(' ')}` +} + +function show(st: State): string { + const c = st.cfg + const flag = (b: boolean) => (b ? 'on' : 'off') + return [ + mainText(st), + `router: ${st.off ? 'off' : 'on'} · switch: ${flag(c.mainModelSwitch)} · ` + + `verbose: ${flag(c.verbose)} · spinner: ${flag(c.spinner)}`, + `live loops: ${st.loops.size}`, + phasesText(c), + `config: ${st.source}`, + ].join('\n') +} + +function statusLine(st: State): string { + const r = mainRoute(st) + const now = st.off ? 'off' : r ? `${r.source} ${r.phase}` : 'session defaults' + return `route: ${now}${st.cfg.mainModelSwitch ? ' · switch on' : ''}` +} + +const refresh = ($: Api, st: State): void => $.ui.status(statusLine(st)) + +function vlog($: Api, st: State, text: string): void { + if (st.cfg.verbose) $.ui.log(text) +} + +const unknownText = (cfg: Config, token: string): string => + `unknown token "${token}"; phases: ${Object.keys(cfg.phases).join(' ')}; ` + + `levels: ${LEVELS.join(' ')}; models: ${Object.keys(cfg.models).join(' ')} ` + + 'or a full claude-* id' + +// ---- /route command -------------------------------------------------- + +/** One token: `model=x`, `effort=y`, a bare alias, id or level. */ +function applyToken(cfg: Config, route: Route, token: string): boolean { + const eq = token.indexOf('=') + const key = eq < 0 ? '' : token.slice(0, eq) + const value = eq < 0 ? token : token.slice(eq + 1) + if (key !== '' && key !== 'model' && key !== 'effort') return false + if (key !== 'model' && isLevel(value)) route.effort = value + else if (key !== 'effort' && isModelName(cfg.models, value)) { + route.model = value + } else return false + return true +} + +function parseRoute(cfg: Config, args: string): Routed | string { + const words = args.trim().split(/\s+/) + const only = words.length === 1 ? words[0] : undefined + const named = only === undefined ? undefined : phaseRoute(cfg, only) + if (only !== undefined && named) { + return { phase: only, route: { ...named }, source: 'user' } + } + const route: Route = {} + for (const word of words) { + if (!applyToken(cfg, route, word)) return unknownText(cfg, word) + } + return { phase: 'custom', route, source: 'user' } +} + +function toggle(st: State, what: string, arg: string | undefined): string { + if (arg !== 'on' && arg !== 'off') return `usage: /route ${what} on|off` + if (what === 'switch') st.cfg.mainModelSwitch = arg === 'on' + else st.cfg.verbose = arg === 'on' + return show(st) +} + +function setUserRoute($: Api, st: State, args: string): string { + const parsed = parseRoute(st.cfg, args) + if (typeof parsed === 'string') return parsed + st.userMain = parsed + refresh($, st) + return show(st) +} + +/** (Re)loads the config into the state and re-registers the route tool. */ +async function reloadConfig($: Api, st: State): Promise { + const loaded = await loadConfig($, text => $.ui.log(text)) + st.cfg = loaded.cfg + st.rules = compileRules(loaded.cfg) + st.source = loaded.source + await registerTool($, st) +} + +async function handleCommand($: Api, st: State, args: string): Promise { + const [head = '', ...rest] = args.trim().split(/\s+/) + switch (head) { + case '': + case 'show': + return show(st) + case 'clear': + clearRoutes(st) + refresh($, st) + return 'route cleared\n' + show(st) + case 'on': + case 'off': + st.off = head === 'off' + refresh($, st) + return show(st) + case 'reload': + await reloadConfig($, st) + refresh($, st) + return 'config reloaded\n' + show(st) + case 'switch': + case 'verbose': + return toggle(st, head, rest[0]) + default: + return setUserRoute($, st, args) + } +} + +// ---- route tool ------------------------------------------------------ + +async function registerTool($: Api, st: State): Promise { + try { + await $.tool.register({ + name: 'route', + description: + 'Declare the phase of the work ahead so the next model requests ' + + 'run at the effort (and model) it deserves. Call it before a span ' + + 'of work changes nature (planning, orchestrating, mechanical ' + + 'work). It acts on the calling loop only; no model choice here. ' + + `Phases: ${Object.keys(st.cfg.phases).join(', ')}.`, + inputSchema: { + type: 'object', + properties: { + phase: { type: 'string', enum: Object.keys(st.cfg.phases) }, + effort: { type: 'string', enum: [...LEVELS] }, + clear: { type: 'boolean', description: 'drop this loop\'s route' }, + }, + }, + }) + } catch (err) { + $.ui.log(`model-router: route tool not registered: ${String(err)}`) + } +} + +async function registerCommand($: Api): Promise { + try { + await $.command.register({ + name: 'route', + description: 'model-router: show or set the model and effort route', + argumentHint: + '[show|clear|off|on|reload||model= effort=' + + '|switch on|off|verbose on|off]', + immediate: true, + }) + } catch (err) { + $.ui.log(`model-router: /route not registered: ${String(err)}`) + } +} + +function pickRoute(cfg: Config, phase: unknown, effort: unknown) { + const phases = Object.keys(cfg.phases).join(', ') + const named = typeof phase === 'string' ? phaseRoute(cfg, phase) : undefined + if (phase !== undefined && !named) { + return `unknown phase "${String(phase)}"; phases: ${phases}` + } + if (effort !== undefined && !isLevel(effort)) { + return `unknown effort "${String(effort)}"; levels: ${LEVELS.join(', ')}` + } + if (phase === undefined && effort === undefined) { + return `give a phase (${phases}), an effort, or clear` + } + const route: Route = { ...named, ...(isLevel(effort) ? { effort } : {}) } + const name = typeof phase === 'string' ? phase : `effort-${String(effort)}` + return { phase: name, route } +} + +function applyRoute(st: State, agentId: string | undefined, p: Picked): void { + if (agentId === undefined) { + st.turnMain = { phase: p.phase, route: p.route, source: 'model' } + } else { + writeLoop(loopOf(st, agentId), p.route) + } +} + +function clearLoop(st: State, agentId: string | undefined): string { + if (agentId === undefined) { + st.turnMain = null + return 'route cleared for main' + } + const loop = st.loops.get(agentId) + if (loop) writeLoop(loop, {}) + return 'route cleared for this agent' +} + +/** Truthful answer: states what the calling loop will actually do. */ +function routedText(st: State, agentId: string | undefined, p: Picked): string { + if (agentId === undefined && st.userMain) { + return `recorded ${p.phase} for this turn, but a sticky /route ` + + `${st.userMain.phase} is in force; it wins until /route clear` + } + const loop = agentId === undefined ? undefined : st.loops.get(agentId) + const effort = loop?.explicitEffort ? undefined : p.route.effort + const model = agentId === undefined && !st.cfg.mainModelSwitch + ? undefined + : loop?.explicitModel ? undefined : p.route.model + const modelNote = p.route.model && model === undefined ? ' (switch off)' : '' + return `routed ${agentId === undefined ? 'main' : 'this agent'} to ` + + `${p.phase}: effort ${effort ?? 'unchanged'}, model ` + + `${model === undefined ? 'unchanged' : resolveModel(st.cfg, model)}` + + modelNote +} + +function handleRouteTool(st: State, e: RouteInput) { + if (st.off) { + return { + result: 'model-router is off (/route on to resume); nothing routed', + } + } + if (e.clear === true) return { result: clearLoop(st, e.agentId) } + const picked = pickRoute(st.cfg, e.phase, e.effort) + if (typeof picked === 'string') return { deny: picked } + applyRoute(st, e.agentId, picked) + return { result: routedText(st, e.agentId, picked) } +} + +// ---- skills ---------------------------------------------------------- + +const skillResult = (skill: string, line: string) => ({ + result: { success: true, commandName: skill, status: 'inline' as const }, + context: [line], +}) + +/** Answers Skill(effort-) in place: one writer, the skill never loads. */ +function effortBridge(st: State, agentId: string | undefined, skill: string, + level: Level) { + if (agentId === undefined) { + const route = { ...st.turnMain?.route, effort: level } + st.turnMain = { phase: skill, route, source: 'skill' } + return skillResult(skill, st.userMain + ? `model-router: ${skill} recorded, but a sticky /route ` + + `${st.userMain.phase} is in force and wins until /route clear.` + : `model-router: effort → ${level} for this loop from the next ` + + `request on; the ${skill} skill text was not loaded.`) + } + const loop = loopOf(st, agentId) + if (loop.explicitEffort) { + return skillResult(skill, 'model-router: this agent was dispatched with ' + + 'an explicit effort; the shift does not apply.') + } + loop.effort = level + return skillResult(skill, `model-router: effort → ${level} for this ` + + `loop from the next request on; the ${skill} skill text was not loaded.`) +} + +/** A non-effort skill load: resets the loop's route, applies its table row. */ +function onSkillLoad(st: State, skill: string, agentId: string | undefined) { + const table = hasKey(st.cfg.skills, skill) ? st.cfg.skills[skill] : undefined + const route = table === undefined ? undefined : phaseRoute(st.cfg, table) + if (agentId === undefined) { + if (st.turnMain && st.turnMain.source !== 'prompt') st.turnMain = null + if (table !== undefined && route) { + st.turnMain = { phase: table, route, source: 'skill' } + } + return + } + const loop = route ? loopOf(st, agentId) : st.loops.get(agentId) + if (loop) writeLoop(loop, { effort: route?.effort }) +} + +/** A user-typed /effort-: prepends one line, args ride in the text. */ +function slashEffort(st: State, skill: string, text: string) { + const level = EFFORT_SKILL.exec(skill)?.[1] + if (!isLevel(level)) return undefined + st.turnMain = { phase: skill, route: { effort: level }, source: 'slash' } + const line = st.userMain + ? `Effort ${level} recorded; the sticky /route ${st.userMain.phase} ` + + 'wins until /route clear.' + : `Effort shifted to ${level} by model-router for this turn.` + return { text: line + '\n' + text } +} + +// ---- agents ---------------------------------------------------------- + +type SpawnIn = { + tool_use_id: string + subagentType: string + provider: { plugin: string } + model?: string + fork: boolean + workflow?: unknown +} + +/** + * The table row of a built-in agent. Known limit: provider.plugin === + * 'engine' is the best built-in test at spawn; a user agent named Explore + * in a foreign project also matches (wave 1: sonnet/medium on it). + */ +function spawnRoute(cfg: Config, e: SpawnIn, frozen: boolean) { + if (frozen || e.provider.plugin !== 'engine') return undefined + if (!hasKey(cfg.agents, e.subagentType)) return undefined + const phase = cfg.agents[e.subagentType] + return phase === undefined ? undefined : phaseRoute(cfg, phase) +} + +function trackLoop(st: State, e: SpawnIn, started: { + model: string + agentId?: string +}, route: Route | undefined, frozen: boolean): void { + const given = st.explicitEffort.get(e.tool_use_id) + st.explicitEffort.delete(e.tool_use_id) + if (started.agentId === undefined) return + st.loops.set(started.agentId, { + spawnModel: started.model, + frozen, + explicitModel: e.model !== undefined, + explicitEffort: given !== undefined, + effort: given ? undefined : route?.effort, + }) +} + +// ---- turn steps ------------------------------------------------------ + +function agentPlan(st: State, e: StepIn): Plan { + const loop = e.agentId === undefined ? undefined : st.loops.get(e.agentId) + const wanted = loop?.model + const reroute = loop && wanted !== undefined && !loop.frozen && + e.model === loop.spawnModel + return { + model: reroute ? resolveModel(st.cfg, wanted) : e.model, + effort: loop?.effort ?? e.effort, + } +} + +/** True when the context still fits the target model's known window. */ +async function windowOk($: Api, st: State, id: string): Promise { + const limit = hasKey(st.cfg.windows, id) ? st.cfg.windows[id] : undefined + if (limit === undefined) return true + let tokens: number | undefined + try { + tokens = (await $.session.usage()).context.tokens + } catch { + tokens = undefined + } + if (typeof tokens === 'number' && tokens < limit) return true + if (!st.windowWarned) { + st.windowWarned = true + $.ui.log(`model-router: no switch to ${id}: context not known to fit`) + } + return false +} + +async function mainPlan($: Api, st: State, e: StepIn): Promise { + const set = mainRoute(st) + const effort = set?.route.effort ?? e.effort + const wanted = set?.route.model + if (wanted === undefined || !st.cfg.mainModelSwitch) { + return { model: e.model, effort } + } + const id = resolveModel(st.cfg, wanted) + return { model: (await windowOk($, st, id)) ? id : e.model, effort } +} + +async function planStep($: Api, st: State, e: StepIn): Promise { + const plan = e.agentId === undefined + ? await mainPlan($, st, e) + : agentPlan(st, e) + // Haiku takes no effort: omit it rather than send a hook-set value. + return plan.model.startsWith(HAIKU) ? { ...plan, effort: undefined } : plan +} + +function withPlan(e: StepIn, plan: Plan): StepIn { + const { effort: _replaced, ...rest } = e + const base = { ...rest, model: plan.model } + return plan.effort === undefined ? base : { ...base, effort: plan.effort } +} + +function stepLog(e: StepIn, plan: Plan): string { + const id = e.agentId + const loop = id === undefined ? 'main' : `agent ${id.slice(0, 8)}` + return `step ${e.index} ${loop}: ${e.model}/${String(e.effort)} → ` + + `${plan.model}/${String(plan.effort)}` +} + +function noteMain($: Api, st: State, plan: Plan): void { + st.lastMain = `${plan.model.replace(/^claude-/, '')}/${plan.effort ?? '-'}` + refresh($, st) +} + +function endMainTurn($: Api, st: State): void { + st.turnMain = st.pendingPrompt + st.pendingPrompt = null + st.explicitEffort.clear() + st.lastMain = '' + st.windowWarned = false + refresh($, st) +} + +// ---- registration ---------------------------------------------------- + +function registerSession(on: On, st: State): void { + on('session.start', async ($, e, next) => { + await reloadConfig($, st) + await registerCommand($) + refresh($, st) + return next(e) + }).catch(($, e, next) => next(e)) + on('session.end', async ($, e, next) => { + Object.assign(st, newState(st.cfg, st.source)) + return next(e) + }).catch(($, e, next) => next(e)) + on('command.run', { command: 'route' }, async ($, e) => ({ + text: await handleCommand($, st, e.args), + })).catch(($, e, next) => ({ text: `route failed (${next.error.kind})` })) +} + +function registerRouteTool(on: On, st: State): void { + on('tool.call', { tool: TOOL }, async ($, e) => { + const out = handleRouteTool(st, e) + refresh($, st) + vlog($, st, `route ${e.agentId ?? 'main'}: ${JSON.stringify(out)}`) + return out + }).catch(($, e, next) => ({ + result: `route failed (${next.error.kind}); nothing routed`, + })) +} + +function registerSkills(on: On, st: State): void { + on('tool.call', { tool: 'Skill' }, async ($, e, next) => { + if (st.off) return next(e) + const level = EFFORT_SKILL.exec(e.skill)?.[1] + if (isLevel(level)) return effortBridge(st, e.agentId, e.skill, level) + st.skillCalls += 1 + try { + onSkillLoad(st, e.skill, e.agentId) + return await next(e) + } finally { + st.skillCalls -= 1 + } + }).catch(($, e, next) => next(e)) + on('skill.prompt', async ($, e, next) => { + if (st.off || st.skillCalls > 0) return next(e) + return slashEffort(st, e.skill, e.text) ?? next(e) + }).catch(($, e, next) => next(e)) +} + +function registerAgents(on: On, st: State): void { + on('tool.call', { tool: 'Agent' }, async ($, e, next) => { + if (!st.off && isLevel(e.effort) && typeof e.tool_use_id === 'string') { + st.explicitEffort.set(e.tool_use_id, e.effort) + } + return next(e) + }).catch(($, e, next) => next(e)) + on('agent.spawn', async ($, e, next) => { + if (st.off) return next(e) + const frozen = e.fork || e.workflow !== undefined + const route = spawnRoute(st.cfg, e, frozen) + // An explicit model param on the Agent call always wins. + const wanted = e.model === undefined ? route?.model : undefined + const started = await next(wanted === undefined + ? e + : { ...e, model: resolveModel(st.cfg, wanted) }) + if (started.deny !== undefined) return started + trackLoop(st, e, started, route, frozen) + vlog($, st, `spawn ${e.subagentType}: ${e.model ?? '-'} → ${started.model}`) + return started + }).catch(($, e, next) => next(e)) +} + +function registerTurns(on: On, st: State): void { + on('turn.step', async function* ($, e, next) { + if (st.off) return yield* next(e) + const plan = await planStep($, st, e) + const changed = plan.model !== e.model || plan.effort !== e.effort + if (e.agentId === undefined) noteMain($, st, plan) + vlog($, st, stepLog(e, plan)) + const result = yield* next(changed ? withPlan(e, plan) : e) + vlog($, st, `step ${e.index} answered by ${result.usage?.model ?? '?'}`) + return result + }).catch(async function* ($, e, next) { + return yield* next(e) + }) + on('turn.complete', async ($, e, next) => { + if (e.agentId !== undefined) st.loops.delete(e.agentId) + else endMainTurn($, st) + return next(e) + }).catch(($, e, next) => next(e)) +} + +function registerPrompt(on: On, st: State): void { + on('prompt.submit', async ($, e, next) => { + if (st.off || e.origin.kind !== 'composer') return next(e) + const rule = st.rules.find(r => r.re.test(e.text)) + const route = rule ? phaseRoute(st.cfg, rule.phase) : undefined + if (rule && route) { + const routed: Routed = { phase: rule.phase, route, source: 'prompt' } + // Typed mid-turn and asked to wait: it belongs to the NEXT turn. + if (e.turnId !== undefined && e.wait) st.pendingPrompt = routed + else st.turnMain = routed + refresh($, st) + } + return next(e) + }).catch(($, e, next) => next(e)) + on('ui.render', { component: 'Spinner' }, async ($, e, next) => { + if (st.off || !st.cfg.spinner || !st.lastMain) return next(e) + const suffix = ` · ${st.lastMain}…` + return next({ ...e, props: { ...e.props, suffix } }) + }).catch(($, e, next) => next(e)) +} + +export const register: Register = on => { + const st = newState(mergeConfig(undefined, () => undefined), 'defaults') + registerSession(on, st) + registerRouteTool(on, st) + registerSkills(on, st) + registerAgents(on, st) + registerTurns(on, st) + registerPrompt(on, st) +}