feat(model-router): wave 2-A — phase rows for every repo skill and agent, run slot, typed-slash routing
Rows by role replace the pins as the live source (frontmatter stays as the off-state floor): phases write=work/high and apply=work/low, 56 skill rows, 21 agent rows + Explore/Plan. Agents get the row's model at spawn (within the tier, upward only, explicit params win, project-defined agents skipped via agent.offer) and its effort per step. A typed slash of a rowed skill routes main through a name-bound marker (composer|sdk|bridge, pending slot mid-turn) or the idle fallback; a best-tier row lives in a runMain slot that survives turn end and route calls. An unrowed skill leaves the route. Typed /effort-* floor code removed (bridge kept until W2-B). The route answer always names the id. Override rows accept null. Kit suite 58 → 88. Contract .claude/tasks/contracts/2026-10-09-model-router-w2a-1546.md, plan r4 .claude/tasks/plans/2026-10-09-model-router-w2-1546.md: 3 lenses + 2 confirmations, feater + 4 rounds, GATE 0 MET, verifier 3x ECARTS on test coverage only (user-accepted at the cap), security PASS.
This commit is contained in:
@@ -23,6 +23,8 @@ async function boot(
|
||||
on('session.model', () => ({ value: model }))
|
||||
on('classic.StopFailure', () => ({}))
|
||||
on('classic.PostModelSwitch', () => ({}))
|
||||
on('skill.prompt', ($, e) => ({ text: e.text }))
|
||||
on('agent.offer', () => ({ isOffered: true }))
|
||||
if (tokens !== null) usageOf(on, tokens)
|
||||
on('session.start', ($, e) => ({ cwd: e.cwd }))
|
||||
await $.session.start({ cwd: '/tmp', surface: null, isInteractive: false })
|
||||
@@ -321,30 +323,14 @@ test('floor: ultrathink survives a model route', async ($, on) => {
|
||||
expect(await stepEffort($, seen)).toBe('max')
|
||||
})
|
||||
|
||||
test('floor: typed /effort-medium clamps low, lets max pass', async (
|
||||
test('floor: survives a skill load; an unrowed skill keeps the route', 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')
|
||||
expect(mainLine(await route($, 'show'))).toContain('model orchestrate')
|
||||
})
|
||||
|
||||
test('floor: lifts a lower sticky, then ends with the turn', async (
|
||||
@@ -439,35 +425,6 @@ test('reload with an unreadable override keeps the previous config', async (
|
||||
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')
|
||||
})
|
||||
|
||||
// ---- tiers, breaker, derived phases -------------------------------------
|
||||
|
||||
type Rig = { seen: Seen[]; clock: MockClock; specs: (string | undefined)[] }
|
||||
@@ -766,16 +723,6 @@ test('default rule: a typed slash command gets no default rule', async (
|
||||
expect(mainLine(await route($, 'show'))).toContain('session defaults')
|
||||
})
|
||||
|
||||
test('default rule: /effort-low pourquoi sets the floor, no default', async (
|
||||
$, on) => {
|
||||
await bootRig($, on)
|
||||
await typed($, '/effort-low pourquoi ça plante')
|
||||
await $.skill.prompt({ skill: 'effort-low', text: 'x' })
|
||||
const line = mainLine(await route($, 'show'))
|
||||
expect(line).toContain('floor low')
|
||||
expect(line).not.toContain('reflect')
|
||||
})
|
||||
|
||||
test('per axis: a model-less sticky never hides a turn route tier', async (
|
||||
$, on) => {
|
||||
const rig = await bootRig($, on, HAIKU)
|
||||
@@ -844,3 +791,466 @@ test('text: show names the model a floor upgrade moves to', async (
|
||||
const line = mainLine(await route($, 'show'))
|
||||
expect(line).toContain(`${FABLE} (upgrade)`)
|
||||
})
|
||||
|
||||
// ---- wave 2: skill rows, run slot, agent rows ---------------------------
|
||||
|
||||
const skillPrompt = ($: Engine, skill: string) =>
|
||||
$.skill.prompt({ skill, text: 'x' })
|
||||
|
||||
const loadSkill = ($: Engine, skill: string, agentId?: string) =>
|
||||
$.tool.call({
|
||||
tool: 'Skill',
|
||||
skill,
|
||||
...(agentId === undefined ? {} : { agentId }),
|
||||
})
|
||||
|
||||
/** bootRig plus a bottom Skill tool, so a model-loaded skill goes through. */
|
||||
async function bootRun($: Engine, on: On, model: string = FABLE) {
|
||||
on('tool.call', { tool: 'Skill' }, () => ({
|
||||
result: { success: true, commandName: 'bottom' },
|
||||
}))
|
||||
return bootRig($, on, model)
|
||||
}
|
||||
|
||||
const spawnOf = (agent: string, extra: object = {}) =>
|
||||
({ ...spawnInput(), subagentType: agent, ...extra })
|
||||
|
||||
const offerOf = ($: Engine, agent: string, source: string) =>
|
||||
$.agent.offer({
|
||||
agent,
|
||||
description: 'd',
|
||||
source,
|
||||
provider: { plugin: 'engine', tier: 'core' },
|
||||
})
|
||||
|
||||
/**
|
||||
* A ui.log recorder. The kit refuses a bottom hook for a void event, so the
|
||||
* hook sits on top and swallows the missing implementation below it.
|
||||
*/
|
||||
function recordLogs(on: On): string[] {
|
||||
const lines: string[] = []
|
||||
on('ui.log', async ($, e, next) => {
|
||||
lines.push(e.text)
|
||||
try {
|
||||
await next(e)
|
||||
} catch {
|
||||
// nothing implements ui.log below the recorder in the kit
|
||||
}
|
||||
})
|
||||
return lines
|
||||
}
|
||||
|
||||
test('typed /feat routes main to its row, best tier', async ($, on) => {
|
||||
await bootRun($, on)
|
||||
await typed($, '/feat add a thing')
|
||||
await skillPrompt($, 'feat')
|
||||
const line = mainLine(await route($, 'show'))
|
||||
expect(line).toContain('skill reflect')
|
||||
expect(line).toContain('effort high')
|
||||
expect(line).toContain('[tier best]')
|
||||
})
|
||||
|
||||
test('typed slash: a foreign origin arms nothing, fallback refused', async (
|
||||
$, on) => {
|
||||
await bootRun($, on)
|
||||
await $.prompt.submit({
|
||||
text: '/feat x',
|
||||
wait: false,
|
||||
origin: { kind: 'channel', server: 'slack' },
|
||||
})
|
||||
await skillPrompt($, 'feat')
|
||||
expect(mainLine(await route($, 'show'))).toContain('session defaults')
|
||||
})
|
||||
|
||||
test('typed slash: a preload in a live sub-agent leaves main alone', async (
|
||||
$, on) => {
|
||||
await bootRun($, on)
|
||||
await typed($, 'hello')
|
||||
await $.agent.spawn(spawnOf('feater'))
|
||||
await skillPrompt($, 'feat')
|
||||
expect(mainLine(await route($, 'show'))).toContain('session defaults')
|
||||
})
|
||||
|
||||
test('typed slash: no live loop and an allowed origin still routes', async (
|
||||
$, on) => {
|
||||
await bootRun($, on)
|
||||
await typed($, 'hello')
|
||||
await skillPrompt($, 'feat')
|
||||
expect(mainLine(await route($, 'show'))).toContain('skill reflect')
|
||||
})
|
||||
|
||||
test('typed slash: an sdk origin arms the marker', async ($, on) => {
|
||||
await bootRun($, on)
|
||||
await $.prompt.submit({
|
||||
text: '/status',
|
||||
wait: false,
|
||||
origin: { kind: 'sdk' },
|
||||
})
|
||||
await $.agent.spawn(spawnOf('feater'))
|
||||
await skillPrompt($, 'status')
|
||||
expect(mainLine(await route($, 'show'))).toContain('skill mechanical')
|
||||
})
|
||||
|
||||
test('typed slash: a mid-turn /status waits for the turn end', async (
|
||||
$, on) => {
|
||||
await bootRun($, on)
|
||||
await $.prompt.submit({
|
||||
text: '/status',
|
||||
wait: true,
|
||||
origin: { kind: 'composer' },
|
||||
turnId: 'u1',
|
||||
})
|
||||
await skillPrompt($, 'status')
|
||||
expect(mainLine(await route($, 'show'))).toContain('session defaults')
|
||||
await endTurn($)
|
||||
await skillPrompt($, 'status')
|
||||
expect(mainLine(await route($, 'show'))).toContain('skill mechanical')
|
||||
})
|
||||
|
||||
test('skill without a row keeps the route; a rowed one replaces it', async (
|
||||
$, on) => {
|
||||
await bootRun($, on)
|
||||
await $.tool.call({ tool: ROUTE_TOOL, phase: 'plan' })
|
||||
await loadSkill($, 'find-docs')
|
||||
expect(mainLine(await route($, 'show'))).toContain('model plan')
|
||||
await loadSkill($, 'gitflow')
|
||||
expect(mainLine(await route($, 'show'))).toContain('skill implement')
|
||||
})
|
||||
|
||||
test('run slot: survives route calls and the turn end', async ($, on) => {
|
||||
await bootRun($, on)
|
||||
await loadSkill($, 'feat')
|
||||
await $.tool.call({ tool: ROUTE_TOOL, phase: 'orchestrate' })
|
||||
await endTurn($)
|
||||
expect(mainLine(await route($, 'show'))).toContain('run reflect')
|
||||
await $.tool.call({ tool: ROUTE_TOOL, phase: 'plan' })
|
||||
await endTurn($)
|
||||
expect(mainLine(await route($, 'show'))).toContain('run reflect')
|
||||
})
|
||||
|
||||
test('run slot: a model-loaded helper skill keeps it', async ($, on) => {
|
||||
await bootRun($, on)
|
||||
await loadSkill($, 'feat')
|
||||
await loadSkill($, 'using-git-worktrees')
|
||||
expect(mainLine(await route($, 'show'))).toContain('skill mechanical')
|
||||
await endTurn($)
|
||||
expect(mainLine(await route($, 'show'))).toContain('run reflect')
|
||||
})
|
||||
|
||||
test('run slot: a typed non-best skill drops it', async ($, on) => {
|
||||
await bootRun($, on)
|
||||
await loadSkill($, 'feat')
|
||||
await endTurn($)
|
||||
await typed($, '/status')
|
||||
await skillPrompt($, 'status')
|
||||
await endTurn($)
|
||||
expect(mainLine(await route($, 'show'))).toContain('session defaults')
|
||||
})
|
||||
|
||||
test('run slot: /route clear and /route off drop it', async ($, on) => {
|
||||
await bootRun($, on)
|
||||
await loadSkill($, 'feat')
|
||||
expect(await route($, 'clear')).toContain('run slot dropped')
|
||||
await endTurn($)
|
||||
expect(mainLine(await route($, 'show'))).toContain('session defaults')
|
||||
await loadSkill($, 'feat')
|
||||
await route($, 'off')
|
||||
await route($, 'on')
|
||||
await endTurn($)
|
||||
expect(mainLine(await route($, 'show'))).toContain('session defaults')
|
||||
})
|
||||
|
||||
test('run slot: a user /model switch drops it, an auto one does not', async (
|
||||
$, on) => {
|
||||
await bootRun($, on)
|
||||
await loadSkill($, 'feat')
|
||||
await endTurn($)
|
||||
await switchTo($, FABLE, OPUS, 'auto')
|
||||
expect(mainLine(await route($, 'show'))).toContain('run reflect')
|
||||
await switchTo($, FABLE, OPUS, 'command')
|
||||
expect(mainLine(await route($, 'show'))).toContain('session defaults')
|
||||
})
|
||||
|
||||
test('run slot: a Skill call inside a sub-agent never touches it', async (
|
||||
$, on) => {
|
||||
await bootRun($, on)
|
||||
await loadSkill($, 'feat', 'a1')
|
||||
await endTurn($)
|
||||
expect(mainLine(await route($, 'show'))).toContain('session defaults')
|
||||
})
|
||||
|
||||
test('run slot: route(clear) clears the turn and names the run', async (
|
||||
$, on) => {
|
||||
await bootRun($, on)
|
||||
await loadSkill($, 'feat')
|
||||
const out = await $.tool.call({ tool: ROUTE_TOOL, clear: true })
|
||||
expect(JSON.stringify(out)).toContain('run reflect still holds')
|
||||
})
|
||||
|
||||
test('Skill(effort-low) bridge is not sticky', async ($, on) => {
|
||||
await bootRun($, on)
|
||||
await loadSkill($, 'effort-low')
|
||||
expect(mainLine(await route($, 'show'))).toContain('effort low')
|
||||
await endTurn($)
|
||||
expect(mainLine(await route($, 'show'))).toContain('session defaults')
|
||||
})
|
||||
|
||||
test('route answer names the id even with a floor in force', async (
|
||||
$, on) => {
|
||||
await bootRun($, on)
|
||||
await ultrathink($)
|
||||
const out = await $.tool.call({ tool: ROUTE_TOOL, phase: 'plan' })
|
||||
const text = JSON.stringify(out)
|
||||
expect(text).toContain(`model ${FABLE}`)
|
||||
expect(text).toContain('user floor max')
|
||||
})
|
||||
|
||||
test('agent row: feater spawns on sonnet and steps at medium', async (
|
||||
$, on) => {
|
||||
const rig = await bootRun($, on)
|
||||
const out = await $.agent.spawn(spawnOf('feater'))
|
||||
expect(rig.specs).toEqual([SONNET])
|
||||
expect(out.model).toBe(SONNET)
|
||||
await runStep($, { ...highStep('a1'), model: SONNET })
|
||||
expect(rig.seen[rig.seen.length - 1]).toEqual({
|
||||
model: SONNET,
|
||||
effort: 'medium',
|
||||
})
|
||||
})
|
||||
|
||||
/** Spawn feater, load a skill inside it, return the effort of its step. */
|
||||
async function effortAfterSkill($: Engine, on: On, skill: string) {
|
||||
const rig = await bootRun($, on)
|
||||
await $.agent.spawn(spawnOf('feater'))
|
||||
await loadSkill($, skill, 'a1')
|
||||
await runStep($, { ...highStep('a1'), model: SONNET })
|
||||
return rig.seen[rig.seen.length - 1].effort
|
||||
}
|
||||
|
||||
test('agent skill: an unrowed one leaves the loop effort', async ($, on) => {
|
||||
expect(await effortAfterSkill($, on, 'find-docs')).toBe('medium')
|
||||
})
|
||||
|
||||
test('agent skill: a rowed one writes the loop effort', async ($, on) => {
|
||||
expect(await effortAfterSkill($, on, 'status')).toBe('low')
|
||||
})
|
||||
|
||||
test('agent row: judge moves up to fable when opus is down', async (
|
||||
$, on) => {
|
||||
const rig = await bootRun($, on)
|
||||
await switchTo($, OPUS, SONNET, 'auto')
|
||||
await $.agent.spawn(spawnOf('plan-challenger'))
|
||||
expect(rig.specs).toEqual([FABLE])
|
||||
})
|
||||
|
||||
test('agent row: opus and fable down, no write and one log line', async (
|
||||
$, on) => {
|
||||
const logs = recordLogs(on)
|
||||
const rig = await bootRun($, on)
|
||||
await switchTo($, OPUS, SONNET, 'auto')
|
||||
await switchTo($, FABLE, SONNET, 'auto')
|
||||
await $.agent.spawn(spawnOf('plan-challenger'))
|
||||
await $.agent.spawn(spawnOf('plan-challenger'))
|
||||
expect(rig.specs).toEqual([undefined, undefined])
|
||||
const down = logs.filter(l => l.includes('frontmatter model kept'))
|
||||
expect(down).toEqual([
|
||||
'model-router: plan-challenger tier big down, frontmatter model kept',
|
||||
])
|
||||
})
|
||||
|
||||
test('agent row: explicit model, explicit effort and fork win', async (
|
||||
$, on) => {
|
||||
const rig = await bootRun($, on)
|
||||
await $.agent.spawn(spawnOf('feater', { model: 'opus' }))
|
||||
await $.agent.spawn(spawnOf('feater', { fork: true }))
|
||||
expect(rig.specs).toEqual(['opus', undefined])
|
||||
await $.tool.call({
|
||||
tool: 'Agent',
|
||||
description: 'd',
|
||||
prompt: 'p',
|
||||
effort: 'low',
|
||||
tool_use_id: 't1',
|
||||
})
|
||||
await $.agent.spawn(spawnOf('feater'))
|
||||
await runStep($, { ...highStep('a1'), model: SONNET })
|
||||
expect(rig.seen[rig.seen.length - 1]?.effort).toBe('high')
|
||||
})
|
||||
|
||||
test('agent row: a project definition keeps its own model', async ($, on) => {
|
||||
const rig = await bootRun($, on)
|
||||
await offerOf($, 'verifier', 'projectSettings')
|
||||
await offerOf($, 'feater', 'userSettings')
|
||||
await $.agent.spawn(spawnOf('verifier'))
|
||||
await $.agent.spawn(spawnOf('feater'))
|
||||
expect(rig.specs).toEqual([undefined, SONNET])
|
||||
})
|
||||
|
||||
test('agent row: an override null drops the row', async ($, on) => {
|
||||
on('env.get', () => ({ value: '/home/t' }))
|
||||
on('fs.exists', () => ({ value: true }))
|
||||
on('fs.stat', () => ({
|
||||
value: { kind: 'file' as const, size: 40, mtimeMs: 0, isLink: false },
|
||||
}))
|
||||
on('fs.read', () => ({ value: '{"agents":{"verifier":null}}' }))
|
||||
const rig = await bootRun($, on)
|
||||
await $.agent.spawn(spawnOf('verifier'))
|
||||
await $.agent.spawn(spawnOf('feater'))
|
||||
expect(rig.specs).toEqual([undefined, SONNET])
|
||||
})
|
||||
|
||||
test('show lists the write and apply phases', async ($, on) => {
|
||||
await bootRun($, on)
|
||||
const text = await route($, 'show')
|
||||
expect(text).toContain('apply=work→claude-sonnet-5-5/low')
|
||||
expect(text).toContain('write=work→claude-sonnet-5-5/high')
|
||||
})
|
||||
|
||||
// ---- wave 2: typed-slash origins, spawning counter, spawn exclusions ----
|
||||
|
||||
/** A promise the test settles by hand, to hold a hook in flight. */
|
||||
function deferred() {
|
||||
let release: () => void = () => {}
|
||||
const gate = new Promise<void>(r => { release = r })
|
||||
return { gate, release }
|
||||
}
|
||||
|
||||
test('typed slash: an in-flight spawn refuses the fallback', async (
|
||||
$, on) => {
|
||||
const hold = deferred()
|
||||
const entered = deferred()
|
||||
on('prompt.submit', ($, e) => ({ text: e.text }))
|
||||
on('agent.spawn', async ($, e) => {
|
||||
entered.release()
|
||||
await hold.gate
|
||||
return { model: e.model ?? e.parentModel, agentId: 'a1' }
|
||||
})
|
||||
await boot($, on)
|
||||
await typed($, 'hello')
|
||||
const spawned = $.agent.spawn(spawnOf('feater'))
|
||||
await entered.gate
|
||||
await skillPrompt($, 'feat')
|
||||
expect(mainLine(await route($, 'show'))).toContain('session defaults')
|
||||
hold.release()
|
||||
await spawned
|
||||
})
|
||||
|
||||
test('typed slash: a bridge origin arms the marker', async ($, on) => {
|
||||
await bootRun($, on)
|
||||
await $.prompt.submit({
|
||||
text: '/feat x',
|
||||
wait: false,
|
||||
origin: { kind: 'bridge' },
|
||||
})
|
||||
await $.agent.spawn(spawnOf('feater'))
|
||||
await skillPrompt($, 'feat')
|
||||
expect(mainLine(await route($, 'show'))).toContain('skill reflect')
|
||||
})
|
||||
|
||||
test('typed slash: a promoted mid-turn marker routes past a live loop', async (
|
||||
$, on) => {
|
||||
await bootRun($, on)
|
||||
await $.agent.spawn(spawnOf('feater'))
|
||||
await $.prompt.submit({
|
||||
text: '/status',
|
||||
wait: true,
|
||||
origin: { kind: 'composer' },
|
||||
turnId: 'u1',
|
||||
})
|
||||
await endTurn($)
|
||||
await skillPrompt($, 'status')
|
||||
expect(mainLine(await route($, 'show'))).toContain('skill mechanical')
|
||||
})
|
||||
|
||||
test('agent row: any provider plugin applies, a local definition not', async (
|
||||
$, on) => {
|
||||
const rig = await bootRun($, on)
|
||||
const provider = { plugin: 'some-plugin', tier: 'core' as const }
|
||||
await $.agent.spawn(spawnOf('feater', { provider }))
|
||||
await offerOf($, 'verifier', 'localSettings')
|
||||
await $.agent.spawn(spawnOf('verifier', { provider }))
|
||||
expect(rig.specs).toEqual([SONNET, undefined])
|
||||
})
|
||||
|
||||
test('agent row: a workflow spawn is untouched, model and effort', async (
|
||||
$, on) => {
|
||||
const rig = await bootRun($, on)
|
||||
await $.agent.spawn(spawnOf('feater', { workflow: {} }))
|
||||
expect(rig.specs).toEqual([undefined])
|
||||
await runStep($, { ...highStep('a1'), model: SONNET })
|
||||
expect(rig.seen[rig.seen.length - 1]?.effort).toBe('high')
|
||||
})
|
||||
|
||||
test('typed slash: the marker binds to its skill name only', async (
|
||||
$, on) => {
|
||||
await bootRun($, on)
|
||||
await typed($, '/status')
|
||||
await $.agent.spawn(spawnOf('feater'))
|
||||
await skillPrompt($, 'feat')
|
||||
expect(mainLine(await route($, 'show'))).toContain('session defaults')
|
||||
})
|
||||
|
||||
test('typed slash: the marker is one-shot', async ($, on) => {
|
||||
await bootRun($, on)
|
||||
await typed($, '/status')
|
||||
await $.agent.spawn(spawnOf('feater'))
|
||||
await skillPrompt($, 'status')
|
||||
expect(mainLine(await route($, 'show'))).toContain('skill mechanical')
|
||||
await route($, 'clear')
|
||||
await skillPrompt($, 'status')
|
||||
expect(mainLine(await route($, 'show'))).toContain('session defaults')
|
||||
})
|
||||
|
||||
test('skill.prompt nested in a Skill call leaves the run slot', async (
|
||||
$, on) => {
|
||||
on('tool.call', { tool: 'Skill' }, async (_api, e) => {
|
||||
await skillPrompt($, 'status')
|
||||
return { result: { success: true, commandName: String(e.skill) } }
|
||||
})
|
||||
await bootRig($, on, FABLE)
|
||||
await typed($, '/status')
|
||||
await loadSkill($, 'feat')
|
||||
await endTurn($)
|
||||
expect(mainLine(await route($, 'show'))).toContain('run reflect')
|
||||
})
|
||||
|
||||
/** Submits a prompt from `kind` over a running turn (mid-turn). */
|
||||
const queued = ($: Engine, kind: string, text: string) =>
|
||||
$.prompt.submit({
|
||||
text,
|
||||
wait: true,
|
||||
origin: { kind },
|
||||
turnId: 'u1',
|
||||
})
|
||||
|
||||
test('idle fallback: a mid-turn foreign prompt never arms it', async (
|
||||
$, on) => {
|
||||
await bootRun($, on)
|
||||
await queued($, 'channel', 'hello')
|
||||
await endTurn($)
|
||||
await skillPrompt($, 'status')
|
||||
expect(mainLine(await route($, 'show'))).toContain('session defaults')
|
||||
})
|
||||
|
||||
test('idle fallback: a mid-turn composer prompt promotes the allowance', async (
|
||||
$, on) => {
|
||||
await bootRun($, on)
|
||||
await $.prompt.submit({
|
||||
text: 'hello',
|
||||
wait: false,
|
||||
origin: { kind: 'channel' },
|
||||
})
|
||||
await queued($, 'composer', 'hello')
|
||||
await endTurn($)
|
||||
await skillPrompt($, 'status')
|
||||
expect(mainLine(await route($, 'show'))).toContain('skill mechanical')
|
||||
})
|
||||
|
||||
test('spawning: a finished spawn no longer counts as a live loop', async (
|
||||
$, on) => {
|
||||
await bootRun($, on)
|
||||
await $.agent.spawn(spawnOf('feater'))
|
||||
await agentEnds($)
|
||||
await typed($, 'hello')
|
||||
await skillPrompt($, 'status')
|
||||
expect(mainLine(await route($, 'show'))).toContain('skill mechanical')
|
||||
})
|
||||
|
||||
+312
-110
@@ -21,7 +21,7 @@ type Config = {
|
||||
models: Record<string, string> // alias -> full id
|
||||
windows: Record<string, number> // full id -> context window (tokens)
|
||||
phases: Record<string, Route>
|
||||
agents: Record<string, string> // built-in subagentType -> phase
|
||||
agents: Record<string, string> // subagentType -> phase
|
||||
skills: Record<string, string> // skill name -> phase
|
||||
prompt: PromptRule[]
|
||||
tiers: Record<string, string[]> // tier -> alias preference, best first
|
||||
@@ -35,7 +35,7 @@ type Config = {
|
||||
enabled: boolean // false: every hook passes through (per machine)
|
||||
}
|
||||
type Rule = { re: RegExp; phase: string; mode: PromptMode }
|
||||
type Source = 'user' | 'model' | 'skill' | 'prompt' | 'slash' | 'derived'
|
||||
type Source = 'user' | 'model' | 'skill' | 'prompt' | 'run' | 'derived'
|
||||
type Routed = { phase: string; route: Route; source: Source }
|
||||
type Hold = { until: number; reason: string } // until: ms, Infinity = reload
|
||||
type Pushed = { prev: Routed | null; spawnIds: Set<string> }
|
||||
@@ -50,10 +50,17 @@ type State = {
|
||||
userMain: Routed | null // /route by the user, sticky until /route clear
|
||||
turnMain: Routed | null // model route tool, skill table row, Skill(effort-*)
|
||||
// bridge, prompt default rule, derived orchestrate; dropped at turn end
|
||||
turnFloor: Routed | null // user-explicit level for this turn (prompt rule,
|
||||
// typed /effort-<l>): a floor, main loop only
|
||||
runMain: Routed | null // best-tier skill row: spans the turns of a run;
|
||||
// only a skill, /route clear|off or a user /model write or drop it
|
||||
turnFloor: Routed | null // user-explicit level for this turn (prompt rule):
|
||||
// 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>
|
||||
typedSlash: string | null // the rowed skill the user typed at prompt.submit
|
||||
pendingSlash: string | null // same, typed mid-turn: promoted at turn end
|
||||
promptAllowed: boolean // this turn's prompt came from a typing origin
|
||||
pendingAllowed: boolean // same, for the mid-turn prompt
|
||||
spawning: number // agent.spawn hooks in flight (preloads fire inside)
|
||||
offers: Map<string, string> // subagentType -> definition source
|
||||
loops: Map<string, Loop> // agentId -> that loop's routing
|
||||
explicitEffort: Map<string, Level> // Agent tool_use_id -> effort param
|
||||
skillCalls: number // Skill tool calls in flight
|
||||
@@ -89,6 +96,19 @@ const LEVELS: readonly Level[] = ['low', 'medium', 'high', 'xhigh', 'max']
|
||||
const MODEL_ID = /^claude-[a-z0-9.-]+$/
|
||||
const TOOL = 'mcp__model-router__route'
|
||||
const EFFORT_SKILL = /^effort-(low|medium|high|xhigh|max)$/
|
||||
const BEST = 'best' // the tier whose skill rows hold for a whole run
|
||||
// Origins that are a person typing: only these arm a typed slash.
|
||||
const TYPED_ORIGINS: ReadonlySet<string> = new Set([
|
||||
'composer', 'sdk', 'bridge',
|
||||
])
|
||||
// Definition sources of a foreign repo's own agent: its row is skipped.
|
||||
const PROJECT_SOURCES: ReadonlySet<string> = new Set([
|
||||
'projectSettings', 'localSettings',
|
||||
])
|
||||
// PostModelSwitch sources a person chose (auto and resume are not).
|
||||
const USER_SWITCH: ReadonlySet<string> = new Set([
|
||||
'command', 'picker', 'sdk',
|
||||
])
|
||||
const OVERRIDE = '.claude/model-router.json'
|
||||
const HAIKU = 'claude-haiku'
|
||||
const MAX_PATTERN = 200 // chars of a prompt-rule pattern
|
||||
@@ -132,14 +152,69 @@ const DEFAULT_CONFIG: Config = {
|
||||
escalate: { tier: 'best', effort: 'max' },
|
||||
judge: { tier: 'big', effort: 'xhigh' },
|
||||
implement: { tier: 'work', effort: 'medium' },
|
||||
write: { tier: 'work', effort: 'medium' },
|
||||
write: { tier: 'work', effort: 'high' },
|
||||
verify: { tier: 'work', effort: 'xhigh' },
|
||||
explore: { tier: 'work', effort: 'medium' },
|
||||
apply: { tier: 'work', effort: 'low' },
|
||||
mechanical: { tier: 'cheap', effort: 'low' },
|
||||
},
|
||||
// Built-ins only: repo agents keep their frontmatter pin (wave 2).
|
||||
agents: { Explore: 'explore', Plan: 'judge' },
|
||||
skills: {},
|
||||
// phase = role; one row per routed repo skill/agent (wave 2); a
|
||||
// project-level agent of the same name shadows its row (see spawnRoute).
|
||||
agents: {
|
||||
// explore
|
||||
Explore: 'explore',
|
||||
// judge
|
||||
Plan: 'judge', 'plan-challenger': 'judge', 'plugin-advisor': 'judge',
|
||||
'seo-analyzer': 'judge', 'geo-analyzer': 'judge', analyzer: 'judge',
|
||||
// implement
|
||||
feater: 'implement', bugfixer: 'implement', 'code-cleaner': 'implement',
|
||||
scaffolder: 'implement', onboarder: 'implement',
|
||||
// write
|
||||
'commit-changer': 'write', 'doc-syncer': 'write',
|
||||
'handover-doc-writer': 'write', refactorer: 'write',
|
||||
// apply
|
||||
hotfixer: 'apply', 'release-executor': 'apply', 'plugin-probe': 'apply',
|
||||
'validator-analyzer': 'apply',
|
||||
// verify
|
||||
verifier: 'verify', 'security-auditor': 'verify',
|
||||
// mechanical
|
||||
'status-reporter': 'mechanical',
|
||||
},
|
||||
skills: {
|
||||
// plan
|
||||
'ship-feature': 'plan', 'init-project': 'plan', onboard: 'plan',
|
||||
tour: 'plan', 'audit-delta': 'plan', analyze: 'plan',
|
||||
'code-clean': 'plan', 'client-handover': 'plan', brainstorming: 'plan',
|
||||
'writing-plans': 'plan', 'requesting-code-review': 'plan',
|
||||
'21st-ui-review': 'plan',
|
||||
// reflect
|
||||
feat: 'reflect', hotfix: 'reflect', bugfix: 'reflect',
|
||||
refactor: 'reflect', 'web-validate': 'reflect', harden: 'reflect',
|
||||
seo: 'reflect', geo: 'reflect', 'site-motion': 'reflect',
|
||||
'frontend-design': 'reflect', 'emil-design-eng': 'reflect',
|
||||
'design-motion-principles': 'reflect', '21st-ui-build': 'reflect',
|
||||
'scroll-world-storytelling': 'reflect',
|
||||
'build-threejs-scroll-worlds': 'reflect',
|
||||
'scroll-scrubbed-visual-sequence': 'reflect',
|
||||
'scroll-scrubbed-word-reveal': 'reflect',
|
||||
'scroll-progress-timeline': 'reflect',
|
||||
'subagent-driven-development': 'reflect', 'writing-skills': 'reflect',
|
||||
'deprecation-and-migration': 'reflect', '21st-ai': 'reflect',
|
||||
'21st-ui-explore': 'reflect',
|
||||
// implement
|
||||
gitflow: 'implement', 'prune-memory': 'implement',
|
||||
'pdf-translate': 'implement', 'ci-cd-and-automation': 'implement',
|
||||
'observability-and-instrumentation': 'implement',
|
||||
'test-driven-development': 'implement',
|
||||
// apply
|
||||
'commit-change': 'apply', 'release-candidate': 'apply', doc: 'apply',
|
||||
capitalize: 'apply', close: 'apply', reconcile: 'apply', deploy: 'apply',
|
||||
// mechanical
|
||||
status: 'mechanical', profile: 'mechanical', 'plugin-check': 'mechanical',
|
||||
'skills-perso': 'mechanical', 'using-git-worktrees': 'mechanical',
|
||||
'21st-cli-use': 'mechanical', '21st-registry': 'mechanical',
|
||||
'21st-design-sync': 'mechanical',
|
||||
},
|
||||
// `floor` rules set the turn's minimum effort; `default` rules set the
|
||||
// turn's route, which a route call or a skill overrides. Neither lowers.
|
||||
prompt: [
|
||||
@@ -386,6 +461,25 @@ function mergeRouting(
|
||||
}
|
||||
}
|
||||
|
||||
/** A row table: `name: null` in the override drops a default row. */
|
||||
function mergeRows(
|
||||
base: Record<string, string>,
|
||||
user: unknown,
|
||||
name: string,
|
||||
ref: (key: string, value: unknown) => string | undefined,
|
||||
log: Log,
|
||||
): Record<string, string> {
|
||||
const entries = isRecord(user) ? Object.entries(user) : []
|
||||
const kept = isRecord(user)
|
||||
? Object.fromEntries(entries.filter(([, v]) => v !== null))
|
||||
: user
|
||||
const rows = mergeTable(base, kept, name, ref, log)
|
||||
for (const [key, value] of entries) {
|
||||
if (value === null && key !== '__proto__') delete rows[key]
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
/** Defaults overlaid with the user's entries, each validated first. */
|
||||
function mergeConfig(user: unknown, log: Log): Config {
|
||||
const base = structuredClone(DEFAULT_CONFIG)
|
||||
@@ -405,8 +499,8 @@ function mergeConfig(user: unknown, log: Log): Config {
|
||||
windows: mergeTable(
|
||||
base.windows, user.windows, 'windows', acceptWindow, log),
|
||||
phases,
|
||||
agents: mergeTable(base.agents, user.agents, 'agents', ref, log),
|
||||
skills: mergeTable(base.skills, user.skills, 'skills', ref, log),
|
||||
agents: mergeRows(base.agents, user.agents, 'agents', ref, log),
|
||||
skills: mergeRows(base.skills, user.skills, 'skills', ref, log),
|
||||
prompt: mergePrompt(base.prompt, user.prompt, phases, log),
|
||||
mainModelSwitch: pickBool(user.mainModelSwitch, base.mainModelSwitch),
|
||||
verbose: pickBool(user.verbose, base.verbose),
|
||||
@@ -487,9 +581,15 @@ function newState(cfg: Config, source: string): State {
|
||||
source,
|
||||
userMain: null,
|
||||
turnMain: null,
|
||||
runMain: null,
|
||||
turnFloor: null,
|
||||
pendingPrompt: null,
|
||||
typedSlash: false,
|
||||
typedSlash: null,
|
||||
pendingSlash: null,
|
||||
promptAllowed: false,
|
||||
pendingAllowed: false,
|
||||
spawning: 0,
|
||||
offers: new Map(),
|
||||
loops: new Map(),
|
||||
explicitEffort: new Map(),
|
||||
skillCalls: 0,
|
||||
@@ -511,7 +611,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) and the model.
|
||||
* the breaker (a model's quota outlives the conversation), the model and
|
||||
* the agent definitions' sources (the listing is not offered again).
|
||||
*/
|
||||
function resetSession(st: State): void {
|
||||
const kept = {
|
||||
@@ -519,6 +620,7 @@ function resetSession(st: State): void {
|
||||
strikes: st.strikes,
|
||||
agentModels: st.agentModels,
|
||||
sessionModel: st.sessionModel,
|
||||
offers: st.offers,
|
||||
}
|
||||
Object.assign(st, newState(st.cfg, st.source), kept)
|
||||
}
|
||||
@@ -788,10 +890,11 @@ function decideMain(
|
||||
: downgradeCall(st, cur, wanted, tokens)
|
||||
}
|
||||
|
||||
/** Model axis: sticky, then turn route, then the floor's own model. */
|
||||
/** Model axis: sticky, turn route, run slot, then the floor's own model. */
|
||||
const mainModel = (st: State): string | undefined =>
|
||||
routeName(st.userMain?.route) ??
|
||||
routeName(st.turnMain?.route) ??
|
||||
routeName(st.runMain?.route) ??
|
||||
routeName(st.turnFloor?.route)
|
||||
|
||||
async function readTokens($: Api): Promise<number | undefined> {
|
||||
@@ -827,8 +930,9 @@ function logCall($: Api, st: State, call: Call): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** The main loop's effective route: user /route > latest turn route. */
|
||||
const mainRoute = (st: State): Routed | null => st.userMain ?? st.turnMain
|
||||
/** The main loop's effective route: user /route > turn route > run slot. */
|
||||
const mainRoute = (st: State): Routed | null =>
|
||||
st.userMain ?? st.turnMain ?? st.runMain
|
||||
|
||||
const rank = (l: Level | undefined): number =>
|
||||
l === undefined ? -1 : LEVELS.indexOf(l)
|
||||
@@ -851,7 +955,8 @@ function higherFloor(cur: Routed | null, next: Routed): Routed {
|
||||
*/
|
||||
function mainEffort(st: State, engine: Effort): Decision {
|
||||
const sticky = st.userMain?.route.effort
|
||||
const named = sticky ?? st.turnMain?.route.effort
|
||||
const named = sticky ?? st.turnMain?.route.effort ??
|
||||
st.runMain?.route.effort
|
||||
const floor = st.turnFloor?.route.effort
|
||||
const base = named ?? floor ?? engine
|
||||
const effort = floored(base, floor)
|
||||
@@ -862,11 +967,8 @@ function mainEffort(st: State, engine: Effort): Decision {
|
||||
return { effort, by: sticky === undefined ? 'turn' : 'sticky' }
|
||||
}
|
||||
|
||||
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)})`
|
||||
`user floor ${f.route.effort ?? '-'} (prompt rule ${f.phase})`
|
||||
|
||||
/**
|
||||
* Why main will not run at `asked`, or '' when it will. Truthful tail of
|
||||
@@ -903,6 +1005,7 @@ function writeLoop(loop: Loop, route: Route): void {
|
||||
function clearRoutes(st: State): void {
|
||||
st.userMain = null
|
||||
st.turnMain = null
|
||||
st.runMain = null
|
||||
st.turnFloor = null
|
||||
st.pendingPrompt = null
|
||||
}
|
||||
@@ -1122,14 +1225,18 @@ async function handleCommand($: Api, st: State, args: string): Promise<string> {
|
||||
case '':
|
||||
case 'show':
|
||||
return show($, st)
|
||||
case 'clear':
|
||||
case 'clear': {
|
||||
const run = st.runMain !== null
|
||||
clearRoutes(st)
|
||||
refresh($, st)
|
||||
return 'route cleared\n' + (await show($, st))
|
||||
return 'route cleared' + (run ? '; run slot dropped' : '') +
|
||||
'\n' + (await show($, st))
|
||||
}
|
||||
case 'on':
|
||||
case 'off':
|
||||
st.off = head === 'off'
|
||||
st.offConfig = false
|
||||
if (st.off) st.runMain = null
|
||||
refresh($, st)
|
||||
return show($, st)
|
||||
case 'reload':
|
||||
@@ -1218,20 +1325,28 @@ function clearLoop(st: State, agentId: string | undefined): string {
|
||||
const held = f && f.route.effort !== undefined
|
||||
? `; ${floorWord(f)} still holds, /route clear drops it`
|
||||
: ''
|
||||
return 'route cleared for main' + held
|
||||
const run = st.runMain
|
||||
? `; run ${st.runMain.phase} still holds`
|
||||
: ''
|
||||
return 'route cleared for main' + held + run
|
||||
}
|
||||
const loop = st.loops.get(agentId)
|
||||
if (loop) writeLoop(loop, {})
|
||||
return 'route cleared for this agent'
|
||||
}
|
||||
|
||||
/** What the main loop's model will do, in the words of the decision. */
|
||||
/** The id the next main step runs on, with the decision's reason. */
|
||||
async function mainAnswer($: Api, st: State): Promise<string> {
|
||||
const { call, wanted } = await snapshot($, st)
|
||||
if (call.moved) return `${idWord(st, call.model)} (${call.why})`
|
||||
const quiet = call.why === 'unchanged' ||
|
||||
(wanted === undefined && !isFallback(call))
|
||||
return quiet ? 'unchanged' : `unchanged (${call.why})`
|
||||
const { call } = await snapshot($, st)
|
||||
return `${idWord(st, call.model)} (${call.why})`
|
||||
}
|
||||
|
||||
/** Main's answer: effort and model asked, the sticky/floor note appended. */
|
||||
async function mainRoutedText($: Api, st: State, p: Picked) {
|
||||
const base = `routed main to ${p.phase}: effort ${
|
||||
p.route.effort ?? 'unchanged'}, model ${await mainAnswer($, st)}`
|
||||
const note = mainNote(st, p.route.effort)
|
||||
return note ? `${base}; but ${note}` : base
|
||||
}
|
||||
|
||||
/** Truthful answer: states what the calling loop will actually do. */
|
||||
@@ -1241,15 +1356,11 @@ async function routedText(
|
||||
agentId: string | undefined,
|
||||
p: Picked,
|
||||
): Promise<string> {
|
||||
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
|
||||
? await mainAnswer($, st)
|
||||
: routeName(p.route) ? 'unchanged (fixed at spawn)' : 'unchanged'
|
||||
return `routed ${agentId === undefined ? 'main' : 'this agent'} to ` +
|
||||
`${p.phase}: effort ${effort ?? 'unchanged'}, model ${model}`
|
||||
if (agentId === undefined) return mainRoutedText($, st, p)
|
||||
const explicit = st.loops.get(agentId)?.explicitEffort
|
||||
const model = routeName(p.route) ? 'unchanged (fixed at spawn)' : 'unchanged'
|
||||
return `routed this agent to ${p.phase}: effort ${
|
||||
explicit ? 'unchanged' : p.route.effort ?? 'unchanged'}, model ${model}`
|
||||
}
|
||||
|
||||
async function handleRouteTool($: Api, st: State, e: RouteInput) {
|
||||
@@ -1295,51 +1406,59 @@ function effortBridge(st: State, agentId: string | undefined, skill: string,
|
||||
`loop from the next request on; the ${skill} skill text was not loaded.`)
|
||||
}
|
||||
|
||||
/** A non-effort skill load: resets the loop's route, applies its table row. */
|
||||
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) {
|
||||
st.turnMain = null
|
||||
if (table !== undefined && route) {
|
||||
st.turnMain = { phase: table, route, source: 'skill' }
|
||||
}
|
||||
return
|
||||
}
|
||||
const loop = route ? loopOf(st, agentId) : st.loops.get(agentId)
|
||||
if (loop) writeLoop(loop, { effort: route?.effort })
|
||||
}
|
||||
|
||||
/** A user-typed /effort-<l>: 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
|
||||
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 }
|
||||
/** A skill's table row: its phase and that phase's route, if both exist. */
|
||||
function skillRow(st: State, skill: string): Picked | undefined {
|
||||
const phase = hasKey(st.cfg.skills, skill) ? st.cfg.skills[skill] : undefined
|
||||
const route = phase === undefined ? undefined : phaseRoute(st.cfg, phase)
|
||||
return phase !== undefined && route ? { phase, route } : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* A rowed skill on main sets the turn route; a best-tier row also holds the
|
||||
* run slot. A typed non-best row drops the run; one the model loads (a
|
||||
* helper skill inside a run) leaves it.
|
||||
*/
|
||||
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,
|
||||
function routeMainBySkill(st: State, row: Picked, typed: boolean): void {
|
||||
const turn: Routed = { phase: row.phase, route: { ...row.route },
|
||||
source: 'skill' }
|
||||
st.turnMain = turn
|
||||
if (row.route.tier === BEST) st.runMain = { ...turn, source: 'run' }
|
||||
else if (typed) st.runMain = null
|
||||
}
|
||||
|
||||
/** A model-loaded skill: its row applies; an unrowed one changes nothing. */
|
||||
function onSkillLoad(st: State, skill: string, agentId: string | undefined) {
|
||||
const row = skillRow(st, skill)
|
||||
if (agentId === undefined) {
|
||||
if (row) routeMainBySkill(st, row, false)
|
||||
return
|
||||
}
|
||||
if (row) writeLoop(loopOf(st, agentId), row.route)
|
||||
}
|
||||
|
||||
type TypedPath = 'typed-marker' | 'typed-fallback'
|
||||
|
||||
/**
|
||||
* Whether a skill.prompt on main is the user's own typing. The marker from
|
||||
* prompt.submit names the skill; failing that, an allowed-origin prompt with
|
||||
* no live or spawning sub-agent (a preload fires inside one) still counts.
|
||||
*/
|
||||
function typedPath(st: State, skill: string): TypedPath | undefined {
|
||||
if (st.typedSlash === skill) {
|
||||
st.typedSlash = null
|
||||
return 'typed-marker'
|
||||
}
|
||||
return slashEffort(st, skill, text)
|
||||
const idle = st.loops.size === 0 && st.spawning === 0
|
||||
return st.promptAllowed && idle ? 'typed-fallback' : undefined
|
||||
}
|
||||
|
||||
function applyTypedSkill($: Api, st: State, skill: string): void {
|
||||
const row = skillRow(st, skill)
|
||||
const path = typedPath(st, skill)
|
||||
if (row === undefined || path === undefined) return
|
||||
routeMainBySkill(st, row, true)
|
||||
refresh($, st)
|
||||
vlog($, st, `skill ${skill}: ${path} → ${row.phase}`)
|
||||
}
|
||||
|
||||
// ---- agents ----------------------------------------------------------
|
||||
@@ -1347,27 +1466,60 @@ function guardedSlash(st: State, skill: string, text: string) {
|
||||
type SpawnIn = {
|
||||
tool_use_id: string
|
||||
subagentType: string
|
||||
provider: { plugin: string }
|
||||
model?: string
|
||||
fork: boolean
|
||||
workflow?: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* The table row of a built-in agent. Known limit: provider.plugin ===
|
||||
* 'engine' is the best built-in test at spawn; a user agent named Explore
|
||||
* in a foreign project also matches (wave 1: sonnet/medium on it).
|
||||
* The table row of an agent, for every provider. Skipped for a fork, a
|
||||
* workflow agent, and an agent whose definition came from the project (a
|
||||
* foreign repo's own verifier.md). Known limit: the source is recorded by
|
||||
* name only, so an offer fired inside a sub-agent with another cwd
|
||||
* overwrites it; no record = the row applies (fail-open on routing).
|
||||
*/
|
||||
function spawnRoute(cfg: Config, e: SpawnIn, frozen: boolean) {
|
||||
if (frozen || e.provider.plugin !== 'engine') return undefined
|
||||
if (!hasKey(cfg.agents, e.subagentType)) return undefined
|
||||
const phase = cfg.agents[e.subagentType]
|
||||
return phase === undefined ? undefined : phaseRoute(cfg, phase)
|
||||
function spawnRoute(st: State, e: SpawnIn, frozen: boolean) {
|
||||
if (frozen || !hasKey(st.cfg.agents, e.subagentType)) return undefined
|
||||
if (PROJECT_SOURCES.has(st.offers.get(e.subagentType) ?? '')) {
|
||||
return undefined
|
||||
}
|
||||
const phase = st.cfg.agents[e.subagentType]
|
||||
return phase === undefined ? undefined : phaseRoute(st.cfg, phase)
|
||||
}
|
||||
|
||||
/** Once per turn: a spawn whose tier has nothing at or above its head. */
|
||||
function tierDownLog($: Api, st: State, agent: string, tier: string): void {
|
||||
const text = `model-router: ${agent} tier ${tier} down, ` +
|
||||
'frontmatter model kept'
|
||||
const key = `spawn-down:${agent}:${tier}`
|
||||
if (st.turnLogged.has(key)) return
|
||||
st.turnLogged.add(key)
|
||||
$.ui.log(text)
|
||||
}
|
||||
|
||||
/**
|
||||
* The first model of the route's tier that is not down, only when it ranks
|
||||
* at or above the tier's head: an agent moves UP from its frontmatter alias,
|
||||
* never below. Nothing qualifies: no write, the frontmatter model runs.
|
||||
*/
|
||||
function inTier($: Api, st: State, agent: string, tier: string) {
|
||||
const aliases = hasKey(st.cfg.tiers, tier) ? st.cfg.tiers[tier] : undefined
|
||||
const head = aliases?.[0]
|
||||
const headId = head === undefined ? undefined : st.cfg.models[head]
|
||||
if (aliases === undefined || headId === undefined) return undefined
|
||||
const id = availableIn(st, aliases)
|
||||
if (id !== undefined && (id === headId || ranksAbove(st, id, headId))) {
|
||||
return id
|
||||
}
|
||||
tierDownLog($, st, agent, tier)
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** The model a spawn is rewritten to; an explicit `model` param wins. */
|
||||
function spawnTarget(st: State, e: SpawnIn, route: Route | undefined) {
|
||||
return e.model === undefined ? resolveRoute(st, route) : undefined
|
||||
function spawnTarget($: Api, st: State, e: SpawnIn, route: Route | undefined) {
|
||||
if (e.model !== undefined || route === undefined) return undefined
|
||||
if (route.tier === undefined) return resolveRoute(st, route)
|
||||
return inTier($, st, e.subagentType, route.tier)
|
||||
}
|
||||
|
||||
function trackLoop(st: State, e: SpawnIn, started: {
|
||||
@@ -1493,7 +1645,10 @@ function endMainTurn($: Api, st: State): void {
|
||||
st.turnMain = null
|
||||
st.pushed = null
|
||||
st.turnModel = undefined
|
||||
st.typedSlash = false
|
||||
st.typedSlash = st.pendingSlash
|
||||
st.promptAllowed = st.pendingAllowed
|
||||
st.pendingSlash = null
|
||||
st.pendingAllowed = false
|
||||
st.explicitEffort.clear()
|
||||
st.spinner = ''
|
||||
st.turnLogged.clear()
|
||||
@@ -1554,6 +1709,7 @@ async function onAutoSwitch($: Api, st: State, e: SwitchIn): Promise<void> {
|
||||
/** Any model change: keeps `sessionModel`; a user choice clears its mark. */
|
||||
async function onModelSwitch($: Api, st: State, e: SwitchIn): Promise<void> {
|
||||
st.sessionModel = e.to_model
|
||||
if (USER_SWITCH.has(e.source)) st.runMain = null
|
||||
if (st.off || e.source === 'resume') return
|
||||
if (e.source === 'auto') await onAutoSwitch($, st, e)
|
||||
else clearBreaker(st, canonical(st.cfg, e.to_model))
|
||||
@@ -1652,10 +1808,8 @@ function registerSkills(on: On, st: State): void {
|
||||
})
|
||||
on('skill.prompt', async ($, e, next) => {
|
||||
if (st.off || st.skillCalls > 0) return next(e)
|
||||
const out = guardedSlash(st, e.skill, e.text)
|
||||
if (!out) return next(e)
|
||||
refresh($, st)
|
||||
return out
|
||||
safely(st, $, 'skill.prompt', () => applyTypedSkill($, st, e.skill))
|
||||
return next(e)
|
||||
}).catch(($, e, next) => {
|
||||
warnOnce(st, $, 'skill.prompt', next.error.kind)
|
||||
return next(e)
|
||||
@@ -1679,26 +1833,47 @@ function registerAgents(on: On, st: State): void {
|
||||
})
|
||||
}
|
||||
|
||||
function registerSpawn(on: On, st: State): void {
|
||||
on('agent.spawn', async ($, e, next) => {
|
||||
if (st.off) return next(e)
|
||||
await prune($, st)
|
||||
const frozen = e.fork || e.workflow !== undefined
|
||||
const route = spawnRoute(st.cfg, e, frozen)
|
||||
const wanted = spawnTarget(st, e, route)
|
||||
const started = await next(wanted === undefined
|
||||
? e
|
||||
: { ...e, model: wanted })
|
||||
if (typeof started.agentId === 'string') {
|
||||
/** Bookkeeping once the agent started: its loop, its model, a log line. */
|
||||
function afterSpawn(
|
||||
$: Api,
|
||||
st: State,
|
||||
e: SpawnIn,
|
||||
started: { agentId?: unknown; model: string },
|
||||
route: Route | undefined,
|
||||
): void {
|
||||
if (typeof started.agentId !== 'string') return
|
||||
const agentId = started.agentId
|
||||
safely(st, $, 'agent.spawn', () => {
|
||||
trackLoop(st, e, started, route)
|
||||
trackLoop(st, e, { agentId }, route)
|
||||
rememberAgent(st, agentId, started.model)
|
||||
vlog($, st,
|
||||
`spawn ${e.subagentType}: ${e.model ?? '-'} → ${started.model}`)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function registerSpawn(on: On, st: State): void {
|
||||
on('agent.offer', async ($, e, next) => {
|
||||
if (!st.off) st.offers.set(e.agent, e.source)
|
||||
return next(e)
|
||||
}).catch(($, e, next) => {
|
||||
warnOnce(st, $, 'agent.offer', next.error.kind)
|
||||
return next(e)
|
||||
})
|
||||
on('agent.spawn', async ($, e, next) => {
|
||||
if (st.off) return next(e)
|
||||
st.spawning += 1
|
||||
try {
|
||||
await prune($, st)
|
||||
const route = spawnRoute(st, e, e.fork || e.workflow !== undefined)
|
||||
const wanted = spawnTarget($, st, e, route)
|
||||
const started = await next(wanted === undefined
|
||||
? e
|
||||
: { ...e, model: wanted })
|
||||
afterSpawn($, st, e, started, route)
|
||||
return started
|
||||
} finally {
|
||||
st.spawning = Math.max(0, st.spawning - 1)
|
||||
}
|
||||
}).catch(($, e, next) => {
|
||||
warnOnce(st, $, 'agent.spawn', next.error.kind)
|
||||
return next(e)
|
||||
@@ -1767,14 +1942,41 @@ function routeFromPrompt(st: State, text: string, midTurn: boolean): void {
|
||||
floorFromPrompt(st, midTurn, routed)
|
||||
}
|
||||
const slash = text.trimStart().startsWith('/')
|
||||
if (text.trimStart().startsWith('/effort-')) st.typedSlash = true
|
||||
if (!midTurn && !slash && !floor) defaultFromPrompt(st, scanned)
|
||||
}
|
||||
|
||||
/** The rowed skill a slash prompt names, else null. */
|
||||
function typedSkill(st: State, text: string): string | null {
|
||||
const typed = text.trimStart()
|
||||
if (!typed.startsWith('/')) return null
|
||||
const name = typed.slice(1).split(/\s/, 1)[0] ?? ''
|
||||
return hasKey(st.cfg.skills, name) ? name : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Records what skill.prompt needs to tell a typed slash from a preload: the
|
||||
* skill named and whether a person typed it. A mid-turn prompt waits in the
|
||||
* pending slots for the turn end; a foreign one leaves them alone. A later
|
||||
* queued prompt replaces an earlier one (a shortcut: one pending slot).
|
||||
*/
|
||||
function noteTyped(st: State, kind: string, text: string, midTurn: boolean) {
|
||||
const allowed = TYPED_ORIGINS.has(kind)
|
||||
const slash = allowed ? typedSkill(st, text) : null
|
||||
if (!midTurn) {
|
||||
st.typedSlash = slash
|
||||
st.promptAllowed = allowed
|
||||
} else if (allowed) {
|
||||
st.pendingSlash = slash
|
||||
st.pendingAllowed = true
|
||||
}
|
||||
}
|
||||
|
||||
function registerPrompt(on: On, st: State): void {
|
||||
on('prompt.submit', async ($, e, next) => {
|
||||
if (st.off || e.origin.kind !== 'composer') return next(e)
|
||||
routeFromPrompt(st, e.text, e.turnId !== undefined)
|
||||
if (st.off) return next(e)
|
||||
const midTurn = e.turnId !== undefined
|
||||
noteTyped(st, e.origin.kind, e.text, midTurn)
|
||||
if (e.origin.kind === 'composer') routeFromPrompt(st, e.text, midTurn)
|
||||
refresh($, st)
|
||||
return next(e)
|
||||
}).catch(($, e, next) => {
|
||||
|
||||
Reference in New Issue
Block a user