feat(model-router): wave 4-B — prompt classifier

Plain composer prompts with no route in force get a turn-scoped effort
from $.model.classify (design/analysis/coding/bookkeeping/chat → phase
effort, effort only, never the model). Bounded by a 3 s clock race, a
user interrupt neither counts nor writes, three failures in a row pause
it for cooldownMinutes (one toast, status marker, cleared by /route
reload). No dialog, no routing.json write on a classified route. New
Config key `classify` (default on, pickSwitch), `/route classify on|off`
session toggle, `/route show` line and argumentHint. An explicit user
/effort beats the guess (engineBase check).

Docs: README classifier paragraph, USAGE row + sentence, CHANGELOG,
effort-shift.md. Kit suite 428 tests.
This commit is contained in:
bchanot
2026-10-11 21:05:29 +02:00
parent 4506e0711f
commit e54a2b5d61
6 changed files with 826 additions and 19 deletions
+619
View File
@@ -4377,3 +4377,622 @@ test('logs: an ended agent forgets its answer marks (reused id)', async (
await runStep($, { ...highStep('a1'), model: SONNET })
expect(answers(logs)).toHaveLength(2)
})
// ---- wave 4-B: prompt classifier -----------------------------------------
type Ask = { text: string; labels: readonly string[]; options: unknown }
type Reply = (n: number) => Promise<string | undefined> | string | undefined
type Cls = {
asks: Ask[]
seen: Seen[]
clock: MockClock
logs: string[]
toasts: string[]
status: string[]
world: World
}
type ClsOpts = {
reply?: Reply
model?: string
world?: World
verbose?: boolean
}
const PLAIN = 'refactor the billing module please'
const PAUSE_MS = 15 * 60000 // cooldownMinutes default, in ms
/** A ui.status recorder, same shape as recordLogs. */
function recordStatus(on: On): string[] {
const lines: string[] = []
on('ui.status', async ($, e, next) => {
lines.push(e.text ?? '')
try {
await next(e)
} catch {
// nothing implements ui.status below the recorder in the kit
}
})
return lines
}
/** A bottom `model.classify` answering `reply`; records every ask. */
async function bootCls($: Engine, on: On, o: ClsOpts = {}): Promise<Cls> {
const logs = recordLogs(on)
const toasts = recordToasts(on)
const status = recordStatus(on)
const seen: Seen[] = []
const asks: Ask[] = []
recordSteps(on, seen)
on('prompt.submit', ($, e) => ({ text: e.text }))
on('turn.complete', () => ({ text: '' }))
on('session.end', ($, e) => ({ sessionId: e.sessionId }))
on('tool.call', { tool: 'Skill' }, () => ({
result: { success: true, commandName: 'bottom' },
}))
on('agent.spawn', ($, e) => ({
model: e.model ?? e.parentModel,
agentId: 'a1',
}))
on('model.classify', async (_$, e) => {
asks.push({ text: e.text, labels: e.labels, options: e.options })
return { value: await (o.reply ?? (() => 'coding'))(asks.length) }
})
const world = o.world ?? newWorld()
const clock = await boot($, on, o.model ?? FABLE, 1000, world)
if (o.verbose) await route($, 'verbose on')
logs.splice(0)
return { asks, seen, clock, logs, toasts, status, world }
}
const clearSession = ($: Engine) => $.session.end({
reason: 'clear',
sessionId: 's1',
resume: { id: 's1' },
})
/** The effort a main step reaches the bottom with (engine asks high). */
async function clsEffort($: Engine, c: Cls): Promise<unknown> {
await runStep($, highStep())
return c.seen[c.seen.length - 1]?.effort
}
const never = (): Promise<never> => new Promise(() => undefined)
const boom = (): never => {
throw new Error('overloaded')
}
/** `n` typed prompts that each fail, a turn end between them. */
async function failTimes($: Engine, n: number): Promise<void> {
for (let i = 0; i < n; i++) {
await typed($, PLAIN)
await endTurn($)
}
}
const LABELS_EFFORT: [string, string][] = [
['design', 'xhigh'],
['analysis', 'high'],
['coding', 'medium'],
['bookkeeping', 'low'],
]
test('classify: one call with the five labels and no model option', async (
$, on) => {
const c = await bootCls($, on)
await typed($, PLAIN)
expect(c.asks).toEqual([{
text: PLAIN,
labels: ['design', 'analysis', 'coding', 'bookkeeping', 'chat'],
options: undefined,
}])
})
for (const [label, effort] of LABELS_EFFORT) {
test(`classify: ${label} becomes the turn effort ${effort}`, async (
$, on) => {
const c = await bootCls($, on, { reply: () => label })
await runStep($, highStep())
await typed($, PLAIN)
expect(await clsEffort($, c)).toBe(effort)
})
}
test('classify: coding lowers the engine effort to medium', async ($, on) => {
const c = await bootCls($, on)
await typed($, PLAIN)
expect(await clsEffort($, c)).toBe('medium')
})
test('classify: a chat answer routes nothing', async ($, on) => {
const c = await bootCls($, on, { reply: () => 'chat' })
await typed($, PLAIN)
expect(await clsEffort($, c)).toBe('high')
})
test('classify: no label at all routes nothing', async ($, on) => {
const c = await bootCls($, on, { reply: () => undefined })
await typed($, PLAIN)
expect(await clsEffort($, c)).toBe('high')
})
test('classify: a label naming an object key routes nothing', async (
$, on) => {
const c = await bootCls($, on, { reply: () => 'constructor' })
await typed($, PLAIN)
expect(await clsEffort($, c)).toBe('high')
})
test('classify: an unknown label neither warns nor counts', async ($, on) => {
const c = await bootCls($, on, { reply: () => 'nonsense' })
await failTimes($, 4)
expect(c.asks).toHaveLength(4)
expect(c.toasts).toEqual([])
expect(c.logs).toEqual([])
})
test('classify: the main model never moves for a design guess', async (
$, on) => {
const c = await bootCls($, on, { model: SONNET, reply: () => 'design' })
await typed($, PLAIN)
await runStep($, { ...highStep(), model: SONNET })
expect(c.seen[c.seen.length - 1]).toEqual({ model: SONNET, effort: 'xhigh' })
})
test('classify: the route carries no tier', async ($, on) => {
const c = await bootCls($, on, { reply: () => 'design' })
await typed($, PLAIN)
const line = mainLine(await route($, 'show'))
expect(line).toContain('classifier plan')
expect(line).not.toContain('[tier')
expect(c.asks).toHaveLength(1)
})
test('classify: the route is gone at turn.complete', async ($, on) => {
const c = await bootCls($, on, { reply: () => 'design' })
await typed($, PLAIN)
await endTurn($)
expect(await clsEffort($, c)).toBe('high')
expect(mainLine(await route($, 'show'))).toContain('session defaults')
})
test('classify: a route tool call in the turn replaces it', async ($, on) => {
const c = await bootCls($, on, { reply: () => 'bookkeeping' })
await typed($, PLAIN)
await $.tool.call({ tool: ROUTE_TOOL, phase: 'plan' })
expect(await clsEffort($, c)).toBe('xhigh')
})
test('classify: a typed skill in the turn replaces it', async ($, on) => {
const c = await bootCls($, on, { reply: () => 'design' })
await typed($, PLAIN)
await typed($, '/feat add a thing')
await skillPrompt($, 'feat')
expect(await clsEffort($, c)).toBe('high')
})
test('classify: a user /effort change beats the guessed effort', async (
$, on) => {
const c = await bootCls($, on, { reply: () => 'design' })
await runStep($, highStep())
await typed($, PLAIN)
await runStep($, { ...highStep(), effort: 'low' as const })
expect(c.seen[c.seen.length - 1]?.effort).toBe('low')
})
test('classify: the baseline survives /clear', async ($, on) => {
const c = await bootCls($, on, { reply: () => 'design' })
await runStep($, highStep())
await clearSession($)
await typed($, PLAIN)
await runStep($, { ...highStep(), effort: 'low' as const })
expect(c.seen[c.seen.length - 1]?.effort).toBe('low')
})
test('classify: a baseline-effort step takes the guessed effort', async (
$, on) => {
const c = await bootCls($, on, { reply: () => 'design' })
await runStep($, highStep())
await typed($, PLAIN)
await runStep($, highStep())
expect(c.seen[c.seen.length - 1]?.effort).toBe('xhigh')
})
// ---- the guards: one clause each ---
/** The prompt is typed, then the asks are counted. */
async function asksFor($: Engine, c: Cls, text: string): Promise<number> {
await typed($, text)
return c.asks.length
}
test('classify guard: text under 12 characters is not sent', async (
$, on) => {
const c = await bootCls($, on)
expect(await asksFor($, c, 'hello world')).toBe(0)
})
test('classify guard: 12 characters are sent', async ($, on) => {
const c = await bootCls($, on)
expect(await asksFor($, c, 'hello worlds')).toBe(1)
})
test('classify guard: padded short text is not sent', async ($, on) => {
const c = await bootCls($, on)
expect(await asksFor($, c, ' short ')).toBe(0)
})
test('classify guard: a padded slash prompt is not sent', async ($, on) => {
const c = await bootCls($, on)
expect(await asksFor($, c, ' /feat add a long thing')).toBe(0)
})
test('classify guard: the sample is sent trimmed', async ($, on) => {
const c = await bootCls($, on)
await typed($, ` ${PLAIN} `)
expect(c.asks.map(a => a.text)).toEqual([PLAIN])
})
test('classify guard: a slash prompt is not sent', async ($, on) => {
const c = await bootCls($, on)
expect(await asksFor($, c, '/unknown with a long argument')).toBe(0)
})
test('classify guard: a non-composer origin is not sent', async ($, on) => {
const c = await bootCls($, on)
await $.prompt.submit({ text: PLAIN, wait: false, origin: { kind: 'sdk' } })
expect(c.asks).toHaveLength(0)
})
test('classify guard: a mid-turn prompt is not sent', async ($, on) => {
const c = await bootCls($, on)
await queued($, 'composer', PLAIN)
expect(c.asks).toHaveLength(0)
})
test('classify guard: a keyword rule match is not sent', async ($, on) => {
const c = await bootCls($, on)
expect(await asksFor($, c, 'please review the billing module')).toBe(0)
})
test('classify guard: ultrathink is not sent', async ($, on) => {
const c = await bootCls($, on)
expect(await asksFor($, c, 'ultrathink about the billing module')).toBe(0)
})
test('classify guard: a sticky /route is not overridden', async ($, on) => {
const c = await bootCls($, on)
await route($, 'mechanical')
expect(await asksFor($, c, PLAIN)).toBe(0)
})
test('classify guard: a held run slot is not sent', async ($, on) => {
const c = await bootCls($, on)
await typed($, '/feat add a thing')
await skillPrompt($, 'feat')
await endTurn($)
expect(await asksFor($, c, PLAIN)).toBe(0)
})
test('classify guard: a carried floor is not sent', async ($, on) => {
const c = await bootCls($, on)
await queued($, 'composer', 'ultrathink about the billing module')
await endTurn($)
expect(await asksFor($, c, PLAIN)).toBe(0)
})
test('classify guard: a sub-agent never reaches the classifier', async (
$, on) => {
const c = await bootCls($, on)
await $.agent.spawn(spawnInput())
await runStep($, highStep('a1'))
expect(c.asks).toHaveLength(0)
})
test('classify guard: a config classify=false is not sent', async ($, on) => {
const c = await bootCls($, on, {
world: newWorld({ home: { classify: false } }),
})
expect(await asksFor($, c, PLAIN)).toBe(0)
})
test('classify guard: /route classify off is not sent', async ($, on) => {
const c = await bootCls($, on)
await route($, 'classify off')
expect(await asksFor($, c, PLAIN)).toBe(0)
})
test('classify guard: the mod off is not sent', async ($, on) => {
const c = await bootCls($, on)
await route($, 'off')
expect(await asksFor($, c, PLAIN)).toBe(0)
})
test('classify guard: a haiku id marked down is not sent', async ($, on) => {
const c = await bootCls($, on)
await switchTo($, HAIKU, SONNET, 'auto')
expect(await asksFor($, c, PLAIN)).toBe(0)
})
test('classify guard: a main loop on haiku is not sent', async ($, on) => {
const c = await bootCls($, on, { model: HAIKU })
expect(await asksFor($, c, PLAIN)).toBe(0)
})
test('classify guard: a phase without effort routes nothing', async (
$, on) => {
const world = newWorld({ edit: f => {
(f.phases as Rec).plan = { tier: 'best' }
} })
const c = await bootCls($, on, { reply: () => 'design', world })
await typed($, PLAIN)
expect(await clsEffort($, c)).toBe('high')
})
test('classify guard: a non-best skill turn leaves the next open', async (
$, on) => {
const c = await bootCls($, on)
await typed($, '/status now')
await skillPrompt($, 'status')
await endTurn($)
expect(await asksFor($, c, PLAIN)).toBe(1)
})
test('classify guard: /route clear reopens it after a best-tier run', async (
$, on) => {
const c = await bootCls($, on)
await typed($, '/feat add a thing')
await skillPrompt($, 'feat')
await endTurn($)
await route($, 'clear')
expect(await asksFor($, c, PLAIN)).toBe(1)
})
test('classify guard: a route landing during the call wins', async (
$, on) => {
let release: () => void = () => undefined
const gate = new Promise<void>(resolve => { release = resolve })
const c = await bootCls($, on, {
reply: async () => { await gate; return 'design' },
})
const pending = typed($, PLAIN)
await c.clock.settle()
await $.tool.call({ tool: ROUTE_TOOL, phase: 'mechanical' })
release()
await pending
expect(mainLine(await route($, 'show'))).toContain('model mechanical')
expect(await clsEffort($, c)).toBe('low')
})
// ---- bounded and fail-safe ---
test('classify bound: a call never answering gives no route at 3 s', async (
$, on) => {
const c = await bootCls($, on, { reply: never })
const pending = typed($, PLAIN)
await c.clock.settle()
await c.clock.advance(3000)
await pending
expect(await clsEffort($, c)).toBe('high')
})
test('classify bound: the prompt proceeds after a timeout', async ($, on) => {
const c = await bootCls($, on, { reply: never })
const pending = typed($, PLAIN)
await c.clock.settle()
await c.clock.advance(3000)
expect((await pending).text).toBe(PLAIN)
})
test('classify bound: three timeouts pause the classifier', async ($, on) => {
const c = await bootCls($, on, { reply: never })
for (let i = 0; i < 3; i++) {
const pending = typed($, PLAIN)
await c.clock.settle()
await c.clock.advance(3000)
await pending
}
expect(c.toasts).toEqual(['classifier paused 15 min (3 failures)'])
})
test('classify bound: a rejection is not an error for the prompt', async (
$, on) => {
const c = await bootCls($, on, { reply: boom })
await typed($, PLAIN)
expect(c.logs.filter(l => l.includes('failed'))).toEqual([])
})
test('classify fail: three rejections pause with one toast', async ($, on) => {
const c = await bootCls($, on, { reply: boom })
await failTimes($, 3)
expect(c.toasts).toEqual(['classifier paused 15 min (3 failures)'])
})
test('classify fail: two rejections do not pause', async ($, on) => {
const c = await bootCls($, on, { reply: boom })
await failTimes($, 2)
expect(c.toasts).toEqual([])
})
test('classify fail: the status line shows the pause marker', async (
$, on) => {
const c = await bootCls($, on, { reply: boom })
await failTimes($, 3)
expect(c.status[c.status.length - 1]).toContain('classifier: paused')
})
test('classify fail: no call during the cooldown', async ($, on) => {
const c = await bootCls($, on, { reply: boom })
await failTimes($, 3)
expect(await asksFor($, c, PLAIN)).toBe(3)
})
test('classify fail: a settled answer resets the counter', async ($, on) => {
const c = await bootCls($, on, {
reply: n => (n === 3 ? 'chat' : boom()),
})
await failTimes($, 5)
expect(c.toasts).toEqual([])
expect(c.asks).toHaveLength(5)
})
test('classify pause: still paused one ms before the cooldown ends', async (
$, on) => {
const c = await bootCls($, on, { reply: boom })
await failTimes($, 3)
await c.clock.advance(PAUSE_MS - 1)
expect(await asksFor($, c, PLAIN)).toBe(3)
})
test('classify pause: resumes when the cooldown ends', async ($, on) => {
const c = await bootCls($, on, { reply: boom })
await failTimes($, 3)
await c.clock.advance(PAUSE_MS)
expect(await asksFor($, c, PLAIN)).toBe(4)
})
test('classify pause: the marker clears when it ends', async ($, on) => {
const c = await bootCls($, on, { reply: boom })
await failTimes($, 3)
await c.clock.advance(PAUSE_MS)
await typed($, '/status')
expect(c.status[c.status.length - 1]).not.toContain('paused')
})
test('classify pause: /clear keeps it', async ($, on) => {
const c = await bootCls($, on, { reply: boom })
await failTimes($, 3)
await clearSession($)
expect(await asksFor($, c, PLAIN)).toBe(3)
})
test('classify pause: /route reload clears it', async ($, on) => {
const c = await bootCls($, on, { reply: boom })
await failTimes($, 3)
await route($, 'reload')
expect(await asksFor($, c, PLAIN)).toBe(4)
})
// ---- sample ---
test('classify sample: 2000 code points are sent whole', async ($, on) => {
const c = await bootCls($, on)
const text = 'é'.repeat(2000)
await typed($, text)
expect(c.asks[0]?.text).toBe(text)
})
test('classify sample: a longer text is cut to head and tail', async (
$, on) => {
const c = await bootCls($, on)
await typed($, 'H'.repeat(600) + 'M'.repeat(1000) + 'T'.repeat(1500))
expect(c.asks[0]?.text).toBe('H'.repeat(500) + 'T'.repeat(1500))
})
test('classify sample: a cut never leaves a lone surrogate', async (
$, on) => {
const c = await bootCls($, on)
await typed($, '😀'.repeat(2400))
const sent = c.asks[0]?.text ?? ''
expect(sent.isWellFormed()).toBe(true)
expect(Array.from(sent)).toHaveLength(2000)
})
// ---- no dialog, config, visibility ---
test('classify: no dialog and no file write on a classified prompt', async (
$, on) => {
const c = await bootCls($, on, { world: newWorld({ ask: true }) })
await typed($, PLAIN)
expect(c.asks).toHaveLength(1)
expect(c.world.asked).toEqual([])
expect(c.world.written).toEqual([])
})
test('classify config: an invalid value keeps the default on', async (
$, on) => {
const c = await bootCls($, on, {
world: newWorld({ home: { classify: 'yes' } }),
})
expect(await asksFor($, c, PLAIN)).toBe(1)
})
test('classify config: an invalid value is logged once', async ($, on) => {
const c = await bootCls($, on, {
world: newWorld({ home: { classify: 'yes' } }),
})
await route($, 'reload')
const said = c.logs.filter(l => l.includes('"classify" is not a boolean'))
expect(said).toHaveLength(1)
})
test('classify config: /route show prints classify: on', async ($, on) => {
await bootCls($, on)
expect(await route($, 'show')).toContain('classify: on')
})
test('classify config: /route classify off prints classify: off', async (
$, on) => {
await bootCls($, on)
expect(await route($, 'classify off')).toContain('classify: off')
})
test('classify config: a bad toggle argument prints the usage', async (
$, on) => {
await bootCls($, on)
expect(await route($, 'classify maybe')).toBe('usage: /route classify on|off')
})
test('classify config: the toggle survives a rebuild', async ($, on) => {
const c = await bootCls($, on)
await route($, 'classify off')
expect(await route($, 'reload')).toContain('classify: off')
expect(await asksFor($, c, PLAIN)).toBe(0)
})
test('classify config: /route is refused from a plugin origin', async (
$, on) => {
await bootCls($, on)
const out = await $.command.run({
command: 'route',
args: 'classify off',
origin: { kind: 'plugin', name: 'x' },
presentation: { isFullscreen: false, columns: 80 },
})
expect(out.text).toContain('user-only')
})
test('classify visibility: the status line reads classifier <phase>', async (
$, on) => {
const c = await bootCls($, on, { reply: () => 'analysis' })
await typed($, PLAIN)
expect(c.status[c.status.length - 1]).toContain('route: classifier reflect')
})
test('classify visibility: verbose says (reflect: classifier)', async (
$, on) => {
const c = await bootCls($, on, { reply: () => 'analysis', verbose: true })
await runStep($, { ...highStep(), effort: 'low' as const })
await typed($, PLAIN)
await runStep($, { ...highStep(), effort: 'low' as const })
expect(c.logs).toContain(
mainWord('fable/low', 'fable/high', 'reflect: classifier'))
})
test('classify: a repeated undefined label never pauses', async ($, on) => {
const c = await bootCls($, on, { reply: () => undefined })
await failTimes($, 3)
expect(c.asks).toHaveLength(3)
expect(c.toasts).toEqual([])
expect(c.status.some(l => l.includes('paused'))).toBe(false)
})
test('classify: /route advertises classify on|off in its hint', async (
$, on) => {
const hints: unknown[] = []
on('command.register', (_$, e) => {
hints.push(e.argumentHint)
return { value: { command: e.name } }
})
await bootCls($, on)
expect(hints.join('\n')).toContain('classify on|off')
})
+197 -14
View File
@@ -32,11 +32,13 @@ type Config = {
upgradeMaxTokens: number // above this context an upgrade is skipped
mainModelSwitch: boolean // gates DOWNGRADES only
verbose: boolean
classify: boolean // a small-model guess of the typed prompt's phase
spinner: boolean
enabled: boolean // false: every hook passes through (per machine)
}
type Rule = { re: RegExp; phase: string; mode: PromptMode }
type Source = 'user' | 'model' | 'skill' | 'prompt' | 'run' | 'derived'
type Source =
| 'user' | 'model' | 'skill' | 'prompt' | 'run' | 'derived' | 'classifier'
type Routed = {
phase: string
route: Route
@@ -91,13 +93,21 @@ type State = {
override: Rec | undefined // the machine override's data, if it loaded
loaded: boolean // the layers were read once (else: lazily, on first use)
rebuildDue: boolean // /clear: re-read the layers at the next prompt
toggles: { switch?: boolean; verbose?: boolean } // session toggles
toggles: Partial<Record<Toggle, boolean>> // session toggles
engineBase: NonNullable<Effort> | null // engine effort of main's first step
classifyFails: number // classifier failures in a row
classifyUntil: number // classifier paused until (ms); 0 = running
asking: string | null // key of the one first-use dialog in flight
asked: Set<string> // keys answered (or failed) this session
writes: Promise<unknown> // the serialized routing.json write chain
}
type Log = (text: string) => void
type StepIn = Readonly<TurnStepInput>
type PromptIn = {
text: string
turnId?: string
origin: { kind: string }
}
type Plan = { model: string; effort: StepIn['effort'] }
type RouteInput = {
phase?: unknown
@@ -107,6 +117,7 @@ type RouteInput = {
}
type Picked = { phase: string; route: Route }
type Effort = StepIn['effort']
type Toggle = 'switch' | 'verbose' | 'classify'
type EffortBy = 'floor' | 'sticky' | 'turn' | 'engine'
type Decision = { effort: Effort; by: EffortBy }
type Rec = Record<string, unknown>
@@ -176,6 +187,23 @@ const UNAVAILABLE: ReadonlySet<string> = new Set([
'rate_limit', 'overloaded', 'billing_error', 'model_not_found',
])
const PHASE_KEY = /^[a-z][a-z0-9_-]{0,31}$/
// Prompt classifier: label -> the phase whose effort the turn takes. `chat`
// (and any label not here) routes nothing.
const CLASSIFY_LABELS: readonly string[] =
['design', 'analysis', 'coding', 'bookkeeping', 'chat']
const LABEL_PHASE: Readonly<Record<string, string>> = {
design: 'plan',
analysis: 'reflect',
coding: 'implement',
bookkeeping: 'apply',
}
const CLASSIFY_MIN = 12 // code points of a typed prompt worth a call
const CLASSIFY_MS = 3000 // the call is abandoned after this
const CLASSIFY_MAX = 2000 // code points sent whole; longer: head + tail
const CLASSIFY_HEAD = 500
const CLASSIFY_TAIL = 1500
const CLASSIFY_FAILS = 3 // failures in a row that pause the classifier
const TIMEOUT: unique symbol = Symbol('classify timeout')
const DEFAULT_CONFIG: Config = {
models: {
@@ -232,6 +260,7 @@ const DEFAULT_CONFIG: Config = {
],
mainModelSwitch: false,
verbose: false,
classify: true,
spinner: true,
enabled: true,
}
@@ -512,6 +541,9 @@ function mergeConfig(
mainModelSwitch: pickBool(user.mainModelSwitch, base.mainModelSwitch),
verbose: pickSwitch(
'config "verbose" is not a boolean; ignored', user.verbose, base.verbose, log),
classify: pickSwitch(
'model-router: config "classify" is not a boolean; ignored',
user.classify, base.classify, log),
spinner: pickBool(user.spinner, base.spinner),
enabled: pickSwitch(
'model-router: config "enabled" is not a boolean; ignored',
@@ -845,6 +877,9 @@ function newState(cfg: Config, source: string): State {
loaded: false,
rebuildDue: false,
toggles: {},
engineBase: null,
classifyFails: 0,
classifyUntil: 0,
asking: null,
asked: new Set(),
writes: Promise.resolve(),
@@ -853,7 +888,8 @@ function newState(cfg: Config, source: string): State {
/**
* /clear rebuilds the state but keeps what is account- or process-wide:
* the breaker (a model's quota outlives the conversation), the model, the
* the breaker, the classifier's pause and effort baseline (a model's quota
* and the user's own /effort outlive the conversation), the model, the
* agent definitions' sources (the listing is not offered again), the config
* with its session toggles and the dialog in flight. The layers are read
* again at the next prompt; the config is swapped only if that read works.
@@ -870,6 +906,9 @@ function resetSession(st: State): void {
override: st.override,
loaded: st.loaded,
toggles: st.toggles,
engineBase: st.engineBase,
classifyFails: st.classifyFails,
classifyUntil: st.classifyUntil,
asking: st.asking,
writes: st.writes,
}
@@ -1204,11 +1243,12 @@ function higherFloor(cur: Routed | null, next: Routed): Routed {
* The one decision of the main loop's effort. The user's floor is the turn's
* default (no sticky or turn route names an effort) and its minimum.
* `by` names who set the value: the floor when it raised or supplied it.
* `skipTurn`: the turn route yields (a classified effort under a user /effort).
*/
function mainEffort(st: State, engine: Effort): Decision {
function mainEffort(st: State, engine: Effort, skipTurn = false): Decision {
const sticky = st.userMain?.route.effort
const named = sticky ?? st.turnMain?.route.effort ??
st.runMain?.route.effort
const turn = skipTurn ? undefined : st.turnMain?.route.effort
const named = sticky ?? turn ?? st.runMain?.route.effort
const floor = st.turnFloor?.route.effort
const base = named ?? floor ?? engine
const effort = floored(base, floor)
@@ -1369,7 +1409,8 @@ async function show($: Api, st: State): Promise<string> {
mainText(st, s),
`router: ${routerWord(st)} · switch: ${flag(c.mainModelSwitch)} ` +
`(downgrade) · upgrade: ${flag(c.mainUpgrade)} · verbose: ${
flag(c.verbose)} · spinner: ${flag(c.spinner)}`,
flag(c.verbose)} · classify: ${flag(c.classify)} · spinner: ${
flag(c.spinner)}`,
downText(st, s.now),
`live loops: ${st.loops.size}`,
phasesText(st),
@@ -1383,8 +1424,10 @@ function statusLine(st: State): string {
? routerWord(st)
: r ? `${r.source} ${r.phase}` : 'session defaults'
const floor = liveFloor(st)
const paused = st.cfg.classify && st.classifyUntil !== 0
return `route: ${now}${floor ? ` · floor ${floor.level}` : ''}${
st.cfg.mainModelSwitch ? ' · switch on' : ''}`
st.cfg.mainModelSwitch ? ' · switch on' : ''}${
paused ? ' · classifier: paused' : ''}`
}
const refresh = ($: Api, st: State): void => $.ui.status(statusLine(st))
@@ -1431,12 +1474,11 @@ function parseRoute(cfg: Config, args: string): Routed | string {
async function toggle(
$: Api,
st: State,
what: string,
what: Toggle,
arg: string | undefined,
): Promise<string> {
if (arg !== 'on' && arg !== 'off') return `usage: /route ${what} on|off`
if (what === 'switch') st.toggles.switch = arg === 'on'
else st.toggles.verbose = arg === 'on'
st.toggles[what] = arg === 'on'
applyToggles(st)
return show($, st)
}
@@ -1451,9 +1493,10 @@ async function setUserRoute($: Api, st: State, args: string) {
/** Session toggles outlive any rebuild of the config. */
function applyToggles(st: State): void {
const { switch: downgrade, verbose } = st.toggles
const { switch: downgrade, verbose, classify } = st.toggles
if (downgrade !== undefined) st.cfg.mainModelSwitch = downgrade
if (verbose !== undefined) st.cfg.verbose = verbose
if (classify !== undefined) st.cfg.classify = classify
}
function applyLoaded(st: State, loaded: Loaded, log: Log): void {
@@ -1497,6 +1540,8 @@ async function ensureConfig($: Api, st: State, atPrompt = false) {
/** The breaker is cleared first, whatever the config read then does. */
async function reloadCommand($: Api, st: State): Promise<string> {
clearBreaker(st)
st.classifyFails = 0
st.classifyUntil = 0
await reloadConfig($, st)
refresh($, st)
return 'config reloaded\n' + (await show($, st))
@@ -1533,6 +1578,7 @@ async function handleCommand($: Api, st: State, args: string): Promise<string> {
return forgetCommand($, st, rest)
case 'switch':
case 'verbose':
case 'classify':
return toggle($, st, head, rest[0])
default:
return setUserRoute($, st, args)
@@ -2414,7 +2460,8 @@ async function registerCommand($: Api): Promise<void> {
argumentHint:
'[show|clear|off|on|reload|pending|ask on|off|' +
'forget <name|all|projects>|<phase>|' +
'model=<alias|id> effort=<level>|switch on|off|verbose on|off]',
'model=<alias|id> effort=<level>|switch on|off|verbose on|off|' +
'classify on|off]',
immediate: true,
})
} catch (err) {
@@ -2770,6 +2817,7 @@ function slotWord(slot: Routed | null): string {
case 'model': return 'route tool'
case 'prompt': return 'prompt rule'
case 'derived': return 'dispatch'
case 'classifier': return 'classifier'
case 'skill':
case 'run': return 'skill' + label
default: return 'session'
@@ -2921,13 +2969,27 @@ function agentPlan(st: State, e: StepIn): Plan {
type Why = { by: EffortBy; call: Call; cur: string }
type Step = { plan: Plan; why?: Why }
/** The engine effort of main's first step: what the user left as default. */
function noteEngineBase(st: State, e: StepIn): void {
if (st.engineBase === null && e.effort !== undefined) {
st.engineBase = e.effort
}
}
/** A user /effort beats a guessed phase: the engine effort left its base. */
function userMovedEffort(st: State, e: StepIn): boolean {
return st.turnMain?.source === 'classifier' && e.effort !== undefined &&
st.engineBase !== null && e.effort !== st.engineBase
}
/**
* 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<Step> {
const { effort, by } = mainEffort(st, e.effort)
noteEngineBase(st, e)
const { effort, by } = mainEffort(st, e.effort, userMovedEffort(st, e))
await prune($, st)
const cur = st.turnModel ?? e.model
const { call } = await decideFor($, st, cur)
@@ -3309,6 +3371,126 @@ function noteTyped(st: State, kind: string, text: string, midTurn: boolean) {
}
}
// ---- prompt classifier -----------------------------------------------
// A typed prompt no rule, skill, route or floor covers is guessed by one
// small-model call: the phase's EFFORT becomes the turn's default (never a
// model move, never a dialog, never a file write). The call is bounded and
// every failure leaves the prompt as it was.
/** The whole text up to CLASSIFY_MAX code points, else its head and tail. */
function sampleOf(text: string): string {
const points = Array.from(text.trim())
if (points.length <= CLASSIFY_MAX) return points.join('')
return points.slice(0, CLASSIFY_HEAD).join('') +
points.slice(-CLASSIFY_TAIL).join('')
}
/** No route in force on main and the classifier is on and not paused. */
const classifierIdle = (st: State): boolean =>
!st.off && st.cfg.classify && st.classifyUntil === 0 &&
mainRoute(st) === null && st.turnFloor === null
/** A lapsed pause ends here, before any other guard (no stale marker). */
async function expirePause($: Api, st: State): Promise<void> {
if (st.classifyUntil === 0 || (await $.clock.now()) < st.classifyUntil) {
return
}
st.classifyUntil = 0
refresh($, st)
}
/** The small model is the haiku id: marked down, or main already runs it. */
async function smallModelBusy($: Api, st: State): Promise<boolean> {
await prune($, st)
const small = st.cfg.models.haiku
const main = st.turnModel ?? st.sessionModel
return (small !== undefined && st.down.has(small)) || isHaiku(st.cfg, main)
}
/** The guards of D8, in order; the text itself is checked last. */
async function classifierWanted($: Api, st: State, e: PromptIn) {
if (!st.cfg.classify || e.origin.kind !== 'composer') return false
if (e.turnId !== undefined) return false
await expirePause($, st)
const text = e.text.trim()
if (text.startsWith('/') || Array.from(text).length < CLASSIFY_MIN) {
return false
}
return classifierIdle(st) && !(await smallModelBusy($, st))
}
/** One failure; the third in a row pauses the classifier, said once. */
async function countFailure($: Api, st: State): Promise<void> {
st.classifyFails += 1
if (st.classifyFails < CLASSIFY_FAILS) return
st.classifyFails = 0
st.classifyUntil = (await $.clock.now()) + st.cfg.cooldownMinutes * 60000
$.ui.toast(`classifier paused ${st.cfg.cooldownMinutes} min (${
CLASSIFY_FAILS} failures)`)
refresh($, st)
}
/** classify's own abort cause: a user interrupt, not a failure. */
const isAbort = (err: unknown): boolean =>
err instanceof Error && (err.name === 'AbortError' ||
/\baborted\b/i.test(err.message))
/** The label, or TIMEOUT; rejects like classify or when the dispatch aborts. */
function raceClassify($: Api, text: string, signal: AbortSignal) {
return Promise.race([
$.model.classify(sampleOf(text), CLASSIFY_LABELS),
$.clock.sleep(CLASSIFY_MS, { signal })
.then((): typeof TIMEOUT => TIMEOUT),
])
}
/** The label, or undefined: no answer, an interrupt or a counted failure. */
async function askClassifier(
$: Api,
st: State,
text: string,
signal: AbortSignal,
): Promise<string | undefined> {
let label: string | undefined | typeof TIMEOUT
try {
label = await raceClassify($, text, signal)
} catch (err) {
if (!signal.aborted && !isAbort(err)) await countFailure($, st)
return undefined
}
if (label === TIMEOUT) {
await countFailure($, st)
return undefined
}
st.classifyFails = 0
return label
}
/** The turn route of a label: its phase's effort only; false = nothing. */
function routeFromLabel(st: State, label: string): boolean {
const phase = hasKey(LABEL_PHASE, label) ? LABEL_PHASE[label] : undefined
const effort = phase === undefined
? undefined
: phaseRoute(st.cfg, phase)?.effort
if (phase === undefined || effort === undefined) return false
st.turnMain = { phase, route: { effort }, source: 'classifier' }
return true
}
/** Fills an empty route with the guessed phase's effort; never throws on it. */
async function classifyPrompt(
$: Api,
st: State,
e: PromptIn,
signal: AbortSignal,
): Promise<void> {
if (!(await classifierWanted($, st, e))) return
const label = await askClassifier($, st, e.text, signal)
// a route that landed meanwhile wins; a late answer never crosses turns
if (label === undefined || signal.aborted || !classifierIdle(st)) return
if (routeFromLabel(st, label)) refresh($, st)
}
function registerPrompt(on: On, st: State): void {
on('prompt.submit', async ($, e, next) => {
await ensureConfig($, st, true)
@@ -3317,6 +3499,7 @@ function registerPrompt(on: On, st: State): void {
noteTyped(st, e.origin.kind, e.text, midTurn)
if (e.origin.kind === 'composer') routeFromPrompt(st, e.text, midTurn)
refresh($, st)
await classifyPrompt($, st, e, next.signal)
return next(e)
}).catch(($, e, next) => {
warnOnce(st, $, 'prompt.submit', next.error.kind)