diff --git a/mods/model-router/hooks/register.test.ts b/mods/model-router/hooks/register.test.ts index b1074d5..d85b9a2 100644 --- a/mods/model-router/hooks/register.test.ts +++ b/mods/model-router/hooks/register.test.ts @@ -1,11 +1,32 @@ -import { test, expect } from 'claude-code/testing' -import type { Engine } from 'claude-code/testing' +import { test, expect, mock } from 'claude-code/testing' +import type { Engine, MockClock } from 'claude-code/testing' import type { On, TurnStepInput } from 'claude-code' -/** Fires session.start so the mod loads its config and registers /route. */ -async function boot($: Engine, on: On): Promise { +const FABLE = 'claude-fable-5-1' +const OPUS = 'claude-opus-5-5' +const SONNET = 'claude-sonnet-5-5' +const HAIKU = 'claude-haiku-4-5-20251001' + +/** + * Fires session.start so the mod loads its config and registers /route. + * The bottom hooks the router reads are in place before the first `$` call: + * the session model, the classic failure and switch events, a mock clock, + * a small context (`tokens`; null = usage unanswered, size unknown). + */ +async function boot( + $: Engine, + on: On, + model: string = FABLE, + tokens: number | null = 1000, +): Promise { + const clock = mock.clock(on) + on('session.model', () => ({ value: model })) + on('classic.StopFailure', () => ({})) + on('classic.PostModelSwitch', () => ({})) + if (tokens !== null) usageOf(on, tokens) on('session.start', ($, e) => ({ cwd: e.cwd })) await $.session.start({ cwd: '/tmp', surface: null, isInteractive: false }) + return clock } /** Runs `/route ` as the user would type it. */ @@ -99,7 +120,7 @@ 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') + expect(mainLine(alias)).toContain('asked claude-sonnet-5-5, keeps ') const id = await route($, 'model=claude-x-9') expect(mainLine(id)).toContain('claude-x-9') expect(await route($, 'model=sonet')).toContain('unknown') @@ -446,3 +467,380 @@ test('typed slash with no agent and no marker writes the floor', async ( await $.skill.prompt({ skill: 'effort-max', text: 'x' }) expect(mainLine(await route($, 'show'))).toContain('floor max') }) + +// ---- tiers, breaker, derived phases ------------------------------------- + +type Rig = { seen: Seen[]; clock: MockClock; specs: (string | undefined)[] } + +type Failure = 'rate_limit' | 'overloaded' | 'invalid_request' + +/** Bottom hooks for a routed run, then the boot; `specs` records spawns. */ +async function bootRig( + $: Engine, + on: On, + model: string = FABLE, + tokens: number | null = 1000, +): Promise { + const seen: Seen[] = [] + const specs: (string | undefined)[] = [] + recordSteps(on, seen) + on('prompt.submit', ($, e) => ({ text: e.text })) + on('turn.complete', () => ({ text: '' })) + on('agent.spawn', ($, e) => { + specs.push(e.model) + return { model: e.model ?? e.parentModel, agentId: 'a1' } + }) + on('tool.call', { tool: 'Agent' }, () => ({ + result: { + status: 'async_launched' as const, + agentId: 'a1', + description: 'd', + prompt: 'p', + outputFile: '/tmp/o', + }, + })) + const clock = await boot($, on, model, tokens) + return { seen, clock, specs } +} + +/** One main step on `model`; returns what reached the bottom hook. */ +async function stepOn($: Engine, rig: Rig, model: string): Promise { + await runStep($, { ...highStep(), model }) + return rig.seen[rig.seen.length - 1] ?? { model: '', effort: undefined } +} + +const failWith = ($: Engine, error: Failure, agentId?: string) => + $.classic.StopFailure({ + error, + ...(agentId === undefined ? {} : { agent_id: agentId }), + }) + +const switchTo = ( + $: Engine, + from: string, + to: string, + source: 'command' | 'auto', +) => $.classic.PostModelSwitch({ + from_model: from, + to_model: to, + requested_model: null, + source, + context_tokens: 0, + prompt_cache_warm: false, + cache_ttl: '5m', + estimated_cache_write_usd: 0, + pricing: 'catalog', +}) + +const planRoute = ($: Engine) => + $.tool.call({ tool: ROUTE_TOOL, phase: 'plan' }) + +/** A bottom `session.usage` answering a context of `tokens`. */ +function usageOf(on: On, tokens: number): void { + on('session.usage', () => ({ + value: { + startedAt: 0, + context: { window: 1000000, tokens }, + rateLimits: [], + }, + })) +} + +test('tier: a plan route upgrades a haiku session to fable xhigh', async ( + $, on) => { + const rig = await bootRig($, on, HAIKU) + await planRoute($) + expect(await stepOn($, rig, HAIKU)).toEqual({ + model: FABLE, + effort: 'xhigh', + }) +}) + +test('tier: show prints the resolved id of each phase and a down line', async ( + $, on) => { + await bootRig($, on) + const text = await route($, 'show') + expect(text).toContain('plan=best→claude-fable-5-1/xhigh') + expect(text).toContain('judge=big→claude-opus-5-5/xhigh') + expect(text).toContain('down: none') +}) + +test('tier: an upgrade is skipped above the context cap', async ($, on) => { + const rig = await bootRig($, on, SONNET, 300000) + await planRoute($) + expect(await stepOn($, rig, SONNET)).toEqual({ + model: SONNET, + effort: 'xhigh', + }) +}) + +test('cap: unknown usage blocks the upgrade', async ($, on) => { + const rig = await bootRig($, on, HAIKU, null) + await planRoute($) + expect((await stepOn($, rig, HAIKU)).model).toBe(HAIKU) +}) + +test('cap: a down model leaves for a better wanted only under the cap', async ( + $, on) => { + const rig = await bootRig($, on, OPUS, 300000) + await planRoute($) + await stepOn($, rig, OPUS) + await failWith($, 'rate_limit') + expect((await stepOn($, rig, OPUS)).model).toBe(SONNET) +}) + +test('breaker: model_not_found on a non-table id marks nothing', async ( + $, on) => { + const rig = await bootRig($, on) + await planRoute($) + await stepOn($, rig, 'claude-zz-9') + await $.classic.StopFailure({ error: 'model_not_found' }) + expect(await route($, 'show')).toContain('down: none') +}) + +test('downgrade: mechanical on fable keeps the model, switch off', async ( + $, on) => { + const rig = await bootRig($, on) + await $.tool.call({ tool: ROUTE_TOOL, phase: 'mechanical' }) + expect(await stepOn($, rig, FABLE)).toEqual({ model: FABLE, effort: 'low' }) +}) + +test('downgrade: the switch on moves main to the cheap tier', async ( + $, on) => { + const rig = await bootRig($, on) + await route($, 'switch on') + await $.tool.call({ tool: ROUTE_TOOL, phase: 'mechanical' }) + const sent = await stepOn($, rig, FABLE) + expect(sent.model).toBe(HAIKU) + expect(sent.effort).toBeUndefined() +}) + +test('fallback: a rate limit on fable moves plan to opus xhigh', async ( + $, on) => { + const rig = await bootRig($, on) + await planRoute($) + expect((await stepOn($, rig, FABLE)).model).toBe(FABLE) + await failWith($, 'rate_limit') + expect(await stepOn($, rig, FABLE)).toEqual({ model: OPUS, effort: 'xhigh' }) + expect(await route($, 'show')).toContain('claude-fable-5-1 for 15 min') + await route($, 'reload') + expect((await stepOn($, rig, FABLE)).model).toBe(FABLE) +}) + +test('breaker: an invalid_request or a turn error never marks down', async ( + $, on) => { + const rig = await bootRig($, on) + await planRoute($) + await stepOn($, rig, FABLE) + await failWith($, 'invalid_request') + await $.turn.complete({ + turnId: 'u1', + answer: '', + durationMs: 1, + isAborted: false, + reason: 'error', + }) + expect((await stepOn($, rig, FABLE)).model).toBe(FABLE) + expect(await route($, 'show')).toContain('down: none') +}) + +test('breaker: the hold lapses with the clock, a second one doubles', async ( + $, on) => { + const rig = await bootRig($, on) + await planRoute($) + await stepOn($, rig, FABLE) + await failWith($, 'overloaded') + await rig.clock.advance(15 * 60000) + expect(await route($, 'show')).toContain('down: none') + await endTurn($) + await planRoute($) + expect((await stepOn($, rig, FABLE)).model).toBe(FABLE) + await failWith($, 'overloaded') + expect(await route($, 'show')).toContain('claude-fable-5-1 for 30 min') +}) + +test('breaker: a /model command clears the mark of its target', async ( + $, on) => { + const rig = await bootRig($, on) + await planRoute($) + await stepOn($, rig, FABLE) + await failWith($, 'rate_limit') + expect(await route($, 'show')).toContain('claude-fable-5-1 for') + await switchTo($, OPUS, FABLE, 'command') + expect(await route($, 'show')).toContain('down: none') + expect((await stepOn($, rig, FABLE)).model).toBe(FABLE) +}) + +test('breaker: model_not_found holds until the reload', async ($, on) => { + const rig = await bootRig($, on) + await planRoute($) + await stepOn($, rig, FABLE) + await $.classic.StopFailure({ error: 'model_not_found' }) + await rig.clock.advance(24 * 3600000) + expect(await route($, 'show')).toContain('until reload (model_not_found)') +}) + +test('engine fallback: an auto switch marks the model it left, once', async ( + $, on) => { + const rig = await bootRig($, on) + await switchTo($, FABLE, OPUS, 'auto') + const first = await route($, 'show') + expect(first).toContain('claude-fable-5-1 for 15 min (engine fallback)') + await planRoute($) + expect((await stepOn($, rig, OPUS)).model).toBe(OPUS) + await rig.clock.advance(60000) + await switchTo($, FABLE, OPUS, 'auto') + expect(await route($, 'show')).toContain('claude-fable-5-1 for 14 min') +}) + +test('unknown: a session model absent from the table is never switched', async ( + $, on) => { + const rig = await bootRig($, on) + await planRoute($) + expect((await stepOn($, rig, 'claude-zz-9')).model).toBe('claude-zz-9') +}) + +test('spawn: Explore goes to opus while sonnet is down', async ($, on) => { + const rig = await bootRig($, on) + await $.agent.spawn(spawnInput()) + await failWith($, 'overloaded', 'a1') + await $.agent.spawn(spawnInput()) + expect(rig.specs).toEqual([SONNET, OPUS]) +}) + +/** Types a plain prompt, idle, in the composer. */ +const typed = ($: Engine, text: string) => $.prompt.submit({ + text, + wait: false, + origin: { kind: 'composer' }, +}) + +const dispatch = ($: Engine) => + $.tool.call({ tool: 'Agent', description: 'd', prompt: 'p' }) + +const agentEnds = ($: Engine) => $.turn.complete({ + turnId: 'a1', + agentId: 'a1', + answer: '', + durationMs: 1, + isAborted: false, + reason: 'answer', +}) + +test('derived: a dispatch pushes orchestrate, the end pops the prompt', async ( + $, on) => { + await bootRig($, on) + await typed($, 'planifie la migration') + expect(mainLine(await route($, 'show'))).toContain('prompt plan') + await dispatch($) + expect(mainLine(await route($, 'show'))).toContain('derived orchestrate') + await agentEnds($) + expect(mainLine(await route($, 'show'))).toContain('prompt plan') +}) + +test('derived: a route declared after the dispatch survives the pop', async ( + $, on) => { + await bootRig($, on) + await typed($, 'planifie la migration') + await dispatch($) + await $.tool.call({ tool: ROUTE_TOOL, phase: 'reflect' }) + await agentEnds($) + expect(mainLine(await route($, 'show'))).toContain('model reflect') +}) + +test('default rule: planifie sets plan, a later route overrides', async ( + $, on) => { + const rig = await bootRig($, on, HAIKU) + await typed($, 'planifie la migration') + expect(await stepOn($, rig, HAIKU)).toEqual({ model: FABLE, effort: 'xhigh' }) + await $.tool.call({ tool: ROUTE_TOOL, phase: 'implement' }) + const line = mainLine(await route($, 'show')) + expect(line).toContain('model implement') + expect(line).not.toContain('prompt') +}) + +test('default rule: a typed slash command gets no default rule', async ( + $, on) => { + await bootRig($, on) + await typed($, '/analyze pourquoi ça plante') + expect(mainLine(await route($, 'show'))).toContain('session defaults') +}) + +test('default rule: /effort-low pourquoi sets the floor, no default', async ( + $, on) => { + await bootRig($, on) + await typed($, '/effort-low pourquoi ça plante') + await $.skill.prompt({ skill: 'effort-low', text: 'x' }) + const line = mainLine(await route($, 'show')) + expect(line).toContain('floor low') + expect(line).not.toContain('reflect') +}) + +test('per axis: a model-less sticky never hides a turn route tier', async ( + $, on) => { + const rig = await bootRig($, on, HAIKU) + await route($, 'effort=low') + await planRoute($) + expect(await stepOn($, rig, HAIKU)).toEqual({ model: FABLE, effort: 'low' }) +}) + +// ---- texts follow the decision, from the turn's model ------------------ + +test('text: effort-only route on a down model says fallback', async ( + $, on) => { + const rig = await bootRig($, on) + await stepOn($, rig, FABLE) + await failWith($, 'rate_limit') + const out = await $.tool.call({ tool: ROUTE_TOOL, effort: 'low' }) + expect(JSON.stringify(out)).toContain(`model ${OPUS} (fallback)`) +}) + +test('text: /route effort= on a down model names the fallback', async ( + $, on) => { + const rig = await bootRig($, on) + await stepOn($, rig, FABLE) + await failWith($, 'rate_limit') + await route($, 'effort=low') + expect(mainLine(await route($, 'show'))).toContain(`${OPUS} (fallback)`) +}) + +test('text: show names the session model after the turn ended', async ( + $, on) => { + const rig = await bootRig($, on, HAIKU) + await planRoute($) + expect((await stepOn($, rig, HAIKU)).model).toBe(FABLE) + await endTurn($) + await route($, 'mechanical') + const line = mainLine(await route($, 'show')) + expect(line).toContain(`model ${HAIKU}`) + expect(line).not.toContain(FABLE) +}) + +test('text: show reflects a /model command to opus', async ($, on) => { + const rig = await bootRig($, on) + await stepOn($, rig, FABLE) + await switchTo($, FABLE, OPUS, 'command') + await route($, 'mechanical') + const line = mainLine(await route($, 'show')) + expect(line).toContain(OPUS) + expect(line).not.toContain(FABLE) +}) + +test('text: an unknown session model prints "session model"', async ( + $, on) => { + await bootRig($, on, 'mystery-model') + await route($, 'plan') + expect(mainLine(await route($, 'show'))).toContain('keeps session model') +}) + +test('text: show names the model a floor upgrade moves to', async ( + $, on) => { + await bootRig($, on, HAIKU) + await $.prompt.submit({ + text: 'ultrathink please', + wait: false, + origin: { kind: 'composer' }, + }) + const line = mainLine(await route($, 'show')) + expect(line).toContain(`${FABLE} (upgrade)`) +}) diff --git a/mods/model-router/hooks/register.ts b/mods/model-router/hooks/register.ts index 4d757b8..09f2d3b 100644 --- a/mods/model-router/hooks/register.ts +++ b/mods/model-router/hooks/register.ts @@ -1,14 +1,22 @@ // 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. +// model and effort its phase deserves. Phases name a TIER (an ordered list of +// aliases, the first available wins); a circuit breaker fed by the engine's +// own failure events marks a model down for a while. 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 Route = { + tier?: string // a `tiers` key: the first AVAILABLE alias of its list + model?: string // alias or full id: explicit, never skipped when down + effort?: Level +} +type PromptMode = 'floor' | 'default' +type PromptRule = { pattern: string; phase: string; mode?: PromptMode } type Config = { models: Record // alias -> full id windows: Record // full id -> context window (tokens) @@ -16,14 +24,21 @@ type Config = { agents: Record // built-in subagentType -> phase skills: Record // skill name -> phase prompt: PromptRule[] - mainModelSwitch: boolean + tiers: Record // tier -> alias preference, best first + fallback: string[] // alias order, best first: the rank and the fallback chain + cooldownMinutes: number // breaker hold at the first strike + mainUpgrade: boolean // main may move UP to a phase's tier by itself + upgradeMaxTokens: number // above this context an upgrade is skipped + mainModelSwitch: boolean // gates DOWNGRADES only verbose: boolean spinner: boolean enabled: boolean // false: every hook passes through (per machine) } -type Rule = { re: RegExp; phase: string } -type Source = 'user' | 'model' | 'skill' | 'prompt' | 'slash' +type Rule = { re: RegExp; phase: string; mode: PromptMode } +type Source = 'user' | 'model' | 'skill' | 'prompt' | 'slash' | 'derived' type Routed = { phase: string; route: Route; source: Source } +type Hold = { until: number; reason: string } // until: ms, Infinity = reload +type Pushed = { prev: Routed | null; spawnIds: Set } type Loop = { effort?: Level // an agent's model is fixed at spawn: effort is its only axis explicitEffort: boolean // Agent call gave an effort: axis frozen @@ -34,7 +49,7 @@ type State = { source: string // 'defaults' or the override path userMain: Routed | null // /route by the user, sticky until /route clear turnMain: Routed | null // model route tool, skill table row, Skill(effort-*) - // bridge; dropped at turn end + // bridge, prompt default rule, derived orchestrate; dropped at turn end turnFloor: Routed | null // user-explicit level for this turn (prompt rule, // typed /effort-): a floor, main loop only pendingPrompt: Routed | null // typed mid-turn: the next turn's floor @@ -44,8 +59,16 @@ type State = { skillCalls: number // Skill tool calls in flight off: boolean // /route off or config: every hook passes through offConfig: boolean // `off` comes from the config key `enabled` - lastMain: string // "model/effort" of the last main step (spinner) - windowWarned: boolean // context-window warning already logged this turn + spinner: string // "model/effort" of the last main step of this turn + lastPlan: Plan | null // the plan last sent on main: the breaker's target + turnModel: string | undefined // sticky main model, set when the router moved + sessionModel: string // the main loop's model as /model shows it, '' unknown + down: Map // canonical id -> unavailable until (breaker) + strikes: Map // canonical id -> episodes, drives the backoff + agentModels: Map // agentId -> exact id sent at spawn + pushed: Pushed | null // derived orchestrate in force (background dispatch) + logged: Set // session-once log lines + turnLogged: Set // turn-once log lines warned: Set // hooks whose fail-open was already logged } type Log = (text: string) => void @@ -71,6 +94,16 @@ const HAIKU = 'claude-haiku' const MAX_PATTERN = 200 // chars of a prompt-rule pattern const MAX_PROMPT_SCAN = 4096 // chars of a prompt a rule is run against const MAX_CONFIG_BYTES = 65536 // override file size +const MAX_AGENT_MODELS = 256 // agent -> model entries kept (oldest dropped) +const PREFIX = 'claude-' +const ONE_M = /\[1m\]$/ // the long-context variant of a model id +// Hold = cooldownMinutes x step: 15 -> 30 -> 60 -> 120 -> 300 by default. +const BACKOFF_STEPS: readonly number[] = [1, 2, 4, 8, 20] +// StopFailure kinds that mean "this model cannot serve now". The others +// (context limit, network, auth...) are not availability. +const UNAVAILABLE: ReadonlySet = new Set([ + 'rate_limit', 'overloaded', 'billing_error', 'model_not_found', +]) const PHASE_KEY = /^[a-z][a-z0-9_-]{0,31}$/ const DEFAULT_CONFIG: Config = { @@ -81,22 +114,49 @@ const DEFAULT_CONFIG: Config = { fable: 'claude-fable-5-1', }, windows: { 'claude-haiku-4-5-20251001': 200000 }, + // A tier is an ordered alias list: the first one not down is used. + tiers: { + best: ['fable', 'opus', 'sonnet'], + big: ['opus', 'fable', 'sonnet'], + work: ['sonnet', 'opus'], + cheap: ['haiku', 'sonnet'], + }, + fallback: ['fable', 'opus', 'sonnet', 'haiku'], // rank = index, best first + cooldownMinutes: 15, + mainUpgrade: true, + upgradeMaxTokens: 200000, // an upgrade re-reads the whole context cold 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' }, + plan: { tier: 'best', effort: 'xhigh' }, + reflect: { tier: 'best', effort: 'high' }, + orchestrate: { tier: 'best', effort: 'medium' }, + escalate: { tier: 'best', effort: 'max' }, + judge: { tier: 'big', effort: 'xhigh' }, + implement: { tier: 'work', effort: 'medium' }, + write: { tier: 'work', effort: 'medium' }, + verify: { tier: 'work', effort: 'xhigh' }, + explore: { tier: 'work', effort: 'medium' }, + mechanical: { tier: 'cheap', 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' }], + // `floor` rules set the turn's minimum effort; `default` rules set the + // turn's route, which a route call or a skill overrides. Neither lowers. + prompt: [ + { pattern: '\\bultrathink\\b', phase: 'escalate', mode: 'floor' }, + { + pattern: '(?, 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 } @@ -156,9 +212,14 @@ const acceptWindow = (key: string, v: unknown): number | undefined => ? v : undefined -function acceptPhase(models: Record) { +/** A phase names a tier OR a model, never both; a tier must exist. */ +function acceptPhase( + models: Record, + tiers: Record, +) { return (key: string, v: unknown): Route | undefined => { if (!PHASE_KEY.test(key) || !isRecord(v)) return undefined + if (v.tier !== undefined && v.model !== undefined) return undefined const route: Route = {} if (v.effort !== undefined) { if (!isLevel(v.effort)) return undefined @@ -168,10 +229,66 @@ function acceptPhase(models: Record) { if (!isModelName(models, v.model)) return undefined route.model = v.model } - return route.effort || route.model ? route : undefined + if (v.tier !== undefined) { + if (typeof v.tier !== 'string' || !hasKey(tiers, v.tier)) return undefined + route.tier = v.tier + } + return route.effort || route.model || route.tier ? route : undefined } } +/** A tier: a non-empty alias list; unknown aliases dropped, duplicates too. */ +function acceptTier(models: Record, log: Log) { + return (key: string, v: unknown): string[] | undefined => { + // a tier named like an alias would make a bare name ambiguous + if (!PHASE_KEY.test(key) || hasKey(models, key)) return undefined + if (!Array.isArray(v)) return undefined + const items = v as unknown[] + const kept = items.filter( + (a): a is string => typeof a === 'string' && hasKey(models, a)) + if (kept.length < items.length) { + log(`model-router: config tiers.${key} entries dropped`) + } + return kept.length > 0 ? [...new Set(kept)] : undefined + } +} + +/** Appends the `models` aliases a user's chain omits, so all are ranked. */ +function withEveryAlias( + chain: string[], + models: Record, + log: Log, +): string[] { + const missing = Object.keys(models).filter(a => !chain.includes(a)) + if (missing.length > 0) { + log(`model-router: config fallback lacks ${missing.join(', ')}; appended`) + } + return [...chain, ...missing] +} + +/** The fallback chain: deduplicated aliases of `models`, else the default. */ +function pickFallback( + user: unknown, + base: string[], + models: Record, + log: Log, +): string[] { + if (user === undefined) return base + const items = Array.isArray(user) ? (user as unknown[]) : [] + const kept = items.filter( + (a): a is string => typeof a === 'string' && hasKey(models, a)) + if (kept.length > 0) return withEveryAlias([...new Set(kept)], models, log) + log('model-router: config fallback ignored: need a list of model aliases') + return base +} + +function pickPositive(v: unknown, fallback: number, name: string, log: Log) { + if (v === undefined) return fallback + if (typeof v === 'number' && Number.isInteger(v) && v > 0) return v + log(`model-router: config ${name} ignored: not a positive integer`) + return fallback +} + function acceptPhaseRef(phases: Record) { return (_key: string, v: unknown): string | undefined => typeof v === 'string' && PHASE_KEY.test(v) && hasKey(phases, v) @@ -179,22 +296,32 @@ function acceptPhaseRef(phases: Record) { : undefined } -function compiles(pattern: string): boolean { - try { - new RegExp(pattern, 'i') - return true - } catch { - return false +/** Unicode-aware first; a pattern only valid without `u` still works. */ +function buildRegex(pattern: string): RegExp | undefined { + for (const flags of ['iu', 'i']) { + try { + return new RegExp(pattern, flags) + } catch { + // retry with the next flag set + } } + return undefined } -function acceptRule(phases: Record, v: unknown) { +/** Absent `mode` = floor: override files written before modes keep meaning. */ +function acceptRule( + phases: Record, + v: unknown, +): PromptRule | undefined { if (!isRecord(v)) return undefined - const { pattern, phase } = v + const { pattern, phase, mode } = v if (typeof pattern !== 'string' || typeof phase !== 'string') return undefined + if (mode !== undefined && mode !== 'floor' && mode !== 'default') { + return undefined + } if (pattern.length > MAX_PATTERN) return undefined - return hasKey(phases, phase) && compiles(pattern) - ? { pattern, phase } + return hasKey(phases, phase) && buildRegex(pattern) + ? { pattern, phase, mode: mode ?? 'floor' } : undefined } @@ -231,6 +358,34 @@ function pickEnabled(v: unknown, fallback: boolean, log: Log): boolean { return pickBool(v, fallback) } +type Routing = Pick + +/** Tiers, fallback chain and breaker knobs, validated against `models`. */ +function mergeRouting( + base: Config, + user: Record, + models: Record, + log: Log, +): Routing { + const tiers = mergeTable( + base.tiers, user.tiers, 'tiers', acceptTier(models, log), log) + const fallback = pickFallback(user.fallback, base.fallback, models, log) + if (tiers.best?.[0] !== fallback[0]) { + log('model-router: tiers.best does not lead the fallback chain; rank ' + + 'comes from the fallback list alone') + } + return { + tiers, + fallback, + cooldownMinutes: pickPositive( + user.cooldownMinutes, base.cooldownMinutes, 'cooldownMinutes', log), + mainUpgrade: pickBool(user.mainUpgrade, base.mainUpgrade), + upgradeMaxTokens: pickPositive( + user.upgradeMaxTokens, base.upgradeMaxTokens, 'upgradeMaxTokens', log), + } +} + /** Defaults overlaid with the user's entries, each validated first. */ function mergeConfig(user: unknown, log: Log): Config { const base = structuredClone(DEFAULT_CONFIG) @@ -240,11 +395,13 @@ function mergeConfig(user: unknown, log: Log): Config { } const models = mergeTable( base.models, user.models, 'models', acceptModel, log) - const phases = mergeTable( - base.phases, user.phases, 'phases', acceptPhase(models), log) + const routing = mergeRouting(base, user, models, log) + const phases = mergeTable(base.phases, user.phases, 'phases', + acceptPhase(models, routing.tiers), log) const ref = acceptPhaseRef(phases) return { models, + ...routing, windows: mergeTable( base.windows, user.windows, 'windows', acceptWindow, log), phases, @@ -310,11 +467,15 @@ async function loadConfig( 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, - })) +/** Compiles the prompt rules; one that fails to compile is dropped, logged. */ +function compileRules(cfg: Config, log: Log): Rule[] { + const rules: Rule[] = [] + for (const r of cfg.prompt) { + const re = buildRegex(r.pattern) + if (re) rules.push({ re, phase: r.phase, mode: r.mode ?? 'floor' }) + else log(`model-router: prompt rule for ${r.phase} does not compile`) + } + return rules } // ---- state ----------------------------------------------------------- @@ -322,7 +483,7 @@ function compileRules(cfg: Config): Rule[] { function newState(cfg: Config, source: string): State { return { cfg, - rules: compileRules(cfg), + rules: compileRules(cfg, () => undefined), source, userMain: null, turnMain: null, @@ -334,12 +495,34 @@ function newState(cfg: Config, source: string): State { skillCalls: 0, off: false, offConfig: false, - lastMain: '', - windowWarned: false, + spinner: '', + lastPlan: null, + turnModel: undefined, + sessionModel: '', + down: new Map(), + strikes: new Map(), + agentModels: new Map(), + pushed: null, + logged: new Set(), + turnLogged: new Set(), warned: new Set(), } } +/** + * /clear rebuilds the state but keeps what is account- or process-wide: + * the breaker (a model's quota outlives the conversation) and the model. + */ +function resetSession(st: State): void { + const kept = { + down: st.down, + strikes: st.strikes, + agentModels: st.agentModels, + sessionModel: st.sessionModel, + } + Object.assign(st, newState(st.cfg, st.source), kept) +} + /** Logs a hook's fail-open once per session; never throws itself. */ function warnOnce(st: State, $: Api, hook: string, kind: string): void { if (st.warned.has(hook)) return @@ -361,6 +544,289 @@ function safely(st: State, $: Api, hook: string, work: () => void): void { } } +// ---- models: ids, tiers, breaker, the main decision ------------------- + +/** The table id an alias, a `[1m]` variant or a dated id stands for. */ +function canonical(cfg: Config, id: string): string { + const bare = id.replace(ONE_M, '') + if (hasKey(cfg.models, bare)) return cfg.models[bare] ?? bare + if (!bare.startsWith(PREFIX) || bare.length <= PREFIX.length) return bare + const hit = Object.values(cfg.models).find( + known => bare.startsWith(known)) + return hit ?? bare +} + +/** The table alias of a canonical id; undefined for an id the table lacks. */ +const aliasOf = (cfg: Config, id: string): string | undefined => + Object.keys(cfg.models).find(alias => cfg.models[alias] === id) + +/** Position in the fallback chain, best first; undefined when unranked. */ +function modelRank(cfg: Config, id: string): number | undefined { + const alias = aliasOf(cfg, id) + const at = alias === undefined ? -1 : cfg.fallback.indexOf(alias) + return at < 0 ? undefined : at +} + +const routeName = (r: Route | null | undefined): string | undefined => + r?.tier ?? r?.model + +/** First alias of the list whose model is not down. */ +function availableIn(st: State, aliases: readonly string[]) { + for (const alias of aliases) { + const id = st.cfg.models[alias] + if (id !== undefined && !st.down.has(id)) return id + } + return undefined +} + +/** The first model not down in the fallback chain, after `after`'s place. */ +function nextAvailable(st: State, after: string | undefined) { + const { fallback, models } = st.cfg + const at = fallback.findIndex(alias => models[alias] === after) + return availableIn(st, fallback.slice(at + 1)) +} + +/** + * A tier, alias or id as an id to run on. A tier skips down aliases, then + * walks the global chain (from `after`, default the tier's last alias); an + * alias or id is explicit and never skipped. + */ +function resolveName(st: State, name: string, after?: string) { + const tier = hasKey(st.cfg.tiers, name) ? st.cfg.tiers[name] : undefined + if (tier === undefined) return canonical(st.cfg, name) + const last = tier[tier.length - 1] + const from = after ?? (last === undefined ? undefined : st.cfg.models[last]) + return availableIn(st, tier) ?? nextAvailable(st, from) +} + +function resolveRoute(st: State, route: Route | undefined) { + const name = routeName(route) + return name === undefined ? undefined : resolveName(st, name) +} + +/** Drops lapsed holds; the clock is read only while something is held. */ +async function prune($: Api, st: State): Promise { + if (st.down.size === 0) return 0 + const now = await $.clock.now() + for (const [id, hold] of st.down) { + if (hold.until <= now) st.down.delete(id) + } + return now +} + +const holdWord = (until: number, now: number): string => + until === Infinity + ? 'until reload' + : `for ${Math.max(1, Math.ceil((until - now) / 60000))} min` + +function holdMinutes(cfg: Config, strikes: number, reason: string): number { + if (reason === 'model_not_found') return Infinity + const step = BACKOFF_STEPS[Math.min(strikes, BACKOFF_STEPS.length) - 1] ?? 1 + return cfg.cooldownMinutes * step +} + +/** + * Marks a table model down for an episode. A mark on a model already down + * adds no strike and no log, except `model_not_found`, which lengthens a + * timed hold to "until reload". Inert while the router is off. + */ +function markDown( + $: Api, + st: State, + id: string, + reason: string, + now: number, +): void { + if (st.off || aliasOf(st.cfg, id) === undefined) return + const held = st.down.get(id) + const lengthen = reason === 'model_not_found' && held?.until !== Infinity + if (held && !lengthen) return + const strikes = (st.strikes.get(id) ?? 0) + (held ? 0 : 1) + st.strikes.set(id, strikes) + const until = now + holdMinutes(st.cfg, strikes, reason) * 60000 + st.down.set(id, { until, reason }) + $.ui.log(`model-router: ${id} unavailable (${reason}) ${ + holdWord(until, now)}; routing falls back`) +} + +/** `/route reload` and a user /model: the marks are stale. */ +function clearBreaker(st: State, id?: string): void { + if (id === undefined) { + st.down.clear() + st.strikes.clear() + } else { + st.down.delete(id) + st.strikes.delete(id) + } + st.turnModel = undefined +} + +type Call = { + model: string // exact string to send: `cur` verbatim when not moved + why: string + moved: boolean + log?: string // turn-once line + logKey?: string // its dedupe key when the text varies; default the text + once?: string // session-once line +} + +const keep = (cur: string, why = 'unchanged', log?: string): Call => + ({ model: cur, why, moved: false, ...(log === undefined ? {} : { log }) }) + +/** The replacement keeps the session's `[1m]` tier, haiku has none. */ +function moveTo(cur: string, target: string, why: string): Call { + const carried = ONE_M.test(cur) && !target.startsWith(HAIKU) + const model = carried ? `${target}[1m]` : target + const once = carried + ? `model-router: ${why} to ${model}: the [1m] variant is carried over` + : undefined + return { model, why, moved: true, ...(once === undefined ? {} : { once }) } +} + +/** True when the context still fits the target model's known window. */ +function fits(st: State, id: string, tokens: number | undefined): boolean { + const limit = hasKey(st.cfg.windows, id) ? st.cfg.windows[id] : undefined + return limit === undefined || (tokens !== undefined && tokens < limit) +} + +const noFit = (id: string): string => + `model-router: no switch to ${id}: context not known to fit` + +/** Why the upgrade cap blocks a move, or undefined. Unknown size = blocked. */ +function capBlock(st: State, tokens: number | undefined): string | undefined { + if (tokens === undefined) return 'context size unknown' + const max = st.cfg.upgradeMaxTokens + return tokens > max ? `context ${tokens} tokens over ${max}` : undefined +} + +/** True when `wanted` ranks above `id` in the fallback chain. */ +function ranksAbove(st: State, wanted: string, id: string): boolean { + const rw = modelRank(st.cfg, wanted) + const rc = modelRank(st.cfg, id) + return rw !== undefined && rc !== undefined && rw < rc +} + +/** + * `cur` is down: `wanted`, else the next model of the chain that fits. A + * better `wanted` is an upgrade and passes the same switch and cap. + */ +function leaveDown( + st: State, + cur: string, + wanted: string | undefined, + tokens: number | undefined, +): Call { + const id = canonical(st.cfg, cur) + const upOk = st.cfg.mainUpgrade && capBlock(st, tokens) === undefined + for (const option of [wanted, nextAvailable(st, id)]) { + if (option === undefined || st.down.has(option)) continue + if (aliasOf(st.cfg, option) === undefined) continue + if (option === wanted && !upOk && ranksAbove(st, option, id)) continue + if (fits(st, option, tokens)) return moveTo(cur, option, 'fallback') + } + return keep(cur, 'fallback unavailable') +} + +/** `wanted` ranks above `cur`: upgrade, under the switch and the cap. */ +function upgradeCall( + st: State, + cur: string, + wanted: string, + tokens: number | undefined, +): Call { + if (!st.cfg.mainUpgrade) return keep(cur, 'switch off') + const block = capBlock(st, tokens) + if (block !== undefined) { + const why = `upgrade skipped: ${block}` + const call = keep(cur, why, `model-router: ${why}`) + return { ...call, logKey: 'upgrade-skipped' } + } + if (!fits(st, wanted, tokens)) return keep(cur, 'no fit', noFit(wanted)) + return moveTo(cur, wanted, 'upgrade') +} + +/** `wanted` is cheaper (or unranked): only with the downgrade switch on. */ +function downgradeCall( + st: State, + cur: string, + wanted: string, + tokens: number | undefined, +): Call { + if (!st.cfg.mainModelSwitch) return keep(cur, 'switch off') + if (!fits(st, wanted, tokens)) return keep(cur, 'no fit', noFit(wanted)) + return moveTo(cur, wanted, 'downgrade') +} + +/** A model the table does not rank is never switched; said once. */ +function unknownCall(cur: string, id: string): Call { + const call = keep(cur, 'model unknown to the table') + if (id === '') return call + const once = `model-router: ${id} unknown to the models table; no switch` + return { ...call, once } +} + +/** + * The one decision of the main loop's model, in this order: router off, a + * model the table does not rank (never touched), `cur` down (leave it, + * always), no or same wanted model, a better one (upgrade), a cheaper one + * (downgrade). Used by the step AND by every text, so they agree. + */ +function decideMain( + st: State, + cur: string, + wanted: string | undefined, + tokens: number | undefined, +): Call { + const id = canonical(st.cfg, cur) + if (st.off) return keep(cur) + if (modelRank(st.cfg, id) === undefined) return unknownCall(cur, id) + if (st.down.has(id)) return leaveDown(st, cur, wanted, tokens) + if (wanted === undefined) return keep(cur) + if (aliasOf(st.cfg, wanted) === aliasOf(st.cfg, id)) return keep(cur) + return ranksAbove(st, wanted, id) + ? upgradeCall(st, cur, wanted, tokens) + : downgradeCall(st, cur, wanted, tokens) +} + +/** Model axis: sticky, then turn route, then the floor's own model. */ +const mainModel = (st: State): string | undefined => + routeName(st.userMain?.route) ?? + routeName(st.turnMain?.route) ?? + routeName(st.turnFloor?.route) + +async function readTokens($: Api): Promise { + try { + return (await $.session.usage()).context.tokens + } catch { + return undefined + } +} + +type Verdict = { call: Call; wanted: string | undefined } + +/** What the main loop would run on from `cur`: wanted model + decision. */ +async function decideFor($: Api, st: State, cur: string): Promise { + const name = mainModel(st) + const wanted = name === undefined + ? undefined + : resolveName(st, name, canonical(st.cfg, cur)) + const needsTokens = wanted !== undefined || st.down.size > 0 + const tokens = needsTokens ? await readTokens($) : undefined + return { call: decideMain(st, cur, wanted, tokens), wanted } +} + +function logCall($: Api, st: State, call: Call): void { + const key = call.logKey ?? call.log + if (call.log !== undefined && key !== undefined && !st.turnLogged.has(key)) { + st.turnLogged.add(key) + $.ui.log(call.log) + } + if (call.once !== undefined && !st.logged.has(call.once)) { + st.logged.add(call.once) + $.ui.log(call.once) + } +} + /** The main loop's effective route: user /route > latest turn route. */ const mainRoute = (st: State): Routed | null => st.userMain ?? st.turnMain @@ -396,12 +862,6 @@ function mainEffort(st: State, engine: Effort): Decision { return { effort, by: sticky === undefined ? 'turn' : 'sticky' } } -/** Model axis: sticky, then turn route, then the floor's own model. */ -const mainModel = (st: State): string | undefined => - st.userMain?.route.model ?? - st.turnMain?.route.model ?? - st.turnFloor?.route.model - const floorSource = (f: Routed): string => f.source === 'prompt' ? `prompt rule ${f.phase}` : `typed /${f.phase}` @@ -463,9 +923,6 @@ const routerWord = (st: State): string => // ---- text ------------------------------------------------------------ -const modelText = (cfg: Config, model: string | undefined): string => - model === undefined ? '-' : resolveModel(cfg, model) - /** The floor's level when it carries one and the router is on. */ function liveFloor(st: State): { f: Routed; level: Level } | undefined { const f = st.turnFloor @@ -473,44 +930,92 @@ function liveFloor(st: State): { f: Routed; level: Level } | undefined { return st.off || !f || level === undefined ? undefined : { f, level } } -/** True when the switch would put the main loop on a haiku model. */ -function mainOnHaiku(st: State): boolean { - const model = mainModel(st) - return st.cfg.mainModelSwitch && model !== undefined && - resolveModel(st.cfg, model).startsWith(HAIKU) +/** An id the table knows, else "session model": '' and foreign ids alike. */ +function idWord(st: State, model: string): string { + const known = aliasOf(st.cfg, canonical(st.cfg, model)) !== undefined + return known ? model : 'session model' } -function effortWord(st: State): string { - if (mainOnHaiku(st)) return '- (haiku takes none)' +/** The router left a down model, or wanted to and found nowhere to go. */ +const isFallback = (call: Call): boolean => call.why.startsWith('fallback') + +/** Model words of the main line, from the decision a step would take. */ +function modelWord(st: State, v: Verdict): string { + const id = idWord(st, v.call.model) + if (v.call.moved) return `${id} (${v.call.why})` + if (v.wanted === undefined) { + return isFallback(v.call) ? `${id} (${v.call.why})` : '-' + } + if (canonical(st.cfg, v.call.model) === v.wanted) return id + return `asked ${v.wanted}, keeps ${id} (${v.call.why})` +} + +/** The tier a route names, shown beside the model it resolved to. */ +function tierWord(st: State, word: string): string { + const name = mainModel(st) + const tier = name !== undefined && hasKey(st.cfg.tiers, name) + return tier ? `${word} [tier ${name}]` : word +} + +/** Haiku takes no effort: the main line says so when it will run there. */ +function effortWord(st: State, v: Verdict): string { + if (v.call.model.startsWith(HAIKU)) return '- (haiku takes none)' return String(mainEffort(st, undefined).effort ?? '-') } -function mainText(st: State): string { +function mainText(st: State, v: Verdict): string { const r = mainRoute(st) const live = liveFloor(st) const floor = live ? ` · floor ${live.level} (${live.f.phase})` : '' - if (!r) return 'main: session defaults' + floor - const model = modelText(st.cfg, mainModel(st)) + if (!r) { + const left = v.call.moved || isFallback(v.call) + ? ` · model ${modelWord(st, v)}` + : '' + return 'main: session defaults' + left + floor + } + const model = tierWord(st, modelWord(st, v)) return `main: ${r.source} ${r.phase} · model ${model} · effort ${ - effortWord(st)}${floor}` + effortWord(st, v)}${floor}` } -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'}`) +/** `name=→/` for every phase. */ +function phasesText(st: State): string { + const entries = Object.entries(st.cfg.phases).map(([name, r]) => { + const asked = routeName(r) + const id = asked === undefined ? undefined : resolveName(st, asked) + const model = asked === undefined + ? 'session' + : asked === id ? asked : `${asked}→${id ?? '-'}` + return `${name}=${model}/${r.effort ?? 'session'}` + }) return `phases: ${entries.join(' ')}` } -function show(st: State): string { +function downText(st: State, now: number): string { + const held = [...st.down].map(([id, h]) => + `${id} ${holdWord(h.until, now)} (${h.reason})`) + return `down: ${held.length > 0 ? held.join(', ') : 'none'}` +} + +/** The verdict a text reports: what the next main step would decide. */ +async function snapshot($: Api, st: State) { + const now = await prune($, st) + const cur = st.turnModel ?? st.sessionModel + return { now, ...(await decideFor($, st, cur)) } +} + +async function show($: Api, st: State): Promise { const c = st.cfg const flag = (b: boolean) => (b ? 'on' : 'off') + const s = await snapshot($, st) return [ - mainText(st), - `router: ${routerWord(st)} · switch: ${flag(c.mainModelSwitch)} · ` + - `verbose: ${flag(c.verbose)} · spinner: ${flag(c.spinner)}`, + mainText(st, s), + `router: ${routerWord(st)} · switch: ${flag(c.mainModelSwitch)} ` + + `(downgrade) · upgrade: ${flag(c.mainUpgrade)} · verbose: ${ + flag(c.verbose)} · spinner: ${flag(c.spinner)}`, + downText(st, s.now), `live loops: ${st.loops.size}`, - phasesText(c), + phasesText(st), `config: ${st.source}`, ].join('\n') } @@ -565,19 +1070,24 @@ function parseRoute(cfg: Config, args: string): Routed | string { return { phase: 'custom', route, source: 'user' } } -function toggle(st: State, what: string, arg: string | undefined): string { +async function toggle( + $: Api, + st: State, + what: string, + arg: string | undefined, +): Promise { 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) + return show($, st) } -function setUserRoute($: Api, st: State, args: string): string { +async function setUserRoute($: Api, st: State, args: string) { const parsed = parseRoute(st.cfg, args) if (typeof parsed === 'string') return parsed st.userMain = parsed refresh($, st) - return show(st) + return show($, st) } /** @@ -589,7 +1099,7 @@ async function reloadConfig($: Api, st: State): Promise { const loaded = await loadConfig($, text => $.ui.log(text)) if (loaded) { st.cfg = loaded.cfg - st.rules = compileRules(loaded.cfg) + st.rules = compileRules(loaded.cfg, text => $.ui.log(text)) st.source = loaded.source applyEnabled(st, loaded.cfg.enabled) } else { @@ -598,29 +1108,35 @@ async function reloadConfig($: Api, st: State): Promise { await registerTool($, st) } +/** The breaker is cleared first, whatever the config read then does. */ +async function reloadCommand($: Api, st: State): Promise { + clearBreaker(st) + await reloadConfig($, st) + refresh($, st) + return 'config reloaded\n' + (await show($, 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) + return show($, st) case 'clear': clearRoutes(st) refresh($, st) - return 'route cleared\n' + show(st) + return 'route cleared\n' + (await show($, st)) case 'on': case 'off': st.off = head === 'off' st.offConfig = false refresh($, st) - return show(st) + return show($, st) case 'reload': - await reloadConfig($, st) - refresh($, st) - return 'config reloaded\n' + show(st) + return reloadCommand($, st) case 'switch': case 'verbose': - return toggle(st, head, rest[0]) + return toggle($, st, head, rest[0]) default: return setUserRoute($, st, args) } @@ -634,10 +1150,11 @@ async function registerTool($: Api, st: State): Promise { name: 'route', description: 'Declare the phase of the work ahead so the next requests of THIS ' + - 'loop run at the right effort (and, on the main loop, the model ' + - 'when the switch is on). Call it before a span of work changes ' + - 'nature (planning, orchestrating, mechanical work). Effort only ' + - 'for a sub-agent; the model of a sub-agent is fixed at spawn. ' + + 'loop run at the right effort and model. Call it before a span of ' + + 'work changes nature (planning, orchestrating, mechanical work). ' + + 'The main loop moves up to a phase\'s tier by itself (below the ' + + 'context cap), down only with the switch on; a sub-agent\'s model ' + + 'is fixed at spawn. ' + `Phases: ${Object.keys(st.cfg.phases).join(', ')}.`, inputSchema: { type: 'object', @@ -708,25 +1225,34 @@ function clearLoop(st: State, agentId: string | undefined): string { return 'route cleared for this agent' } +/** What the main loop's model will do, in the words of the decision. */ +async function mainAnswer($: Api, st: State): Promise { + const { call, wanted } = await snapshot($, st) + if (call.moved) return `${idWord(st, call.model)} (${call.why})` + const quiet = call.why === 'unchanged' || + (wanted === undefined && !isFallback(call)) + return quiet ? 'unchanged' : `unchanged (${call.why})` +} + /** Truthful answer: states what the calling loop will actually do. */ -function routedText(st: State, agentId: string | undefined, p: Picked): string { +async function routedText( + $: Api, + st: State, + agentId: string | undefined, + p: Picked, +): Promise { const note = agentId === undefined ? mainNote(st, p.route.effort) : '' if (note) return `recorded ${p.phase} for this turn, but ${note}` const loop = agentId === undefined ? undefined : st.loops.get(agentId) const effort = loop?.explicitEffort ? undefined : p.route.effort - const model = agentId === undefined && st.cfg.mainModelSwitch - ? p.route.model - : undefined - const modelNote = !p.route.model || model !== undefined - ? '' - : agentId === undefined ? ' (switch off)' : ' (fixed at spawn)' + const model = agentId === undefined + ? await mainAnswer($, st) + : routeName(p.route) ? 'unchanged (fixed at spawn)' : 'unchanged' return `routed ${agentId === undefined ? 'main' : 'this agent'} to ` + - `${p.phase}: effort ${effort ?? 'unchanged'}, model ` + - `${model === undefined ? 'unchanged' : resolveModel(st.cfg, model)}` + - modelNote + `${p.phase}: effort ${effort ?? 'unchanged'}, model ${model}` } -function handleRouteTool(st: State, e: RouteInput) { +async function handleRouteTool($: Api, st: State, e: RouteInput) { if (st.off) { return { result: 'model-router is off (/route on to resume); nothing routed', @@ -736,7 +1262,7 @@ function handleRouteTool(st: State, e: RouteInput) { 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) } + return { result: await routedText($, st, e.agentId, picked) } } // ---- skills ---------------------------------------------------------- @@ -839,6 +1365,11 @@ function spawnRoute(cfg: Config, e: SpawnIn, frozen: boolean) { return phase === undefined ? undefined : phaseRoute(cfg, phase) } +/** The model a spawn is rewritten to; an explicit `model` param wins. */ +function spawnTarget(st: State, e: SpawnIn, route: Route | undefined) { + return e.model === undefined ? resolveRoute(st, route) : undefined +} + function trackLoop(st: State, e: SpawnIn, started: { agentId?: string }, route: Route | undefined): void { @@ -851,6 +1382,62 @@ function trackLoop(st: State, e: SpawnIn, started: { }) } +/** The breaker's target for an agent's failure; oldest entries dropped. */ +function rememberAgent(st: State, agentId: string, model: string): void { + st.agentModels.set(agentId, model.replace(ONE_M, '')) + if (st.agentModels.size <= MAX_AGENT_MODELS) return + const oldest = st.agentModels.keys().next().value + if (oldest !== undefined) st.agentModels.delete(oldest) +} + +// ---- derived orchestrate --------------------------------------------- + +/** + * A main Agent dispatch is orchestration: the turn's route becomes + * `orchestrate` until the spawned agents end. A route the model or a skill + * declared is never replaced. + */ +function pushOrchestrate(st: State): void { + const src = st.turnMain?.source + const route = phaseRoute(st.cfg, 'orchestrate') + if (st.pushed !== null || !route) return + if (src === 'model' || src === 'skill') return + st.pushed = { prev: st.turnMain, spawnIds: new Set() } + st.turnMain = { phase: 'orchestrate', route: { ...route }, source: 'derived' } +} + +/** Restores the route from before the dispatch, unless one replaced it. */ +function popOrchestrate(st: State): void { + if (st.pushed && st.turnMain?.source === 'derived') { + st.turnMain = st.pushed.prev + } + st.pushed = null +} + +/** The agent id of a background launch, from the Agent tool's result. */ +function launchedId(result: unknown): string | undefined { + if (!isRecord(result) || result.status !== 'async_launched') return undefined + return typeof result.agentId === 'string' ? result.agentId : undefined +} + +/** After the Agent call: wait for a background agent, else pop at once. */ +function afterDispatch(st: State, result: unknown): void { + const id = launchedId(result) + if (id !== undefined) { + pushOrchestrate(st) + st.pushed?.spawnIds.add(id) + } else if (st.pushed && st.pushed.spawnIds.size === 0) { + popOrchestrate(st) + } +} + +/** An agent's loop ended: forget it, pop when it was the last awaited. */ +function endAgent(st: State, agentId: string): void { + st.loops.delete(agentId) + const awaited = st.pushed?.spawnIds + if (awaited?.delete(agentId) && awaited.size === 0) popOrchestrate(st) +} + // ---- turn steps ------------------------------------------------------ function agentPlan(st: State, e: StepIn): Plan { @@ -858,32 +1445,19 @@ function agentPlan(st: State, e: StepIn): Plan { return { model: 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 -} - +/** + * The main loop's plan: effort from the floor/sticky/turn decision, model + * from `decideMain`. `cur` is the model the router moved this turn, else + * the engine's own (verbatim: an engine fallback is respected). + */ async function mainPlan($: Api, st: State, e: StepIn): Promise { const { effort } = mainEffort(st, e.effort) - const wanted = mainModel(st) - 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 } + await prune($, st) + const cur = st.turnModel ?? e.model + const { call } = await decideFor($, st, cur) + logCall($, st, call) + if (call.moved) st.turnModel = call.model + return { model: call.model, effort } } async function planStep($: Api, st: State, e: StepIn): Promise { @@ -908,7 +1482,8 @@ function stepLog(e: StepIn, plan: Plan): string { } function noteMain($: Api, st: State, plan: Plan): void { - st.lastMain = `${plan.model.replace(/^claude-/, '')}/${plan.effort ?? '-'}` + st.lastPlan = plan + st.spinner = `${plan.model.replace(/^claude-/, '')}/${plan.effort ?? '-'}` refresh($, st) } @@ -916,18 +1491,88 @@ function endMainTurn($: Api, st: State): void { st.turnFloor = st.pendingPrompt st.pendingPrompt = null st.turnMain = null + st.pushed = null + st.turnModel = undefined st.typedSlash = false st.explicitEffort.clear() - st.lastMain = '' - st.windowWarned = false + st.spinner = '' + st.turnLogged.clear() refresh($, st) } +// ---- breaker inputs -------------------------------------------------- + +/** The exact id a failure is charged to: the agent's, else main's plan. */ +function failureTarget(st: State, agentId: string | undefined) { + if (agentId !== undefined) return st.agentModels.get(agentId) + return st.lastPlan?.model.replace(ONE_M, '') +} + +/** An engine StopFailure of an availability kind marks its model down. */ +async function onStopFailure( + $: Api, + st: State, + e: { error: string; agent_id?: string }, +): Promise { + if (st.off || !UNAVAILABLE.has(e.error)) return + const sent = failureTarget(st, e.agent_id) + if (sent === undefined) return + if (e.error === 'model_not_found' && aliasOf(st.cfg, sent) === undefined) { + $.ui.log(`model-router: model_not_found for ${sent}: not a table id; ` + + 'no mark') + return + } + await prune($, st) + markDown($, st, canonical(st.cfg, sent), e.error, await $.clock.now()) +} + +type SwitchIn = { + from_model: string + to_model: string + requested_model: string | null + source: string +} + +/** + * The engine switched the model by itself. The model it left is marked, one + * strike, unless it landed where the router already was; the mark targets + * the model actually sent, else the model reported as left. + */ +async function onAutoSwitch($: Api, st: State, e: SwitchIn): Promise { + const cfg = st.cfg + const from = canonical(cfg, e.from_model) + const to = canonical(cfg, e.to_model) + $.ui.log(`model-router: engine switched ${from} → ${to} ` + + `(requested ${e.requested_model ?? '-'})`) + const sent = st.lastPlan ? canonical(cfg, st.lastPlan.model) : undefined + if (to === sent) return + const target = sent !== undefined && aliasOf(cfg, sent) ? sent : from + await prune($, st) + markDown($, st, target, 'engine fallback', await $.clock.now()) +} + +/** Any model change: keeps `sessionModel`; a user choice clears its mark. */ +async function onModelSwitch($: Api, st: State, e: SwitchIn): Promise { + st.sessionModel = e.to_model + if (st.off || e.source === 'resume') return + if (e.source === 'auto') await onAutoSwitch($, st, e) + else clearBreaker(st, canonical(st.cfg, e.to_model)) +} + // ---- registration ---------------------------------------------------- +async function readSessionModel($: Api): Promise { + try { + return await $.session.model() + } catch { + return '' + } +} + function registerSession(on: On, st: State): void { on('session.start', async ($, e, next) => { await reloadConfig($, st) + st.sessionModel = await readSessionModel($) await registerCommand($) refresh($, st) return next(e) @@ -936,7 +1581,7 @@ function registerSession(on: On, st: State): void { return next(e) }) on('session.end', async ($, e, next) => { - Object.assign(st, newState(st.cfg, st.source)) + resetSession(st) // session.start never fires after /clear: re-apply the config's // `enabled` so a config-disabled router (offConfig) stays off. applyEnabled(st, st.cfg.enabled) @@ -947,6 +1592,23 @@ function registerSession(on: On, st: State): void { }) } +function registerBreaker(on: On, st: State): void { + on('classic.StopFailure', async ($, e, next) => { + await onStopFailure($, st, e) + return next(e) + }).catch(($, e, next) => { + warnOnce(st, $, 'StopFailure', next.error.kind) + return next(e) + }) + on('classic.PostModelSwitch', async ($, e, next) => { + await onModelSwitch($, st, e) + return next(e) + }).catch(($, e, next) => { + warnOnce(st, $, 'PostModelSwitch', next.error.kind) + return next(e) + }) +} + function registerCommandHook(on: On, st: State): void { on('command.run', { command: 'route' }, async ($, e) => { if (e.origin.kind !== 'composer') { @@ -961,7 +1623,7 @@ function registerCommandHook(on: On, st: State): void { function registerRouteTool(on: On, st: State): void { on('tool.call', { tool: TOOL }, async ($, e) => { - const out = handleRouteTool(st, e) + const out = await handleRouteTool($, st, e) refresh($, st) vlog($, st, `route ${e.agentId ?? 'main'}: ${JSON.stringify(out)}`) return out @@ -1002,10 +1664,15 @@ function registerSkills(on: On, st: State): void { 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') { + if (st.off) return next(e) + if (isLevel(e.effort) && typeof e.tool_use_id === 'string') { st.explicitEffort.set(e.tool_use_id, e.effort) } - return next(e) + if (e.agentId !== undefined) return next(e) + pushOrchestrate(st) + const out = await next(e) + safely(st, $, 'Agent', () => afterDispatch(st, out.result)) + return out }).catch(($, e, next) => { warnOnce(st, $, 'Agent', next.error.kind) return next(e) @@ -1015,16 +1682,18 @@ function registerAgents(on: On, st: State): void { function registerSpawn(on: On, st: State): void { on('agent.spawn', async ($, e, next) => { if (st.off) return next(e) + await prune($, st) 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 wanted = spawnTarget(st, e, route) const started = await next(wanted === undefined ? e - : { ...e, model: resolveModel(st.cfg, wanted) }) + : { ...e, model: wanted }) if (typeof started.agentId === 'string') { + const agentId = started.agentId safely(st, $, 'agent.spawn', () => { trackLoop(st, e, started, route) + rememberAgent(st, agentId, started.model) vlog($, st, `spawn ${e.subagentType}: ${e.model ?? '-'} → ${started.model}`) }) @@ -1052,7 +1721,9 @@ function registerTurns(on: On, st: State): void { return yield* next(e) }) on('turn.complete', async ($, e, next) => { - if (e.agentId !== undefined) st.loops.delete(e.agentId) + const agentId = e.agentId + if (agentId !== undefined) safely(st, $, 'turn.complete', () => + endAgent(st, agentId)) else endMainTurn($, st) return next(e) }).catch(($, e, next) => { @@ -1070,26 +1741,49 @@ function floorFromPrompt(st: State, midTurn: boolean, routed: Routed): void { if (midTurn) st.pendingPrompt = higherFloor(st.pendingPrompt, routed) } +const firstRule = (st: State, mode: PromptMode, text: string) => + st.rules.find(r => r.mode === mode && r.re.test(text)) + +/** A default rule sets the idle turn's route; routes and skills override. */ +function defaultFromPrompt(st: State, scanned: string): void { + const rule = firstRule(st, 'default', scanned) + const route = rule ? phaseRoute(st.cfg, rule.phase) : undefined + if (rule && route) { + st.turnMain = { phase: rule.phase, route: { ...route }, source: 'prompt' } + } +} + +/** + * Floor rules first (the user's explicit minimum). Default rules run only + * on an idle, plain prompt: not mid-turn, not a slash command or skill + * (they route themselves), not one that already carries a floor. + */ +function routeFromPrompt(st: State, text: string, midTurn: boolean): void { + const scanned = text.slice(0, MAX_PROMPT_SCAN) + const floor = firstRule(st, 'floor', scanned) + const route = floor ? phaseRoute(st.cfg, floor.phase) : undefined + if (floor && route) { + const routed: Routed = { phase: floor.phase, route, source: 'prompt' } + floorFromPrompt(st, midTurn, routed) + } + const slash = text.trimStart().startsWith('/') + if (text.trimStart().startsWith('/effort-')) st.typedSlash = true + if (!midTurn && !slash && !floor) defaultFromPrompt(st, scanned) +} + function registerPrompt(on: On, st: State): void { on('prompt.submit', async ($, e, next) => { if (st.off || e.origin.kind !== 'composer') return next(e) - const scanned = e.text.slice(0, MAX_PROMPT_SCAN) - const rule = st.rules.find(r => r.re.test(scanned)) - const route = rule ? phaseRoute(st.cfg, rule.phase) : undefined - if (rule && route) { - const routed: Routed = { phase: rule.phase, route, source: 'prompt' } - floorFromPrompt(st, e.turnId !== undefined, routed) - refresh($, st) - } - if (e.text.trimStart().startsWith('/effort-')) st.typedSlash = true + routeFromPrompt(st, e.text, e.turnId !== undefined) + refresh($, st) return next(e) }).catch(($, e, next) => { warnOnce(st, $, 'prompt.submit', next.error.kind) return 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}…` + if (st.off || !st.cfg.spinner || !st.spinner) return next(e) + const suffix = ` · ${st.spinner}…` return next({ ...e, props: { ...e.props, suffix } }) }).catch(($, e, next) => { warnOnce(st, $, 'ui.render', next.error.kind) @@ -1100,6 +1794,7 @@ function registerPrompt(on: On, st: State): void { export const register: Register = on => { const st = newState(mergeConfig(undefined, () => undefined), 'defaults') registerSession(on, st) + registerBreaker(on, st) registerCommandHook(on, st) registerRouteTool(on, st) registerSkills(on, st)