feat(mods): model-router hardening — user-only /route, effort-only agent routes, config caps, visible fail-open
Security-gate round on the wave 1-A mod: /route answers only a composer origin; an in-agent route call can no longer change the agent's model (effort only, model fixed at spawn); config patterns capped (200 chars, 4096-char scan), phase keys restricted, override file refused above 64 KB, additionalProperties false on the tool schema; every .catch logs once per session; post-next bookkeeping isolated. 14 plugin tests, verifier 11/11.
This commit is contained in:
@@ -200,3 +200,41 @@ test('a tabled agent steps at its table effort', async ($, on) => {
|
|||||||
await runStep($, stepInput('a1'))
|
await runStep($, stepInput('a1'))
|
||||||
expect(seen).toEqual([{ model: 'claude-fable-5-1', effort: 'medium' }])
|
expect(seen).toEqual([{ model: 'claude-fable-5-1', effort: 'medium' }])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('/route from a non-composer origin is refused, state kept', async (
|
||||||
|
$, on) => {
|
||||||
|
await boot($, on)
|
||||||
|
await route($, 'judge')
|
||||||
|
const out = await $.command.run({
|
||||||
|
command: 'route',
|
||||||
|
args: 'mechanical',
|
||||||
|
origin: { kind: 'plugin', name: 'x' },
|
||||||
|
presentation: { isFullscreen: false, columns: 80 },
|
||||||
|
})
|
||||||
|
expect(out.text).toContain('user-only')
|
||||||
|
expect(mainLine(await route($, 'show'))).toContain('user judge')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('an in-agent route sets effort only, the model stays', async ($, on) => {
|
||||||
|
const seen: Seen[] = []
|
||||||
|
recordSteps(on, seen)
|
||||||
|
await boot($, on)
|
||||||
|
await $.tool.call({
|
||||||
|
tool: 'mcp__model-router__route',
|
||||||
|
phase: 'judge',
|
||||||
|
agentId: 'a1',
|
||||||
|
})
|
||||||
|
await runStep($, { ...stepInput('a1'), model: 'claude-sonnet-5-5' })
|
||||||
|
expect(seen).toEqual([{ model: 'claude-sonnet-5-5', effort: 'xhigh' }])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a rule only scans the first 4096 chars of a prompt', async ($, on) => {
|
||||||
|
on('prompt.submit', ($, e) => ({ text: e.text }))
|
||||||
|
await boot($, on)
|
||||||
|
await $.prompt.submit({
|
||||||
|
text: 'x'.repeat(5000) + ' ultrathink',
|
||||||
|
wait: false,
|
||||||
|
origin: { kind: 'composer' },
|
||||||
|
})
|
||||||
|
expect(mainLine(await route($, 'show'))).toContain('session defaults')
|
||||||
|
})
|
||||||
|
|||||||
@@ -24,11 +24,7 @@ type Rule = { re: RegExp; phase: string }
|
|||||||
type Source = 'user' | 'model' | 'skill' | 'prompt' | 'slash'
|
type Source = 'user' | 'model' | 'skill' | 'prompt' | 'slash'
|
||||||
type Routed = { phase: string; route: Route; source: Source }
|
type Routed = { phase: string; route: Route; source: Source }
|
||||||
type Loop = {
|
type Loop = {
|
||||||
effort?: Level
|
effort?: Level // an agent's model is fixed at spawn: effort is its only axis
|
||||||
model?: string // routed by the table or an in-agent call
|
|
||||||
spawnModel: string // the engine's model at spawn
|
|
||||||
frozen: boolean // fork or workflow agent: never re-modelled
|
|
||||||
explicitModel: boolean // Agent call gave a model: axis frozen
|
|
||||||
explicitEffort: boolean // Agent call gave an effort: axis frozen
|
explicitEffort: boolean // Agent call gave an effort: axis frozen
|
||||||
}
|
}
|
||||||
type State = {
|
type State = {
|
||||||
@@ -44,6 +40,7 @@ type State = {
|
|||||||
off: boolean // /route off: every hook passes through
|
off: boolean // /route off: every hook passes through
|
||||||
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
|
||||||
}
|
}
|
||||||
type Log = (text: string) => void
|
type Log = (text: string) => void
|
||||||
type StepIn = Readonly<TurnStepInput>
|
type StepIn = Readonly<TurnStepInput>
|
||||||
@@ -62,6 +59,10 @@ const TOOL = 'mcp__model-router__route'
|
|||||||
const EFFORT_SKILL = /^effort-(low|medium|high|xhigh|max)$/
|
const EFFORT_SKILL = /^effort-(low|medium|high|xhigh|max)$/
|
||||||
const OVERRIDE = '.claude/model-router.json'
|
const OVERRIDE = '.claude/model-router.json'
|
||||||
const HAIKU = 'claude-haiku'
|
const HAIKU = 'claude-haiku'
|
||||||
|
const MAX_PATTERN = 200 // chars of a prompt-rule pattern
|
||||||
|
const MAX_PROMPT_SCAN = 4096 // chars of a prompt a rule is run against
|
||||||
|
const MAX_CONFIG_BYTES = 65536 // override file size
|
||||||
|
const PHASE_KEY = /^[a-z][a-z0-9_-]{0,31}$/
|
||||||
|
|
||||||
const DEFAULT_CONFIG: Config = {
|
const DEFAULT_CONFIG: Config = {
|
||||||
models: {
|
models: {
|
||||||
@@ -123,7 +124,12 @@ function mergeTable<T>(
|
|||||||
log: Log,
|
log: Log,
|
||||||
): Record<string, T> {
|
): Record<string, T> {
|
||||||
const out = { ...base }
|
const out = { ...base }
|
||||||
if (!isRecord(user)) return out
|
if (!isRecord(user)) {
|
||||||
|
if (user !== undefined) {
|
||||||
|
log(`model-router: config ${name} ignored: not an object`)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
for (const [key, value] of Object.entries(user)) {
|
for (const [key, value] of Object.entries(user)) {
|
||||||
const ok = key === '__proto__' ? undefined : accept(key, value)
|
const ok = key === '__proto__' ? undefined : accept(key, value)
|
||||||
if (ok === undefined) log(`model-router: config ${name}.${key} ignored`)
|
if (ok === undefined) log(`model-router: config ${name}.${key} ignored`)
|
||||||
@@ -141,8 +147,8 @@ const acceptWindow = (key: string, v: unknown): number | undefined =>
|
|||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
function acceptPhase(models: Record<string, string>) {
|
function acceptPhase(models: Record<string, string>) {
|
||||||
return (_key: string, v: unknown): Route | undefined => {
|
return (key: string, v: unknown): Route | undefined => {
|
||||||
if (!isRecord(v)) return undefined
|
if (!PHASE_KEY.test(key) || !isRecord(v)) return undefined
|
||||||
const route: Route = {}
|
const route: Route = {}
|
||||||
if (v.effort !== undefined) {
|
if (v.effort !== undefined) {
|
||||||
if (!isLevel(v.effort)) return undefined
|
if (!isLevel(v.effort)) return undefined
|
||||||
@@ -158,7 +164,9 @@ function acceptPhase(models: Record<string, string>) {
|
|||||||
|
|
||||||
function acceptPhaseRef(phases: Record<string, Route>) {
|
function acceptPhaseRef(phases: Record<string, Route>) {
|
||||||
return (_key: string, v: unknown): string | undefined =>
|
return (_key: string, v: unknown): string | undefined =>
|
||||||
typeof v === 'string' && hasKey(phases, v) ? v : undefined
|
typeof v === 'string' && PHASE_KEY.test(v) && hasKey(phases, v)
|
||||||
|
? v
|
||||||
|
: undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
function compiles(pattern: string): boolean {
|
function compiles(pattern: string): boolean {
|
||||||
@@ -174,6 +182,7 @@ function acceptRule(phases: Record<string, Route>, v: unknown) {
|
|||||||
if (!isRecord(v)) return undefined
|
if (!isRecord(v)) return undefined
|
||||||
const { pattern, phase } = v
|
const { pattern, phase } = v
|
||||||
if (typeof pattern !== 'string' || typeof phase !== 'string') return undefined
|
if (typeof pattern !== 'string' || typeof phase !== 'string') return undefined
|
||||||
|
if (pattern.length > MAX_PATTERN) return undefined
|
||||||
return hasKey(phases, phase) && compiles(pattern)
|
return hasKey(phases, phase) && compiles(pattern)
|
||||||
? { pattern, phase }
|
? { pattern, phase }
|
||||||
: undefined
|
: undefined
|
||||||
@@ -186,7 +195,12 @@ function mergePrompt(
|
|||||||
phases: Record<string, Route>,
|
phases: Record<string, Route>,
|
||||||
log: Log,
|
log: Log,
|
||||||
): PromptRule[] {
|
): PromptRule[] {
|
||||||
if (!Array.isArray(user)) return base
|
if (!Array.isArray(user)) {
|
||||||
|
if (user !== undefined) {
|
||||||
|
log('model-router: config prompt ignored: not a list')
|
||||||
|
}
|
||||||
|
return base
|
||||||
|
}
|
||||||
const rules: PromptRule[] = []
|
const rules: PromptRule[] = []
|
||||||
for (const item of user as unknown[]) {
|
for (const item of user as unknown[]) {
|
||||||
const rule = acceptRule(phases, item)
|
const rule = acceptRule(phases, item)
|
||||||
@@ -202,7 +216,10 @@ const pickBool = (v: unknown, fallback: boolean): boolean =>
|
|||||||
/** 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)
|
||||||
if (!isRecord(user)) return base
|
if (!isRecord(user)) {
|
||||||
|
if (user !== undefined) log('model-router: config ignored: not an object')
|
||||||
|
return base
|
||||||
|
}
|
||||||
const models = mergeTable(
|
const models = mergeTable(
|
||||||
base.models, user.models, 'models', acceptModel, log)
|
base.models, user.models, 'models', acceptModel, log)
|
||||||
const phases = mergeTable(
|
const phases = mergeTable(
|
||||||
@@ -222,6 +239,24 @@ function mergeConfig(user: unknown, log: Log): Config {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The file's text, or undefined (logged) when it exceeds the cap. */
|
||||||
|
async function readCapped(
|
||||||
|
$: Api,
|
||||||
|
path: string,
|
||||||
|
log: Log,
|
||||||
|
): Promise<string | undefined> {
|
||||||
|
const tooBig =
|
||||||
|
`model-router: ${OVERRIDE} over ${MAX_CONFIG_BYTES} bytes; defaults`
|
||||||
|
if ((await $.fs.stat(path)).size > MAX_CONFIG_BYTES) {
|
||||||
|
log(tooBig)
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
const text = await $.fs.read(path)
|
||||||
|
if (text.length <= MAX_CONFIG_BYTES) return text
|
||||||
|
log(tooBig)
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
async function readOverride(
|
async function readOverride(
|
||||||
$: Api,
|
$: Api,
|
||||||
log: Log,
|
log: Log,
|
||||||
@@ -231,7 +266,8 @@ async function readOverride(
|
|||||||
if (!home) return undefined
|
if (!home) return undefined
|
||||||
const path = `${home}/${OVERRIDE}`
|
const path = `${home}/${OVERRIDE}`
|
||||||
if (!(await $.fs.exists(path))) return undefined
|
if (!(await $.fs.exists(path))) return undefined
|
||||||
return { path, data: JSON.parse(await $.fs.read(path)) }
|
const text = await readCapped($, path, log)
|
||||||
|
return text === undefined ? undefined : { path, data: JSON.parse(text) }
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log(`model-router: ${OVERRIDE} unreadable (${String(err)}); defaults`)
|
log(`model-router: ${OVERRIDE} unreadable (${String(err)}); defaults`)
|
||||||
return undefined
|
return undefined
|
||||||
@@ -271,6 +307,28 @@ function newState(cfg: Config, source: string): State {
|
|||||||
off: false,
|
off: false,
|
||||||
lastMain: '',
|
lastMain: '',
|
||||||
windowWarned: false,
|
windowWarned: false,
|
||||||
|
warned: new Set(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Logs a hook's fail-open once per session; never throws itself. */
|
||||||
|
function warnOnce(st: State, $: Api, hook: string, kind: string): void {
|
||||||
|
if (st.warned.has(hook)) return
|
||||||
|
st.warned.add(hook)
|
||||||
|
try {
|
||||||
|
$.ui.log(`model-router: ${hook} failed (${kind}): ` +
|
||||||
|
'routing skipped for this event')
|
||||||
|
} catch {
|
||||||
|
// a failing log must not break the fail-open itself
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Runs post-`next` bookkeeping so its failure can never re-run `next`. */
|
||||||
|
function safely(st: State, $: Api, hook: string, work: () => void): void {
|
||||||
|
try {
|
||||||
|
work()
|
||||||
|
} catch {
|
||||||
|
warnOnce(st, $, hook, 'bookkeeping')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -281,9 +339,6 @@ 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
|
||||||
const fresh: Loop = {
|
const fresh: Loop = {
|
||||||
spawnModel: '',
|
|
||||||
frozen: false,
|
|
||||||
explicitModel: false,
|
|
||||||
explicitEffort: false,
|
explicitEffort: false,
|
||||||
}
|
}
|
||||||
st.loops.set(agentId, fresh)
|
st.loops.set(agentId, fresh)
|
||||||
@@ -293,7 +348,6 @@ function loopOf(st: State, agentId: string): Loop {
|
|||||||
/** Writes a route on an agent loop, never on an axis given explicitly. */
|
/** Writes a route on an agent loop, never on an axis given explicitly. */
|
||||||
function writeLoop(loop: Loop, route: Route): void {
|
function writeLoop(loop: Loop, route: Route): void {
|
||||||
if (!loop.explicitEffort) loop.effort = route.effort
|
if (!loop.explicitEffort) loop.effort = route.effort
|
||||||
if (!loop.explicitModel) loop.model = route.model
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearRoutes(st: State): void {
|
function clearRoutes(st: State): void {
|
||||||
@@ -439,10 +493,11 @@ async function registerTool($: Api, st: State): Promise<void> {
|
|||||||
await $.tool.register({
|
await $.tool.register({
|
||||||
name: 'route',
|
name: 'route',
|
||||||
description:
|
description:
|
||||||
'Declare the phase of the work ahead so the next model requests ' +
|
'Declare the phase of the work ahead so the next requests of THIS ' +
|
||||||
'run at the effort (and model) it deserves. Call it before a span ' +
|
'loop run at the right effort (and, on the main loop, the model ' +
|
||||||
'of work changes nature (planning, orchestrating, mechanical ' +
|
'when the switch is on). Call it before a span of work changes ' +
|
||||||
'work). It acts on the calling loop only; no model choice here. ' +
|
'nature (planning, orchestrating, mechanical work). Effort only ' +
|
||||||
|
'for a sub-agent; the model of a sub-agent is fixed at spawn. ' +
|
||||||
`Phases: ${Object.keys(st.cfg.phases).join(', ')}.`,
|
`Phases: ${Object.keys(st.cfg.phases).join(', ')}.`,
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
@@ -451,6 +506,7 @@ async function registerTool($: Api, st: State): Promise<void> {
|
|||||||
effort: { type: 'string', enum: [...LEVELS] },
|
effort: { type: 'string', enum: [...LEVELS] },
|
||||||
clear: { type: 'boolean', description: 'drop this loop\'s route' },
|
clear: { type: 'boolean', description: 'drop this loop\'s route' },
|
||||||
},
|
},
|
||||||
|
additionalProperties: false,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -516,10 +572,12 @@ function routedText(st: State, agentId: string | undefined, p: Picked): string {
|
|||||||
}
|
}
|
||||||
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
|
||||||
? undefined
|
? p.route.model
|
||||||
: loop?.explicitModel ? undefined : p.route.model
|
: undefined
|
||||||
const modelNote = p.route.model && model === undefined ? ' (switch off)' : ''
|
const modelNote = !p.route.model || model !== undefined
|
||||||
|
? ''
|
||||||
|
: agentId === undefined ? ' (switch off)' : ' (fixed at spawn)'
|
||||||
return `routed ${agentId === undefined ? 'main' : 'this agent'} to ` +
|
return `routed ${agentId === undefined ? 'main' : 'this agent'} to ` +
|
||||||
`${p.phase}: effort ${effort ?? 'unchanged'}, model ` +
|
`${p.phase}: effort ${effort ?? 'unchanged'}, model ` +
|
||||||
`${model === undefined ? 'unchanged' : resolveModel(st.cfg, model)}` +
|
`${model === undefined ? 'unchanged' : resolveModel(st.cfg, model)}` +
|
||||||
@@ -619,16 +677,12 @@ function spawnRoute(cfg: Config, e: SpawnIn, frozen: boolean) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function trackLoop(st: State, e: SpawnIn, started: {
|
function trackLoop(st: State, e: SpawnIn, started: {
|
||||||
model: string
|
|
||||||
agentId?: string
|
agentId?: string
|
||||||
}, route: Route | undefined, frozen: boolean): void {
|
}, route: Route | undefined): void {
|
||||||
const given = st.explicitEffort.get(e.tool_use_id)
|
const given = st.explicitEffort.get(e.tool_use_id)
|
||||||
st.explicitEffort.delete(e.tool_use_id)
|
st.explicitEffort.delete(e.tool_use_id)
|
||||||
if (started.agentId === undefined) return
|
if (started.agentId === undefined) return
|
||||||
st.loops.set(started.agentId, {
|
st.loops.set(started.agentId, {
|
||||||
spawnModel: started.model,
|
|
||||||
frozen,
|
|
||||||
explicitModel: e.model !== undefined,
|
|
||||||
explicitEffort: given !== undefined,
|
explicitEffort: given !== undefined,
|
||||||
effort: given ? undefined : route?.effort,
|
effort: given ? undefined : route?.effort,
|
||||||
})
|
})
|
||||||
@@ -638,13 +692,7 @@ function trackLoop(st: State, e: SpawnIn, started: {
|
|||||||
|
|
||||||
function agentPlan(st: State, e: StepIn): Plan {
|
function agentPlan(st: State, e: StepIn): Plan {
|
||||||
const loop = e.agentId === undefined ? undefined : st.loops.get(e.agentId)
|
const loop = e.agentId === undefined ? undefined : st.loops.get(e.agentId)
|
||||||
const wanted = loop?.model
|
return { model: e.model, effort: loop?.effort ?? e.effort }
|
||||||
const reroute = loop && wanted !== undefined && !loop.frozen &&
|
|
||||||
e.model === loop.spawnModel
|
|
||||||
return {
|
|
||||||
model: reroute ? resolveModel(st.cfg, wanted) : e.model,
|
|
||||||
effort: loop?.effort ?? e.effort,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** True when the context still fits the target model's known window. */
|
/** True when the context still fits the target model's known window. */
|
||||||
@@ -719,14 +767,29 @@ function registerSession(on: On, st: State): void {
|
|||||||
await registerCommand($)
|
await registerCommand($)
|
||||||
refresh($, st)
|
refresh($, st)
|
||||||
return next(e)
|
return next(e)
|
||||||
}).catch(($, e, next) => next(e))
|
}).catch(($, e, next) => {
|
||||||
|
warnOnce(st, $, 'session.start', next.error.kind)
|
||||||
|
return next(e)
|
||||||
|
})
|
||||||
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))
|
||||||
return next(e)
|
return next(e)
|
||||||
}).catch(($, e, next) => next(e))
|
}).catch(($, e, next) => {
|
||||||
on('command.run', { command: 'route' }, async ($, e) => ({
|
warnOnce(st, $, 'session.end', next.error.kind)
|
||||||
text: await handleCommand($, st, e.args),
|
return next(e)
|
||||||
})).catch(($, e, next) => ({ text: `route failed (${next.error.kind})` }))
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerCommandHook(on: On, st: State): void {
|
||||||
|
on('command.run', { command: 'route' }, async ($, e) => {
|
||||||
|
if (e.origin.kind !== 'composer') {
|
||||||
|
return { text: 'route: user-only command' }
|
||||||
|
}
|
||||||
|
return { text: await handleCommand($, st, e.args) }
|
||||||
|
}).catch(($, e, next) => {
|
||||||
|
warnOnce(st, $, 'command.run', next.error.kind)
|
||||||
|
return { text: `route failed (${next.error.kind})` }
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function registerRouteTool(on: On, st: State): void {
|
function registerRouteTool(on: On, st: State): void {
|
||||||
@@ -735,28 +798,36 @@ function registerRouteTool(on: On, st: State): void {
|
|||||||
refresh($, st)
|
refresh($, st)
|
||||||
vlog($, st, `route ${e.agentId ?? 'main'}: ${JSON.stringify(out)}`)
|
vlog($, st, `route ${e.agentId ?? 'main'}: ${JSON.stringify(out)}`)
|
||||||
return out
|
return out
|
||||||
}).catch(($, e, next) => ({
|
}).catch(($, e, next) => {
|
||||||
result: `route failed (${next.error.kind}); nothing routed`,
|
warnOnce(st, $, 'route tool', next.error.kind)
|
||||||
}))
|
return { result: `route failed (${next.error.kind}); nothing routed` }
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function registerSkills(on: On, st: State): void {
|
function registerSkills(on: On, st: State): void {
|
||||||
on('tool.call', { tool: 'Skill' }, async ($, e, next) => {
|
on('tool.call', { tool: 'Skill' }, async ($, e, next) => {
|
||||||
if (st.off) return next(e)
|
const skill = typeof e.skill === 'string' ? e.skill : undefined
|
||||||
const level = EFFORT_SKILL.exec(e.skill)?.[1]
|
if (st.off || skill === undefined) return next(e)
|
||||||
if (isLevel(level)) return effortBridge(st, e.agentId, e.skill, level)
|
const level = EFFORT_SKILL.exec(skill)?.[1]
|
||||||
|
if (isLevel(level)) return effortBridge(st, e.agentId, skill, level)
|
||||||
st.skillCalls += 1
|
st.skillCalls += 1
|
||||||
try {
|
try {
|
||||||
onSkillLoad(st, e.skill, e.agentId)
|
safely(st, $, 'Skill', () => onSkillLoad(st, skill, e.agentId))
|
||||||
return await next(e)
|
return await next(e)
|
||||||
} finally {
|
} finally {
|
||||||
st.skillCalls -= 1
|
st.skillCalls -= 1
|
||||||
}
|
}
|
||||||
}).catch(($, e, next) => next(e))
|
}).catch(($, e, next) => {
|
||||||
|
warnOnce(st, $, 'Skill', next.error.kind)
|
||||||
|
return next(e)
|
||||||
|
})
|
||||||
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)
|
return slashEffort(st, e.skill, e.text) ?? next(e)
|
||||||
}).catch(($, e, next) => next(e))
|
}).catch(($, e, next) => {
|
||||||
|
warnOnce(st, $, 'skill.prompt', next.error.kind)
|
||||||
|
return next(e)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function registerAgents(on: On, st: State): void {
|
function registerAgents(on: On, st: State): void {
|
||||||
@@ -765,7 +836,13 @@ function registerAgents(on: On, st: State): void {
|
|||||||
st.explicitEffort.set(e.tool_use_id, e.effort)
|
st.explicitEffort.set(e.tool_use_id, e.effort)
|
||||||
}
|
}
|
||||||
return next(e)
|
return next(e)
|
||||||
}).catch(($, e, next) => next(e))
|
}).catch(($, e, next) => {
|
||||||
|
warnOnce(st, $, 'Agent', next.error.kind)
|
||||||
|
return next(e)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerSpawn(on: On, st: State): void {
|
||||||
on('agent.spawn', async ($, e, next) => {
|
on('agent.spawn', async ($, e, next) => {
|
||||||
if (st.off) return next(e)
|
if (st.off) return next(e)
|
||||||
const frozen = e.fork || e.workflow !== undefined
|
const frozen = e.fork || e.workflow !== undefined
|
||||||
@@ -775,11 +852,18 @@ function registerAgents(on: On, st: State): void {
|
|||||||
const started = await next(wanted === undefined
|
const started = await next(wanted === undefined
|
||||||
? e
|
? e
|
||||||
: { ...e, model: resolveModel(st.cfg, wanted) })
|
: { ...e, model: resolveModel(st.cfg, wanted) })
|
||||||
if (started.deny !== undefined) return started
|
if (typeof started.agentId === 'string') {
|
||||||
trackLoop(st, e, started, route, frozen)
|
safely(st, $, 'agent.spawn', () => {
|
||||||
vlog($, st, `spawn ${e.subagentType}: ${e.model ?? '-'} → ${started.model}`)
|
trackLoop(st, e, started, route)
|
||||||
|
vlog($, st,
|
||||||
|
`spawn ${e.subagentType}: ${e.model ?? '-'} → ${started.model}`)
|
||||||
|
})
|
||||||
|
}
|
||||||
return started
|
return started
|
||||||
}).catch(($, e, next) => next(e))
|
}).catch(($, e, next) => {
|
||||||
|
warnOnce(st, $, 'agent.spawn', next.error.kind)
|
||||||
|
return next(e)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function registerTurns(on: On, st: State): void {
|
function registerTurns(on: On, st: State): void {
|
||||||
@@ -790,22 +874,28 @@ function registerTurns(on: On, st: State): void {
|
|||||||
if (e.agentId === undefined) noteMain($, st, plan)
|
if (e.agentId === undefined) noteMain($, st, plan)
|
||||||
vlog($, st, stepLog(e, plan))
|
vlog($, st, stepLog(e, plan))
|
||||||
const result = yield* next(changed ? withPlan(e, plan) : e)
|
const result = yield* next(changed ? withPlan(e, plan) : e)
|
||||||
vlog($, st, `step ${e.index} answered by ${result.usage?.model ?? '?'}`)
|
safely(st, $, 'turn.step', () => vlog($, st,
|
||||||
|
`step ${e.index} answered by ${result.usage?.model ?? '?'}`))
|
||||||
return result
|
return result
|
||||||
}).catch(async function* ($, e, next) {
|
}).catch(async function* ($, e, next) {
|
||||||
|
warnOnce(st, $, 'turn.step', next.error.kind)
|
||||||
return yield* next(e)
|
return yield* next(e)
|
||||||
})
|
})
|
||||||
on('turn.complete', async ($, e, next) => {
|
on('turn.complete', async ($, e, next) => {
|
||||||
if (e.agentId !== undefined) st.loops.delete(e.agentId)
|
if (e.agentId !== undefined) st.loops.delete(e.agentId)
|
||||||
else endMainTurn($, st)
|
else endMainTurn($, st)
|
||||||
return next(e)
|
return next(e)
|
||||||
}).catch(($, e, next) => next(e))
|
}).catch(($, e, next) => {
|
||||||
|
warnOnce(st, $, 'turn.complete', next.error.kind)
|
||||||
|
return next(e)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
||||||
const rule = st.rules.find(r => r.re.test(e.text))
|
const scanned = e.text.slice(0, MAX_PROMPT_SCAN)
|
||||||
|
const rule = st.rules.find(r => r.re.test(scanned))
|
||||||
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' }
|
||||||
@@ -815,20 +905,28 @@ function registerPrompt(on: On, st: State): void {
|
|||||||
refresh($, st)
|
refresh($, st)
|
||||||
}
|
}
|
||||||
return next(e)
|
return next(e)
|
||||||
}).catch(($, e, next) => next(e))
|
}).catch(($, e, next) => {
|
||||||
|
warnOnce(st, $, 'prompt.submit', next.error.kind)
|
||||||
|
return next(e)
|
||||||
|
})
|
||||||
on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
|
on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
|
||||||
if (st.off || !st.cfg.spinner || !st.lastMain) return next(e)
|
if (st.off || !st.cfg.spinner || !st.lastMain) return next(e)
|
||||||
const suffix = ` · ${st.lastMain}…`
|
const suffix = ` · ${st.lastMain}…`
|
||||||
return next({ ...e, props: { ...e.props, suffix } })
|
return next({ ...e, props: { ...e.props, suffix } })
|
||||||
}).catch(($, e, next) => next(e))
|
}).catch(($, e, next) => {
|
||||||
|
warnOnce(st, $, 'ui.render', next.error.kind)
|
||||||
|
return next(e)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export const register: Register = on => {
|
export const register: Register = on => {
|
||||||
const st = newState(mergeConfig(undefined, () => undefined), 'defaults')
|
const st = newState(mergeConfig(undefined, () => undefined), 'defaults')
|
||||||
registerSession(on, st)
|
registerSession(on, st)
|
||||||
|
registerCommandHook(on, st)
|
||||||
registerRouteTool(on, st)
|
registerRouteTool(on, st)
|
||||||
registerSkills(on, st)
|
registerSkills(on, st)
|
||||||
registerAgents(on, st)
|
registerAgents(on, st)
|
||||||
|
registerSpawn(on, st)
|
||||||
registerTurns(on, st)
|
registerTurns(on, st)
|
||||||
registerPrompt(on, st)
|
registerPrompt(on, st)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user