model-router v0.4.4
Claude Code plugin: routes subagents by how well-defined the brief is, verifies dev work on a second model in the agent's own clone, and estimates savings against Opus 5.5. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MdqPWvuDpbpRvDEdURoAwo
This commit is contained in:
+211
@@ -0,0 +1,211 @@
|
||||
// Pure routing logic: no `$`, so every rule here is unit-tested.
|
||||
|
||||
export type Tier = 'haiku' | 'sonnet' | 'opus' | 'fable'
|
||||
export type Role = 'dev' | 'review' | 'test'
|
||||
|
||||
export const TIERS: readonly Tier[] = ['haiku', 'sonnet', 'opus', 'fable']
|
||||
|
||||
export const rank = (t: Tier) => TIERS.indexOf(t)
|
||||
|
||||
export const asTier = (s: string | undefined): Tier | undefined => {
|
||||
if (!s) return undefined
|
||||
const lower = s.toLowerCase()
|
||||
return TIERS.find(t => lower.includes(t))
|
||||
}
|
||||
|
||||
export const parsePatterns = (csv: string): string[] =>
|
||||
csv
|
||||
.split(',')
|
||||
.map(s => s.trim().toLowerCase())
|
||||
.filter(s => s.length > 0)
|
||||
|
||||
/** The first safety pattern the text mentions, or undefined. */
|
||||
export const safetyHit = (text: string, patterns: readonly string[]): string | undefined => {
|
||||
const lower = text.toLowerCase()
|
||||
return patterns.find(p => lower.includes(p))
|
||||
}
|
||||
|
||||
// ---------- the brief ----------
|
||||
|
||||
export type Brief = {
|
||||
clone?: string
|
||||
ticket?: string
|
||||
role?: Role
|
||||
baseBranch?: string
|
||||
/** Several clone paths and no `Clone:` line: the role cannot be told. */
|
||||
isAmbiguous?: boolean
|
||||
}
|
||||
|
||||
// A ticket is LETTERS-digits plus any extra parts (OPT-11-P4); the role is the last part.
|
||||
const TICKET = '[A-Za-z]+-\\d+(?:-[A-Za-z0-9]+)*?'
|
||||
const CLONE_PATH = new RegExp(`(\\S*?\\/clones\\/(${TICKET})-(dev|review|test))\\/?`)
|
||||
const CLONE_PATHS = new RegExp(CLONE_PATH.source, 'g')
|
||||
// The explicit line every brief carries: `Clone: <path>` with an optional `(Role: dev)`.
|
||||
const CLONE_LINE = /^[ \t>*-]*Clone:[ \t]*`?([^\s`]+?)\/?`?[ \t]*(?:\(Role:[ \t]*(dev|review|test)\))?[ \t]*$/im
|
||||
const BASE =
|
||||
/\b(?:base(?:\s+branch)?|target(?:\s+branch)?|merge\s+into|PR\s+(?:against|into))\s*[:=]?\s*`?((?:release|main|master|develop)[\w./-]*)/i
|
||||
|
||||
const trimPath = (p: string) => p.replace(/^[`'"(]+/, '').replace(/[`'")]+$/, '').replace(/\/+$/, '')
|
||||
|
||||
/**
|
||||
* Reads the clone path, ticket, role and base branch out of a brief.
|
||||
*
|
||||
* The `Clone:` line decides when present: its `(Role: ...)`, else the role in
|
||||
* its path. Without one, a brief that names one clone is read from that path;
|
||||
* one that names several is ambiguous and gets no role.
|
||||
*/
|
||||
export const parseBrief = (prompt: string): Brief => {
|
||||
const b = prompt.match(BASE)
|
||||
// a sentence's full stop or a trailing slash is no part of the branch
|
||||
const baseBranch = b?.[1]?.replace(/[./-]+$/, '')
|
||||
|
||||
const line = prompt.match(CLONE_LINE)
|
||||
if (line?.[1]) {
|
||||
const clone = trimPath(line[1])
|
||||
const fromPath = clone.match(CLONE_PATH)
|
||||
const role = (line[2] ?? fromPath?.[3]) as Role | undefined
|
||||
return { clone, ticket: fromPath?.[2], role, baseBranch }
|
||||
}
|
||||
|
||||
const paths = [...new Set([...prompt.matchAll(CLONE_PATHS)].map(m => trimPath(m[1] ?? '')))]
|
||||
if (paths.length > 1) return { baseBranch, isAmbiguous: true }
|
||||
const only = paths[0]?.match(CLONE_PATH)
|
||||
if (!only) return { baseBranch }
|
||||
return { clone: trimPath(only[1] ?? ''), ticket: only[2], role: only[3] as Role, baseBranch }
|
||||
}
|
||||
|
||||
// ---------- routing ----------
|
||||
|
||||
const READ_ONLY_TYPES = /^(explore|scout|runner|claude-code-guide|statusline-setup)$|:(scout|runner)$/i
|
||||
|
||||
// Light work, only for tasks outside the dev/review/test roles. No bare
|
||||
// "docs" or "run tests": both appear in nearly every brief.
|
||||
const LIGHT = [
|
||||
/\b(find|locate|grep|where is|which (files|modules)|summari[sz]e|read and report)\b/,
|
||||
/\b(fix (a |the )?typo|update (the )?(readme|changelog)|add docstrings?)\b/,
|
||||
/\b(run (the )?(unit )?tests? and report|report (the )?test results)\b/,
|
||||
]
|
||||
|
||||
// Opus writes only when the approach itself is still open. Domain words
|
||||
// (control loop, asyncio, RS485) are everywhere in this codebase and say nothing.
|
||||
const HEAVY = [
|
||||
/\b(design|architect|redesign|decide (how|whether)|propose an approach|trade-?offs?)\b/,
|
||||
/\b(root cause|unclear why|no idea why|investigate why|figure out why|intermittent|flaky)\b/,
|
||||
]
|
||||
|
||||
export type Route = { tier: Tier; reason: string }
|
||||
|
||||
/** A clear signal from the text, or undefined (ask the classifier). */
|
||||
export const heuristic = (subagentType: string | undefined, prompt: string, role?: Role): Route | undefined => {
|
||||
if (!role && subagentType && READ_ONLY_TYPES.test(subagentType))
|
||||
return { tier: 'haiku', reason: `read-only agent type ${subagentType}` }
|
||||
const lower = prompt.toLowerCase()
|
||||
const heavy = HEAVY.find(r => r.test(lower))
|
||||
if (heavy) return { tier: 'opus', reason: 'approach still open' }
|
||||
if (role) return undefined // dev briefs never take the light path
|
||||
const light = LIGHT.find(r => r.test(lower))
|
||||
if (light && prompt.length < 1500) return { tier: 'haiku', reason: 'light task' }
|
||||
return undefined
|
||||
}
|
||||
|
||||
export type Decision =
|
||||
| { kind: 'fixed'; tier: Tier; reason: string; opusRequested?: boolean }
|
||||
| { kind: 'classify'; opusRequested?: boolean }
|
||||
|
||||
/**
|
||||
* Who decides the writer's model.
|
||||
* - review and test agents: a named model is kept; unnamed, they run on Opus
|
||||
* (they are the gates, and a test agent's work has no verifier behind it).
|
||||
* - dev agents: always routed; a named Opus is only logged.
|
||||
* - anything else: routed, unless respectExplicit and a model is named.
|
||||
*/
|
||||
export const decide = (args: {
|
||||
role?: Role
|
||||
named?: Tier
|
||||
subagentType?: string
|
||||
prompt: string
|
||||
respectExplicit: boolean
|
||||
isAmbiguous?: boolean
|
||||
}): Decision => {
|
||||
const { role, named, subagentType, prompt, respectExplicit, isAmbiguous } = args
|
||||
// Could be a test or review agent: never risk a weaker model on it.
|
||||
if (isAmbiguous)
|
||||
return { kind: 'fixed', tier: named && named !== 'haiku' && named !== 'sonnet' ? named : 'opus', reason: 'several clone paths and no Clone: line' }
|
||||
if (role === 'review' || role === 'test')
|
||||
return named
|
||||
? { kind: 'fixed', tier: named, reason: `named model kept for ${role} agent` }
|
||||
: { kind: 'fixed', tier: 'opus', reason: `${role} agent default` }
|
||||
if (!role && respectExplicit && named) return { kind: 'fixed', tier: named, reason: 'model chosen by orchestrator' }
|
||||
const opusRequested = role === 'dev' && (named === 'opus' || named === 'fable')
|
||||
const h = heuristic(subagentType, prompt, role)
|
||||
return h ? { kind: 'fixed', ...h, opusRequested } : { kind: 'classify', opusRequested }
|
||||
}
|
||||
|
||||
// ---------- verification ----------
|
||||
|
||||
export type Skip = 'test-agent' | 'review-agent' | 'no-clone' | 'opus-unverified' | 'agent-ended'
|
||||
|
||||
/** Why an agent run is not verified, or undefined when it is. */
|
||||
export const skipReason = (rec: { role?: Role; clone?: string; tier: Tier }, ended: string): Skip | undefined => {
|
||||
if (ended !== 'answer') return 'agent-ended'
|
||||
if (rec.role === 'test') return 'test-agent'
|
||||
if (rec.role === 'review') return 'review-agent'
|
||||
if (!rec.clone) return 'no-clone'
|
||||
if (rank(rec.tier) >= rank('opus')) return 'opus-unverified'
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** The verifier never runs on the writer's model; below Opus it is Opus. */
|
||||
export const verifierFor = (writer: Tier): Tier => (rank(writer) >= rank('opus') ? 'sonnet' : 'opus')
|
||||
|
||||
/** The model a rewrite runs on: one tier up, never the same model. */
|
||||
export const rewriteTier = (writer: Tier, allowFable: boolean): Tier | undefined => {
|
||||
if (writer === 'haiku') return 'sonnet'
|
||||
if (writer === 'sonnet') return 'opus'
|
||||
if (writer === 'opus' && allowFable) return 'fable'
|
||||
return undefined
|
||||
}
|
||||
|
||||
export type FailStep = { kind: 'resume' } | { kind: 'new-agent'; tier: Tier } | { kind: 'hand-back' }
|
||||
|
||||
/**
|
||||
* After the n-th failed verdict in a task's chain: the first resumes the same
|
||||
* agent, the second starts a new agent one model up in the same clone, and
|
||||
* past maxFails (or with no model above) it goes back to the manager.
|
||||
*/
|
||||
export const onFail = (fails: number, writer: Tier, maxFails: number, allowFable: boolean): FailStep => {
|
||||
if (fails > maxFails) return { kind: 'hand-back' }
|
||||
if (fails === 1) return { kind: 'resume' }
|
||||
const up = rewriteTier(writer, allowFable)
|
||||
return up ? { kind: 'new-agent', tier: up } : { kind: 'hand-back' }
|
||||
}
|
||||
|
||||
export type Verdict = { pass: boolean; issues: string[]; brief: string }
|
||||
|
||||
/** Reads the verifier's reply; anything unreadable counts as a failure. */
|
||||
export const parseVerdict = (text: string): Verdict => {
|
||||
const match = text.match(/\{[\s\S]*\}/)
|
||||
if (match) {
|
||||
try {
|
||||
const v = JSON.parse(match[0]) as { verdict?: string; issues?: unknown; rewrite_brief?: unknown }
|
||||
const issues = Array.isArray(v.issues) ? v.issues.map(String) : []
|
||||
const pass = String(v.verdict ?? '').toUpperCase() === 'PASS'
|
||||
return { pass, issues, brief: typeof v.rewrite_brief === 'string' ? v.rewrite_brief : '' }
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
}
|
||||
return { pass: false, issues: ['Verifier reply was not valid JSON: ' + text.slice(0, 300)], brief: '' }
|
||||
}
|
||||
|
||||
export const REWRITE_MARK = /^\s*\[router-rewrite (T\d+)\]/
|
||||
|
||||
export const clip = (s: string, n: number) =>
|
||||
s.length <= n ? s : s.slice(0, n) + `\n... [${s.length - n} more characters cut]`
|
||||
|
||||
/** A usable test time limit in seconds: anything not a positive number is the default. */
|
||||
export function validTimeout(raw: unknown): number {
|
||||
const n = Number(raw)
|
||||
return Number.isFinite(n) && n >= 1 ? Math.round(n) : 1800
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user