feat(mods): model-router user effort floor — ultrathink and typed /effort-<l> set the main turn's default and minimum
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-<l> attested at prompt.submit so a sub-agent preload cannot floor the main loop. 30 plugin tests.
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
import { test, expect } from 'claude-code/testing'
|
import { test, expect } from 'claude-code/testing'
|
||||||
import type { Engine } 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. */
|
/** Fires session.start so the mod loads its config and registers /route. */
|
||||||
async function boot($: Engine, on: On): Promise<void> {
|
async function boot($: Engine, on: On): Promise<void> {
|
||||||
@@ -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 }))
|
on('prompt.submit', ($, e) => ({ text: e.text }))
|
||||||
await boot($, on)
|
await boot($, on)
|
||||||
await $.prompt.submit({
|
await $.prompt.submit({
|
||||||
@@ -92,7 +92,7 @@ test('ultrathink in a prompt sets escalate on main', async ($, on) => {
|
|||||||
wait: false,
|
wait: false,
|
||||||
origin: { kind: 'composer' },
|
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 (
|
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. */
|
/** Streams one turn.step to its end; the hooks run as the chunks flow. */
|
||||||
async function runStep(
|
async function runStep(
|
||||||
$: Engine,
|
$: Engine,
|
||||||
input: ReturnType<typeof stepInput>,
|
input: TurnStepInput,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const stream = $.turn.step(input)
|
const stream = $.turn.step(input)
|
||||||
for await (const _chunk of stream) {
|
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')
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<unknown> {
|
||||||
|
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<Seen[]> {
|
||||||
|
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')
|
||||||
|
})
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ type Config = {
|
|||||||
mainModelSwitch: boolean
|
mainModelSwitch: boolean
|
||||||
verbose: boolean
|
verbose: boolean
|
||||||
spinner: boolean
|
spinner: boolean
|
||||||
|
enabled: boolean // false: every hook passes through (per machine)
|
||||||
}
|
}
|
||||||
type Rule = { re: RegExp; phase: string }
|
type Rule = { re: RegExp; phase: string }
|
||||||
type Source = 'user' | 'model' | 'skill' | 'prompt' | 'slash'
|
type Source = 'user' | 'model' | 'skill' | 'prompt' | 'slash'
|
||||||
@@ -32,12 +33,17 @@ type State = {
|
|||||||
rules: Rule[]
|
rules: Rule[]
|
||||||
source: string // 'defaults' or the override path
|
source: string // 'defaults' or the override path
|
||||||
userMain: Routed | null // /route by the user, sticky until /route clear
|
userMain: Routed | null // /route by the user, sticky until /route clear
|
||||||
turnMain: Routed | null // tool, skill, slash, prompt; dropped at turn end
|
turnMain: Routed | null // model route tool, skill table row, Skill(effort-*)
|
||||||
pendingPrompt: Routed | null // typed mid-turn, promoted next turn
|
// bridge; dropped at turn end
|
||||||
|
turnFloor: Routed | null // user-explicit level for this turn (prompt rule,
|
||||||
|
// typed /effort-<l>): 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-<l>
|
||||||
loops: Map<string, Loop> // agentId -> that loop's routing
|
loops: Map<string, Loop> // agentId -> that loop's routing
|
||||||
explicitEffort: Map<string, Level> // Agent tool_use_id -> effort param
|
explicitEffort: Map<string, Level> // Agent tool_use_id -> effort param
|
||||||
skillCalls: number // Skill tool calls in flight
|
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)
|
lastMain: string // "model/effort" of the last main step (spinner)
|
||||||
windowWarned: boolean // context-window warning already logged this turn
|
windowWarned: boolean // context-window warning already logged this turn
|
||||||
warned: Set<string> // hooks whose fail-open was already logged
|
warned: Set<string> // hooks whose fail-open was already logged
|
||||||
@@ -52,6 +58,9 @@ type RouteInput = {
|
|||||||
agentId?: string
|
agentId?: string
|
||||||
}
|
}
|
||||||
type Picked = { phase: string; route: Route }
|
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 LEVELS: readonly Level[] = ['low', 'medium', 'high', 'xhigh', 'max']
|
||||||
const MODEL_ID = /^claude-[a-z0-9.-]+$/
|
const MODEL_ID = /^claude-[a-z0-9.-]+$/
|
||||||
@@ -91,6 +100,7 @@ const DEFAULT_CONFIG: Config = {
|
|||||||
mainModelSwitch: false,
|
mainModelSwitch: false,
|
||||||
verbose: false,
|
verbose: false,
|
||||||
spinner: true,
|
spinner: true,
|
||||||
|
enabled: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- config ----------------------------------------------------------
|
// ---- config ----------------------------------------------------------
|
||||||
@@ -213,6 +223,14 @@ function mergePrompt(
|
|||||||
const pickBool = (v: unknown, fallback: boolean): boolean =>
|
const pickBool = (v: unknown, fallback: boolean): boolean =>
|
||||||
typeof v === 'boolean' ? v : fallback
|
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. */
|
/** Defaults overlaid with the user's entries, each validated first. */
|
||||||
function mergeConfig(user: unknown, log: Log): Config {
|
function mergeConfig(user: unknown, log: Log): Config {
|
||||||
const base = structuredClone(DEFAULT_CONFIG)
|
const base = structuredClone(DEFAULT_CONFIG)
|
||||||
@@ -236,6 +254,7 @@ function mergeConfig(user: unknown, log: Log): Config {
|
|||||||
mainModelSwitch: pickBool(user.mainModelSwitch, base.mainModelSwitch),
|
mainModelSwitch: pickBool(user.mainModelSwitch, base.mainModelSwitch),
|
||||||
verbose: pickBool(user.verbose, base.verbose),
|
verbose: pickBool(user.verbose, base.verbose),
|
||||||
spinner: pickBool(user.spinner, base.spinner),
|
spinner: pickBool(user.spinner, base.spinner),
|
||||||
|
enabled: pickEnabled(user.enabled, base.enabled, log),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -246,7 +265,7 @@ async function readCapped(
|
|||||||
log: Log,
|
log: Log,
|
||||||
): Promise<string | undefined> {
|
): Promise<string | undefined> {
|
||||||
const tooBig =
|
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) {
|
if ((await $.fs.stat(path)).size > MAX_CONFIG_BYTES) {
|
||||||
log(tooBig)
|
log(tooBig)
|
||||||
return undefined
|
return undefined
|
||||||
@@ -257,30 +276,37 @@ async function readCapped(
|
|||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
async function readOverride(
|
type Override = { path: string; data: unknown } | 'absent' | 'failed'
|
||||||
$: Api,
|
|
||||||
log: Log,
|
/** 'absent': no file (defaults apply). 'failed': present but unusable. */
|
||||||
): Promise<{ path: string; data: unknown } | undefined> {
|
async function readOverride($: Api, log: Log): Promise<Override> {
|
||||||
try {
|
try {
|
||||||
const home = await $.env.get('HOME')
|
const home = await $.env.get('HOME')
|
||||||
if (!home) return undefined
|
if (!home) return 'absent'
|
||||||
const path = `${home}/${OVERRIDE}`
|
const path = `${home}/${OVERRIDE}`
|
||||||
if (!(await $.fs.exists(path))) return undefined
|
if (!(await $.fs.exists(path))) return 'absent'
|
||||||
const text = await readCapped($, path, log)
|
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) {
|
} catch (err) {
|
||||||
log(`model-router: ${OVERRIDE} unreadable (${String(err)}); defaults`)
|
log(`model-router: ${OVERRIDE} unreadable (${String(err)})`)
|
||||||
return undefined
|
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(
|
async function loadConfig(
|
||||||
$: Api,
|
$: Api,
|
||||||
log: Log,
|
log: Log,
|
||||||
): Promise<{ cfg: Config; source: string }> {
|
): Promise<{ cfg: Config; source: string } | undefined> {
|
||||||
const found = await readOverride($, log)
|
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 }
|
return { cfg: mergeConfig(found.data, log), source: found.path }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -300,11 +326,14 @@ function newState(cfg: Config, source: string): State {
|
|||||||
source,
|
source,
|
||||||
userMain: null,
|
userMain: null,
|
||||||
turnMain: null,
|
turnMain: null,
|
||||||
|
turnFloor: null,
|
||||||
pendingPrompt: null,
|
pendingPrompt: null,
|
||||||
|
typedSlash: false,
|
||||||
loops: new Map(),
|
loops: new Map(),
|
||||||
explicitEffort: new Map(),
|
explicitEffort: new Map(),
|
||||||
skillCalls: 0,
|
skillCalls: 0,
|
||||||
off: false,
|
off: false,
|
||||||
|
offConfig: false,
|
||||||
lastMain: '',
|
lastMain: '',
|
||||||
windowWarned: false,
|
windowWarned: false,
|
||||||
warned: new Set(),
|
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. */
|
/** The main loop's effective route: user /route > latest turn route. */
|
||||||
const mainRoute = (st: State): Routed | null => st.userMain ?? st.turnMain
|
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 {
|
function loopOf(st: State, agentId: string): Loop {
|
||||||
const known = st.loops.get(agentId)
|
const known = st.loops.get(agentId)
|
||||||
if (known) return known
|
if (known) return known
|
||||||
@@ -353,20 +443,56 @@ function writeLoop(loop: Loop, route: Route): void {
|
|||||||
function clearRoutes(st: State): void {
|
function clearRoutes(st: State): void {
|
||||||
st.userMain = null
|
st.userMain = null
|
||||||
st.turnMain = null
|
st.turnMain = null
|
||||||
|
st.turnFloor = null
|
||||||
st.pendingPrompt = 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 ------------------------------------------------------------
|
// ---- text ------------------------------------------------------------
|
||||||
|
|
||||||
const modelText = (cfg: Config, model: string | undefined): string =>
|
const modelText = (cfg: Config, model: string | undefined): string =>
|
||||||
model === undefined ? '-' : resolveModel(cfg, model)
|
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 {
|
function mainText(st: State): string {
|
||||||
const r = mainRoute(st)
|
const r = mainRoute(st)
|
||||||
if (!r) return 'main: session defaults'
|
const live = liveFloor(st)
|
||||||
const model = modelText(st.cfg, r.route.model)
|
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 ${
|
return `main: ${r.source} ${r.phase} · model ${model} · effort ${
|
||||||
r.route.effort ?? '-'}`
|
effortWord(st)}${floor}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function phasesText(cfg: Config): string {
|
function phasesText(cfg: Config): string {
|
||||||
@@ -381,7 +507,7 @@ function show(st: State): string {
|
|||||||
const flag = (b: boolean) => (b ? 'on' : 'off')
|
const flag = (b: boolean) => (b ? 'on' : 'off')
|
||||||
return [
|
return [
|
||||||
mainText(st),
|
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)}`,
|
`verbose: ${flag(c.verbose)} · spinner: ${flag(c.spinner)}`,
|
||||||
`live loops: ${st.loops.size}`,
|
`live loops: ${st.loops.size}`,
|
||||||
phasesText(c),
|
phasesText(c),
|
||||||
@@ -391,8 +517,12 @@ function show(st: State): string {
|
|||||||
|
|
||||||
function statusLine(st: State): string {
|
function statusLine(st: State): string {
|
||||||
const r = mainRoute(st)
|
const r = mainRoute(st)
|
||||||
const now = st.off ? 'off' : r ? `${r.source} ${r.phase}` : 'session defaults'
|
const now = st.off
|
||||||
return `route: ${now}${st.cfg.mainModelSwitch ? ' · switch on' : ''}`
|
? 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))
|
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)
|
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<void> {
|
async function reloadConfig($: Api, st: State): Promise<void> {
|
||||||
const loaded = await loadConfig($, text => $.ui.log(text))
|
const loaded = await loadConfig($, text => $.ui.log(text))
|
||||||
st.cfg = loaded.cfg
|
if (loaded) {
|
||||||
st.rules = compileRules(loaded.cfg)
|
st.cfg = loaded.cfg
|
||||||
st.source = loaded.source
|
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)
|
await registerTool($, st)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -472,6 +611,7 @@ async function handleCommand($: Api, st: State, args: string): Promise<string> {
|
|||||||
case 'on':
|
case 'on':
|
||||||
case 'off':
|
case 'off':
|
||||||
st.off = head === 'off'
|
st.off = head === 'off'
|
||||||
|
st.offConfig = false
|
||||||
refresh($, st)
|
refresh($, st)
|
||||||
return show(st)
|
return show(st)
|
||||||
case 'reload':
|
case 'reload':
|
||||||
@@ -557,7 +697,11 @@ function applyRoute(st: State, agentId: string | undefined, p: Picked): void {
|
|||||||
function clearLoop(st: State, agentId: string | undefined): string {
|
function clearLoop(st: State, agentId: string | undefined): string {
|
||||||
if (agentId === undefined) {
|
if (agentId === undefined) {
|
||||||
st.turnMain = null
|
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)
|
const loop = st.loops.get(agentId)
|
||||||
if (loop) writeLoop(loop, {})
|
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. */
|
/** Truthful answer: states what the calling loop will actually do. */
|
||||||
function routedText(st: State, agentId: string | undefined, p: Picked): string {
|
function routedText(st: State, agentId: string | undefined, p: Picked): string {
|
||||||
if (agentId === undefined && st.userMain) {
|
const note = agentId === undefined ? mainNote(st, p.route.effort) : ''
|
||||||
return `recorded ${p.phase} for this turn, but a sticky /route ` +
|
if (note) return `recorded ${p.phase} for this turn, but ${note}`
|
||||||
`${st.userMain.phase} is in force; it wins until /route clear`
|
|
||||||
}
|
|
||||||
const loop = agentId === undefined ? undefined : st.loops.get(agentId)
|
const loop = agentId === undefined ? undefined : st.loops.get(agentId)
|
||||||
const effort = loop?.explicitEffort ? undefined : p.route.effort
|
const effort = loop?.explicitEffort ? undefined : p.route.effort
|
||||||
const model = agentId === undefined && st.cfg.mainModelSwitch
|
const model = agentId === undefined && st.cfg.mainModelSwitch
|
||||||
@@ -610,9 +752,10 @@ function effortBridge(st: State, agentId: string | undefined, skill: string,
|
|||||||
if (agentId === undefined) {
|
if (agentId === undefined) {
|
||||||
const route = { ...st.turnMain?.route, effort: level }
|
const route = { ...st.turnMain?.route, effort: level }
|
||||||
st.turnMain = { phase: skill, route, source: 'skill' }
|
st.turnMain = { phase: skill, route, source: 'skill' }
|
||||||
return skillResult(skill, st.userMain
|
const note = mainNote(st, level)
|
||||||
? `model-router: ${skill} recorded, but a sticky /route ` +
|
return skillResult(skill, note
|
||||||
`${st.userMain.phase} is in force and wins until /route clear.`
|
? `model-router: ${skill} recorded, but ${note}; the ${skill} skill ` +
|
||||||
|
'text was not loaded.'
|
||||||
: `model-router: effort → ${level} for this loop from the next ` +
|
: `model-router: effort → ${level} for this loop from the next ` +
|
||||||
`request on; the ${skill} skill text was not loaded.`)
|
`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 table = hasKey(st.cfg.skills, skill) ? st.cfg.skills[skill] : undefined
|
||||||
const route = table === undefined ? undefined : phaseRoute(st.cfg, table)
|
const route = table === undefined ? undefined : phaseRoute(st.cfg, table)
|
||||||
if (agentId === undefined) {
|
if (agentId === undefined) {
|
||||||
if (st.turnMain && st.turnMain.source !== 'prompt') st.turnMain = null
|
st.turnMain = null
|
||||||
if (table !== undefined && route) {
|
if (table !== undefined && route) {
|
||||||
st.turnMain = { phase: table, route, source: 'skill' }
|
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 })
|
if (loop) writeLoop(loop, { effort: route?.effort })
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A user-typed /effort-<l>: prepends one line, args ride in the text. */
|
/** A user-typed /effort-<l>: a floor for the turn, prepends one line. */
|
||||||
function slashEffort(st: State, skill: string, text: string) {
|
function slashEffort(st: State, skill: string, text: string) {
|
||||||
const level = EFFORT_SKILL.exec(skill)?.[1]
|
const level = EFFORT_SKILL.exec(skill)?.[1]
|
||||||
if (!isLevel(level)) return undefined
|
if (!isLevel(level)) return undefined
|
||||||
st.turnMain = { phase: skill, route: { effort: level }, source: 'slash' }
|
const route: Route = { effort: level }
|
||||||
const line = st.userMain
|
const slash: Routed = { phase: skill, route, source: 'slash' }
|
||||||
? `Effort ${level} recorded; the sticky /route ${st.userMain.phase} ` +
|
st.turnFloor = higherFloor(st.turnFloor, slash)
|
||||||
'wins until /route clear.'
|
const note = mainNote(st, level)
|
||||||
: `Effort shifted to ${level} by model-router for this turn.`
|
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 }
|
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 ----------------------------------------------------------
|
// ---- agents ----------------------------------------------------------
|
||||||
|
|
||||||
type SpawnIn = {
|
type SpawnIn = {
|
||||||
@@ -714,9 +877,8 @@ async function windowOk($: Api, st: State, id: string): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function mainPlan($: Api, st: State, e: StepIn): Promise<Plan> {
|
async function mainPlan($: Api, st: State, e: StepIn): Promise<Plan> {
|
||||||
const set = mainRoute(st)
|
const { effort } = mainEffort(st, e.effort)
|
||||||
const effort = set?.route.effort ?? e.effort
|
const wanted = mainModel(st)
|
||||||
const wanted = set?.route.model
|
|
||||||
if (wanted === undefined || !st.cfg.mainModelSwitch) {
|
if (wanted === undefined || !st.cfg.mainModelSwitch) {
|
||||||
return { model: e.model, effort }
|
return { model: e.model, effort }
|
||||||
}
|
}
|
||||||
@@ -751,8 +913,10 @@ function noteMain($: Api, st: State, plan: Plan): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function endMainTurn($: Api, st: State): void {
|
function endMainTurn($: Api, st: State): void {
|
||||||
st.turnMain = st.pendingPrompt
|
st.turnFloor = st.pendingPrompt
|
||||||
st.pendingPrompt = null
|
st.pendingPrompt = null
|
||||||
|
st.turnMain = null
|
||||||
|
st.typedSlash = false
|
||||||
st.explicitEffort.clear()
|
st.explicitEffort.clear()
|
||||||
st.lastMain = ''
|
st.lastMain = ''
|
||||||
st.windowWarned = false
|
st.windowWarned = false
|
||||||
@@ -773,6 +937,9 @@ function registerSession(on: On, st: State): void {
|
|||||||
})
|
})
|
||||||
on('session.end', async ($, e, next) => {
|
on('session.end', async ($, e, next) => {
|
||||||
Object.assign(st, newState(st.cfg, st.source))
|
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)
|
return next(e)
|
||||||
}).catch(($, e, next) => {
|
}).catch(($, e, next) => {
|
||||||
warnOnce(st, $, 'session.end', next.error.kind)
|
warnOnce(st, $, 'session.end', next.error.kind)
|
||||||
@@ -823,7 +990,10 @@ function registerSkills(on: On, st: State): void {
|
|||||||
})
|
})
|
||||||
on('skill.prompt', async ($, e, next) => {
|
on('skill.prompt', async ($, e, next) => {
|
||||||
if (st.off || st.skillCalls > 0) return next(e)
|
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) => {
|
}).catch(($, e, next) => {
|
||||||
warnOnce(st, $, 'skill.prompt', next.error.kind)
|
warnOnce(st, $, 'skill.prompt', next.error.kind)
|
||||||
return next(e)
|
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 {
|
function registerPrompt(on: On, st: State): void {
|
||||||
on('prompt.submit', async ($, e, next) => {
|
on('prompt.submit', async ($, e, next) => {
|
||||||
if (st.off || e.origin.kind !== 'composer') return next(e)
|
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
|
const route = rule ? phaseRoute(st.cfg, rule.phase) : undefined
|
||||||
if (rule && route) {
|
if (rule && route) {
|
||||||
const routed: Routed = { phase: rule.phase, route, source: 'prompt' }
|
const routed: Routed = { phase: rule.phase, route, source: 'prompt' }
|
||||||
// Typed mid-turn and asked to wait: it belongs to the NEXT turn.
|
floorFromPrompt(st, e.turnId !== undefined, routed)
|
||||||
if (e.turnId !== undefined && e.wait) st.pendingPrompt = routed
|
|
||||||
else st.turnMain = routed
|
|
||||||
refresh($, st)
|
refresh($, st)
|
||||||
}
|
}
|
||||||
|
if (e.text.trimStart().startsWith('/effort-')) st.typedSlash = true
|
||||||
return next(e)
|
return next(e)
|
||||||
}).catch(($, e, next) => {
|
}).catch(($, e, next) => {
|
||||||
warnOnce(st, $, 'prompt.submit', next.error.kind)
|
warnOnce(st, $, 'prompt.submit', next.error.kind)
|
||||||
|
|||||||
Reference in New Issue
Block a user