From 1ff608a68cf8509d64cbb8e50b4defb986c3ca18 Mon Sep 17 00:00:00 2001 From: bchanot Date: Fri, 9 Oct 2026 10:12:28 +0200 Subject: [PATCH] =?UTF-8?q?feat(mods):=20model-router=20user=20effort=20fl?= =?UTF-8?q?oor=20=E2=80=94=20ultrathink=20and=20typed=20/effort-=20set?= =?UTF-8?q?=20the=20main=20turn's=20default=20and=20minimum?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One decision helper (mainEffort) feeds the plan and every answer text; per-axis precedence (sticky > turn route > floor > engine); a mid-turn prompt floors the running turn and the next; per-machine kill switch "enabled": false in ~/.claude/model-router.json, kept across /clear and across a failed reload; typed /effort- attested at prompt.submit so a sub-agent preload cannot floor the main loop. 30 plugin tests. --- mods/model-router/hooks/register.test.ts | 216 +++++++++++++++++- mods/model-router/hooks/register.ts | 276 +++++++++++++++++++---- 2 files changed, 439 insertions(+), 53 deletions(-) diff --git a/mods/model-router/hooks/register.test.ts b/mods/model-router/hooks/register.test.ts index 030970b..b1074d5 100644 --- a/mods/model-router/hooks/register.test.ts +++ b/mods/model-router/hooks/register.test.ts @@ -1,6 +1,6 @@ import { test, expect } from 'claude-code/testing' import type { Engine } from 'claude-code/testing' -import type { On } from 'claude-code' +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 { @@ -84,7 +84,7 @@ test('/route bogus names the phases', async ($, on) => { } }) -test('ultrathink in a prompt sets escalate on main', async ($, on) => { +test('ultrathink in a prompt sets a max floor on main', async ($, on) => { on('prompt.submit', ($, e) => ({ text: e.text })) await boot($, on) await $.prompt.submit({ @@ -92,7 +92,7 @@ test('ultrathink in a prompt sets escalate on main', async ($, on) => { wait: false, origin: { kind: 'composer' }, }) - expect(mainLine(await route($, 'show'))).toContain('prompt escalate') + expect(mainLine(await route($, 'show'))).toContain('floor max') }) test('/route model: alias resolved, id passed, typo refused', async ( @@ -169,7 +169,7 @@ const stepInput = (agentId?: string) => ({ /** Streams one turn.step to its end; the hooks run as the chunks flow. */ async function runStep( $: Engine, - input: ReturnType, + input: TurnStepInput, ): Promise { const stream = $.turn.step(input) for await (const _chunk of stream) { @@ -238,3 +238,211 @@ test('a rule only scans the first 4096 chars of a prompt', async ($, on) => { }) expect(mainLine(await route($, 'show'))).toContain('session defaults') }) + +// ---- user effort floor ------------------------------------------------- + +const ROUTE_TOOL = 'mcp__model-router__route' + +/** A main (or agent) step as the engine would make it: engine effort high. */ +const highStep = (agentId?: string): TurnStepInput => ({ + ...stepInput(agentId), + effort: 'high' as const, +}) + +/** Types `ultrathink` in the composer, idle or over a running turn. */ +async function ultrathink($: Engine, turnId?: string): Promise { + await $.prompt.submit({ + text: 'ultrathink please', + wait: turnId !== undefined, + origin: { kind: 'composer' }, + ...(turnId === undefined ? {} : { turnId }), + }) +} + +/** Ends a main turn: the floor's life is bounded by this event. */ +async function endTurn($: Engine): Promise { + await $.turn.complete({ + turnId: 'u1', + answer: '', + durationMs: 1, + isAborted: false, + reason: 'answer', + }) +} + +/** One main step; returns the effort that reached the bottom hook. */ +async function stepEffort($: Engine, seen: Seen[]): Promise { + await runStep($, highStep()) + return seen[seen.length - 1]?.effort +} + +/** Boots with a bottom step recorder and prompt/turn hooks in place. */ +async function bootFloor($: Engine, on: On): Promise { + const seen: Seen[] = [] + recordSteps(on, seen) + on('prompt.submit', ($, e) => ({ text: e.text })) + on('turn.complete', () => ({ text: '' })) + on('tool.call', { tool: 'Skill' }, () => ({ + result: { success: true, commandName: 'other' }, + })) + on('agent.spawn', ($, e) => ({ + model: e.model ?? e.parentModel, + agentId: 'a1', + })) + await boot($, on) + return seen +} + +test('floor: ultrathink survives a model route', async ($, on) => { + const seen = await bootFloor($, on) + await ultrathink($) + await $.tool.call({ tool: ROUTE_TOOL, phase: 'orchestrate' }) + expect(await stepEffort($, seen)).toBe('max') +}) + +test('floor: typed /effort-medium clamps low, lets max pass', async ( + $, on) => { + const seen = await bootFloor($, on) + await $.skill.prompt({ skill: 'effort-medium', text: 'x' }) + await $.tool.call({ tool: ROUTE_TOOL, phase: 'mechanical' }) + expect(await stepEffort($, seen)).toBe('medium') + await $.tool.call({ tool: ROUTE_TOOL, phase: 'escalate' }) + expect(await stepEffort($, seen)).toBe('max') +}) + +test('floor: typed /effort-low lowers an unrouted turn', async ($, on) => { + const seen = await bootFloor($, on) + const out = await $.skill.prompt({ skill: 'effort-low', text: 'x' }) + expect(out.text).toContain('minimum') + expect(await stepEffort($, seen)).toBe('low') +}) + +test('floor: survives a skill load', async ($, on) => { + const seen = await bootFloor($, on) + await ultrathink($) + await $.tool.call({ tool: ROUTE_TOOL, phase: 'orchestrate' }) + await $.tool.call({ tool: 'Skill', skill: 'other' }) + expect(await stepEffort($, seen)).toBe('max') + expect(mainLine(await route($, 'show'))).not.toContain('orchestrate') +}) + +test('floor: lifts a lower sticky, then ends with the turn', async ( + $, on) => { + const seen = await bootFloor($, on) + await route($, 'effort=low') + await ultrathink($) + expect(await stepEffort($, seen)).toBe('max') + await endTurn($) + expect(await stepEffort($, seen)).toBe('low') +}) + +test('floor: main only, an agent step is unaffected', async ($, on) => { + const seen = await bootFloor($, on) + await $.agent.spawn(spawnInput()) + await ultrathink($) + await runStep($, highStep('a1')) + expect(seen[0]?.effort).toBe('medium') +}) + +test('floor: /route clear removes it', async ($, on) => { + const seen = await bootFloor($, on) + await ultrathink($) + expect(mainLine(await route($, 'show'))).toContain('floor max') + await route($, 'clear') + expect(await stepEffort($, seen)).toBe('high') + expect(mainLine(await route($, 'show'))).not.toContain('floor') +}) + +test('floor: a mid-turn prompt applies now and is kept for the next', async ( + $, on) => { + const seen = await bootFloor($, on) + await ultrathink($, 'u1') + expect(await stepEffort($, seen)).toBe('max') + await endTurn($) + expect(await stepEffort($, seen)).toBe('max') + await endTurn($) + expect(await stepEffort($, seen)).toBe('high') +}) + +test('floor: the route answer names the floor over a sticky', async ( + $, on) => { + await bootFloor($, on) + await route($, 'effort=low') + await ultrathink($) + const out = await $.tool.call({ tool: ROUTE_TOOL, phase: 'plan' }) + const text = JSON.stringify(out) + expect(text).toContain('user floor max (prompt rule escalate)') + expect(text).toContain('/route clear') +}) + +// `enabled: false` in ~/.claude/model-router.json cannot be reached here (the +// kit has no fs); the shared off path is covered through `/route off`. +test('floor: /route off keeps the floor from routing', async ($, on) => { + const seen = await bootFloor($, on) + await ultrathink($) + await route($, 'off') + expect(await stepEffort($, seen)).toBe('high') + expect(mainLine(await route($, 'show'))).not.toContain('floor') +}) + +test('per axis: a model-only sticky keeps the turn route effort', async ( + $, on) => { + const seen = await bootFloor($, on) + await route($, 'model=sonnet') + await $.tool.call({ tool: ROUTE_TOOL, phase: 'orchestrate' }) + expect(await stepEffort($, seen)).toBe('medium') +}) + +// The config-driven `offConfig` is re-applied in the same rebuild; only the +// session-only off is reachable here (the kit has no fs). +test('session.end rebuild drops a session-only /route off', async ( + $, on) => { + on('session.end', ($, e) => ({ sessionId: e.sessionId })) + await boot($, on) + await route($, 'off') + await $.session.end({ + reason: 'clear', + sessionId: 's1', + resume: { id: 's1' }, + }) + expect(await route($, 'show')).toContain('router: on') +}) + +// The kit has no fs: the reload read fails, so the previous cfg must stay. +test('reload with an unreadable override keeps the previous config', async ( + $, on) => { + await boot($, on) + await route($, 'switch on') + const out = await route($, 'reload') + expect(out).toContain('switch: on') + expect(await route($, 'show')).toContain('switch: on') +}) + +test('typed marker: a preload in a live agent is ignored', async ($, on) => { + const seen = await bootFloor($, on) + await $.agent.spawn(spawnInput()) + const out = await $.skill.prompt({ skill: 'effort-max', text: 'x' }) + expect(out.text?.startsWith('model-router: effort-max preload')).toBe(true) + expect(mainLine(await route($, 'show'))).not.toContain('floor') + expect(await stepEffort($, seen)).not.toBe('max') +}) + +test('typed marker: /effort-max seen at submit writes the floor', async ( + $, on) => { + await bootFloor($, on) + await $.agent.spawn(spawnInput()) + await $.prompt.submit({ + text: '/effort-max go', + wait: false, + origin: { kind: 'composer' }, + }) + await $.skill.prompt({ skill: 'effort-max', text: 'x' }) + expect(mainLine(await route($, 'show'))).toContain('floor max') +}) + +test('typed slash with no agent and no marker writes the floor', async ( + $, on) => { + await bootFloor($, on) + await $.skill.prompt({ skill: 'effort-max', text: 'x' }) + expect(mainLine(await route($, 'show'))).toContain('floor max') +}) diff --git a/mods/model-router/hooks/register.ts b/mods/model-router/hooks/register.ts index fa00a06..4d757b8 100644 --- a/mods/model-router/hooks/register.ts +++ b/mods/model-router/hooks/register.ts @@ -19,6 +19,7 @@ type Config = { mainModelSwitch: boolean 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' @@ -32,12 +33,17 @@ type State = { rules: Rule[] source: string // 'defaults' or the override path userMain: Routed | null // /route by the user, sticky until /route clear - turnMain: Routed | null // tool, skill, slash, prompt; dropped at turn end - pendingPrompt: Routed | null // typed mid-turn, promoted next turn + turnMain: Routed | null // model route tool, skill table row, Skill(effort-*) + // bridge; 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 + typedSlash: boolean // one-shot: prompt.submit saw a typed /effort- loops: Map // agentId -> that loop's routing explicitEffort: Map // Agent tool_use_id -> effort param skillCalls: number // Skill tool calls in flight - off: boolean // /route off: every hook passes through + 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 warned: Set // hooks whose fail-open was already logged @@ -52,6 +58,9 @@ type RouteInput = { agentId?: string } type Picked = { phase: string; route: Route } +type Effort = StepIn['effort'] +type EffortBy = 'floor' | 'sticky' | 'turn' | 'engine' +type Decision = { effort: Effort; by: EffortBy } const LEVELS: readonly Level[] = ['low', 'medium', 'high', 'xhigh', 'max'] const MODEL_ID = /^claude-[a-z0-9.-]+$/ @@ -91,6 +100,7 @@ const DEFAULT_CONFIG: Config = { mainModelSwitch: false, verbose: false, spinner: true, + enabled: true, } // ---- config ---------------------------------------------------------- @@ -213,6 +223,14 @@ function mergePrompt( const pickBool = (v: unknown, fallback: boolean): boolean => typeof v === 'boolean' ? v : fallback +/** The kill switch: a non-boolean value is dropped, and said so. */ +function pickEnabled(v: unknown, fallback: boolean, log: Log): boolean { + if (v !== undefined && typeof v !== 'boolean') { + log('model-router: config "enabled" is not a boolean; ignored') + } + return pickBool(v, fallback) +} + /** Defaults overlaid with the user's entries, each validated first. */ function mergeConfig(user: unknown, log: Log): Config { const base = structuredClone(DEFAULT_CONFIG) @@ -236,6 +254,7 @@ function mergeConfig(user: unknown, log: Log): Config { mainModelSwitch: pickBool(user.mainModelSwitch, base.mainModelSwitch), verbose: pickBool(user.verbose, base.verbose), spinner: pickBool(user.spinner, base.spinner), + enabled: pickEnabled(user.enabled, base.enabled, log), } } @@ -246,7 +265,7 @@ async function readCapped( log: Log, ): Promise { const tooBig = - `model-router: ${OVERRIDE} over ${MAX_CONFIG_BYTES} bytes; defaults` + `model-router: ${OVERRIDE} over ${MAX_CONFIG_BYTES} bytes` if ((await $.fs.stat(path)).size > MAX_CONFIG_BYTES) { log(tooBig) return undefined @@ -257,30 +276,37 @@ async function readCapped( return undefined } -async function readOverride( - $: Api, - log: Log, -): Promise<{ path: string; data: unknown } | undefined> { +type Override = { path: string; data: unknown } | 'absent' | 'failed' + +/** 'absent': no file (defaults apply). 'failed': present but unusable. */ +async function readOverride($: Api, log: Log): Promise { try { const home = await $.env.get('HOME') - if (!home) return undefined + if (!home) return 'absent' const path = `${home}/${OVERRIDE}` - if (!(await $.fs.exists(path))) return undefined + if (!(await $.fs.exists(path))) return 'absent' const text = await readCapped($, path, log) - return text === undefined ? undefined : { path, data: JSON.parse(text) } + if (text === undefined) return 'failed' + const data: unknown = JSON.parse(text) + if (isRecord(data)) return { path, data } + log(`model-router: ${OVERRIDE} is not an object`) + return 'failed' } catch (err) { - log(`model-router: ${OVERRIDE} unreadable (${String(err)}); defaults`) - return undefined + log(`model-router: ${OVERRIDE} unreadable (${String(err)})`) + return 'failed' } } -/** Never throws; a failed read or parse leaves the defaults. */ +/** Never throws; undefined when the override exists but cannot be used. */ async function loadConfig( $: Api, log: Log, -): Promise<{ cfg: Config; source: string }> { +): Promise<{ cfg: Config; source: string } | undefined> { const found = await readOverride($, log) - if (!found) return { cfg: mergeConfig(undefined, log), source: 'defaults' } + if (found === 'failed') return undefined + if (found === 'absent') { + return { cfg: mergeConfig(undefined, log), source: 'defaults' } + } return { cfg: mergeConfig(found.data, log), source: found.path } } @@ -300,11 +326,14 @@ function newState(cfg: Config, source: string): State { source, userMain: null, turnMain: null, + turnFloor: null, pendingPrompt: null, + typedSlash: false, loops: new Map(), explicitEffort: new Map(), skillCalls: 0, off: false, + offConfig: false, lastMain: '', windowWarned: false, warned: new Set(), @@ -335,6 +364,67 @@ function safely(st: State, $: Api, hook: string, work: () => void): void { /** The main loop's effective route: user /route > latest turn route. */ const mainRoute = (st: State): Routed | null => st.userMain ?? st.turnMain +const rank = (l: Level | undefined): number => + l === undefined ? -1 : LEVELS.indexOf(l) + +/** Lifts `effort` to `floor`; a lower level, a number or none is replaced. */ +function floored(effort: Effort, floor: Level | undefined): Effort { + if (floor === undefined) return effort + return isLevel(effort) && rank(effort) >= rank(floor) ? effort : floor +} + +/** Two floors in one turn: the higher level stays (a tie takes the new). */ +function higherFloor(cur: Routed | null, next: Routed): Routed { + return cur && rank(cur.route.effort) > rank(next.route.effort) ? cur : next +} + +/** + * 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. + */ +function mainEffort(st: State, engine: Effort): Decision { + const sticky = st.userMain?.route.effort + const named = sticky ?? st.turnMain?.route.effort + const floor = st.turnFloor?.route.effort + const base = named ?? floor ?? engine + const effort = floored(base, floor) + if (floor !== undefined && (named === undefined || effort !== base)) { + return { effort, by: 'floor' } + } + if (named === undefined) return { effort, by: 'engine' } + 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}` + +const floorWord = (f: Routed): string => + `user floor ${f.route.effort ?? '-'} (${floorSource(f)})` + +/** + * Why main will not run at `asked`, or '' when it will. Truthful tail of + * every answer that records an effort for the main loop. + */ +function mainNote(st: State, asked: Level | undefined): string { + const d = mainEffort(st, undefined) + if (d.by === 'floor') { + if (!st.turnFloor || asked === undefined || d.effort === asked) return '' + return `${floorWord(st.turnFloor)} keeps main at ${String(d.effort)}; ` + + '/route clear to drop it' + } + return d.by === 'sticky' && st.userMain + ? `a sticky /route ${st.userMain.phase} is in force and wins until ` + + '/route clear' + : '' +} + function loopOf(st: State, agentId: string): Loop { const known = st.loops.get(agentId) if (known) return known @@ -353,20 +443,56 @@ function writeLoop(loop: Loop, route: Route): void { function clearRoutes(st: State): void { st.userMain = null st.turnMain = null + st.turnFloor = null st.pendingPrompt = null } +/** Config `enabled: false` switches the router off; true lifts only that. */ +function applyEnabled(st: State, enabled: boolean): void { + if (!enabled) { + st.off = true + st.offConfig = true + } else if (st.offConfig) { + st.off = false + st.offConfig = false + } +} + +const routerWord = (st: State): string => + st.off ? (st.offConfig ? 'off (config)' : 'off') : 'on' + // ---- 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 + const level = f?.route.effort + 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) +} + +function effortWord(st: State): string { + if (mainOnHaiku(st)) return '- (haiku takes none)' + return String(mainEffort(st, undefined).effort ?? '-') +} + function mainText(st: State): string { const r = mainRoute(st) - if (!r) return 'main: session defaults' - const model = modelText(st.cfg, r.route.model) + 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)) return `main: ${r.source} ${r.phase} · model ${model} · effort ${ - r.route.effort ?? '-'}` + effortWord(st)}${floor}` } function phasesText(cfg: Config): string { @@ -381,7 +507,7 @@ function show(st: State): string { const flag = (b: boolean) => (b ? 'on' : 'off') return [ mainText(st), - `router: ${st.off ? 'off' : 'on'} · switch: ${flag(c.mainModelSwitch)} · ` + + `router: ${routerWord(st)} · switch: ${flag(c.mainModelSwitch)} · ` + `verbose: ${flag(c.verbose)} · spinner: ${flag(c.spinner)}`, `live loops: ${st.loops.size}`, phasesText(c), @@ -391,8 +517,12 @@ function show(st: State): string { function statusLine(st: State): string { const r = mainRoute(st) - const now = st.off ? 'off' : r ? `${r.source} ${r.phase}` : 'session defaults' - return `route: ${now}${st.cfg.mainModelSwitch ? ' · switch on' : ''}` + const now = st.off + ? routerWord(st) + : r ? `${r.source} ${r.phase}` : 'session defaults' + const floor = liveFloor(st) + return `route: ${now}${floor ? ` · floor ${floor.level}` : ''}${ + st.cfg.mainModelSwitch ? ' · switch on' : ''}` } const refresh = ($: Api, st: State): void => $.ui.status(statusLine(st)) @@ -450,12 +580,21 @@ function setUserRoute($: Api, st: State, args: string): string { return show(st) } -/** (Re)loads the config into the state and re-registers the route tool. */ +/** + * (Re)loads the config into the state and re-registers the route tool. + * An unusable override keeps the previous config: the kill switch fails + * closed, never back to the defaults. + */ async function reloadConfig($: Api, st: State): Promise { const loaded = await loadConfig($, text => $.ui.log(text)) - st.cfg = loaded.cfg - st.rules = compileRules(loaded.cfg) - st.source = loaded.source + if (loaded) { + st.cfg = loaded.cfg + st.rules = compileRules(loaded.cfg) + st.source = loaded.source + applyEnabled(st, loaded.cfg.enabled) + } else { + $.ui.log('model-router: override unreadable; keeping the previous config') + } await registerTool($, st) } @@ -472,6 +611,7 @@ async function handleCommand($: Api, st: State, args: string): Promise { case 'on': case 'off': st.off = head === 'off' + st.offConfig = false refresh($, st) return show(st) case 'reload': @@ -557,7 +697,11 @@ function applyRoute(st: State, agentId: string | undefined, p: Picked): void { function clearLoop(st: State, agentId: string | undefined): string { if (agentId === undefined) { st.turnMain = null - return 'route cleared for main' + const f = st.turnFloor + const held = f && f.route.effort !== undefined + ? `; ${floorWord(f)} still holds, /route clear drops it` + : '' + return 'route cleared for main' + held } const loop = st.loops.get(agentId) if (loop) writeLoop(loop, {}) @@ -566,10 +710,8 @@ function clearLoop(st: State, agentId: string | undefined): string { /** Truthful answer: states what the calling loop will actually do. */ function routedText(st: State, agentId: string | undefined, p: Picked): string { - if (agentId === undefined && st.userMain) { - return `recorded ${p.phase} for this turn, but a sticky /route ` + - `${st.userMain.phase} is in force; it wins until /route clear` - } + const 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 @@ -610,9 +752,10 @@ function effortBridge(st: State, agentId: string | undefined, skill: string, if (agentId === undefined) { const route = { ...st.turnMain?.route, effort: level } st.turnMain = { phase: skill, route, source: 'skill' } - return skillResult(skill, st.userMain - ? `model-router: ${skill} recorded, but a sticky /route ` + - `${st.userMain.phase} is in force and wins until /route clear.` + const note = mainNote(st, level) + return skillResult(skill, note + ? `model-router: ${skill} recorded, but ${note}; the ${skill} skill ` + + 'text was not loaded.' : `model-router: effort → ${level} for this loop from the next ` + `request on; the ${skill} skill text was not loaded.`) } @@ -631,7 +774,7 @@ function onSkillLoad(st: State, skill: string, agentId: string | undefined) { const table = hasKey(st.cfg.skills, skill) ? st.cfg.skills[skill] : undefined const route = table === undefined ? undefined : phaseRoute(st.cfg, table) if (agentId === undefined) { - if (st.turnMain && st.turnMain.source !== 'prompt') st.turnMain = null + st.turnMain = null if (table !== undefined && route) { st.turnMain = { phase: table, route, source: 'skill' } } @@ -641,18 +784,38 @@ function onSkillLoad(st: State, skill: string, agentId: string | undefined) { if (loop) writeLoop(loop, { effort: route?.effort }) } -/** A user-typed /effort-: prepends one line, args ride in the text. */ +/** A user-typed /effort-: a floor for the turn, prepends one line. */ function slashEffort(st: State, skill: string, text: string) { const level = EFFORT_SKILL.exec(skill)?.[1] if (!isLevel(level)) return undefined - st.turnMain = { phase: skill, route: { effort: level }, source: 'slash' } - const line = st.userMain - ? `Effort ${level} recorded; the sticky /route ${st.userMain.phase} ` + - 'wins until /route clear.' - : `Effort shifted to ${level} by model-router for this turn.` + const route: Route = { effort: level } + const slash: Routed = { phase: skill, route, source: 'slash' } + st.turnFloor = higherFloor(st.turnFloor, slash) + const note = mainNote(st, level) + const line = `Effort ${level} set by model-router for the main loop this ` + + 'turn (minimum; a higher route still applies).' + + (note ? ` But ${note}.` : '') return { text: line + '\n' + text } } +/** + * Floor write for a skill.prompt. Only a typed slash may write it: the + * marker from prompt.submit attests the typing. Without it, a live + * sub-agent means the prompt is a preload inside that agent: ignored. + */ +function guardedSlash(st: State, skill: string, text: string) { + if (!EFFORT_SKILL.test(skill)) return undefined + if (st.typedSlash) { + st.typedSlash = false + } else if (st.loops.size > 0) { + return { + text: `model-router: ${skill} preload inside a live sub-agent is ` + + 'ignored on the main loop.\n' + text, + } + } + return slashEffort(st, skill, text) +} + // ---- agents ---------------------------------------------------------- type SpawnIn = { @@ -714,9 +877,8 @@ async function windowOk($: Api, st: State, id: string): Promise { } async function mainPlan($: Api, st: State, e: StepIn): Promise { - const set = mainRoute(st) - const effort = set?.route.effort ?? e.effort - const wanted = set?.route.model + const { effort } = mainEffort(st, e.effort) + const wanted = mainModel(st) if (wanted === undefined || !st.cfg.mainModelSwitch) { return { model: e.model, effort } } @@ -751,8 +913,10 @@ function noteMain($: Api, st: State, plan: Plan): void { } function endMainTurn($: Api, st: State): void { - st.turnMain = st.pendingPrompt + st.turnFloor = st.pendingPrompt st.pendingPrompt = null + st.turnMain = null + st.typedSlash = false st.explicitEffort.clear() st.lastMain = '' st.windowWarned = false @@ -773,6 +937,9 @@ function registerSession(on: On, st: State): void { }) on('session.end', async ($, e, next) => { Object.assign(st, newState(st.cfg, st.source)) + // 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) return next(e) }).catch(($, e, next) => { warnOnce(st, $, 'session.end', next.error.kind) @@ -823,7 +990,10 @@ function registerSkills(on: On, st: State): void { }) on('skill.prompt', async ($, e, next) => { if (st.off || st.skillCalls > 0) return next(e) - return slashEffort(st, e.skill, e.text) ?? next(e) + const out = guardedSlash(st, e.skill, e.text) + if (!out) return next(e) + refresh($, st) + return out }).catch(($, e, next) => { warnOnce(st, $, 'skill.prompt', next.error.kind) return next(e) @@ -891,6 +1061,15 @@ function registerTurns(on: On, st: State): void { }) } +/** + * A prompt's level is a floor now; typed mid-turn (`wait` is ignored, the + * engine queues either way) it is also kept for the next turn. + */ +function floorFromPrompt(st: State, midTurn: boolean, routed: Routed): void { + st.turnFloor = higherFloor(st.turnFloor, routed) + if (midTurn) st.pendingPrompt = higherFloor(st.pendingPrompt, routed) +} + function registerPrompt(on: On, st: State): void { on('prompt.submit', async ($, e, next) => { if (st.off || e.origin.kind !== 'composer') return next(e) @@ -899,11 +1078,10 @@ function registerPrompt(on: On, st: State): void { const route = rule ? phaseRoute(st.cfg, rule.phase) : undefined if (rule && route) { const routed: Routed = { phase: rule.phase, route, source: 'prompt' } - // Typed mid-turn and asked to wait: it belongs to the NEXT turn. - if (e.turnId !== undefined && e.wait) st.pendingPrompt = routed - else st.turnMain = routed + floorFromPrompt(st, e.turnId !== undefined, routed) refresh($, st) } + if (e.text.trimStart().startsWith('/effort-')) st.typedSlash = true return next(e) }).catch(($, e, next) => { warnOnce(st, $, 'prompt.submit', next.error.kind)