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')
})