Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
05f9663129 |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "model-router",
|
||||
"version": "0.4.4",
|
||||
"version": "0.4.5",
|
||||
"description": "Routes each subagent to Haiku, Sonnet or Opus by how well-defined its brief is, verifies dev work below Opus in its own clone on a different model, sends failures back to the same agent then a stronger one, and estimates the savings against Opus.",
|
||||
"author": {
|
||||
"name": "Glenn Schrooyen"
|
||||
|
||||
@@ -9,7 +9,7 @@ A Claude Code plugin that picks a model for every subagent, checks dev work on a
|
||||
| Route | Dev agents run on **Sonnet** when the brief decides the approach (Expected / acceptance criteria), on **Opus** when the design is still open. Review and test agents keep the model they are given (Opus when none). |
|
||||
| Verify | When a dev agent below Opus finishes a round, the router diffs its clone against the merge base, runs the test command in that clone, and has **Opus** judge the change. |
|
||||
| On FAIL | 1st: resume the same agent with the issues. 2nd: a new agent one model up, in the same clone. After that: handed back to the manager. |
|
||||
| Not verified | A test timeout, a test command that cannot run, a missing clone or merge base: reported as NOT VERIFIED, never as a FAIL. |
|
||||
| Not verified | A test timeout, a test command that cannot run, a missing clone or merge base, or a verdict that cannot be read even after asking once more: reported as NOT VERIFIED, never as a FAIL. |
|
||||
| Savings | `/router savings` prices every run on the model it ran on and compares it with the same tokens on Opus 5.5. |
|
||||
|
||||
## Briefs
|
||||
@@ -58,7 +58,7 @@ claude plugin validate .
|
||||
claude plugin test .
|
||||
```
|
||||
|
||||
44 tests. Every routing, verification, cost and timeout rule was also checked by breaking it on purpose (66 mutations, all caught by a test).
|
||||
54 tests. Every routing, verification, cost and timeout rule was also checked by breaking it on purpose (66 mutations in v0.4.4, plus 13 for the verdict reader in v0.4.5; all caught by a test).
|
||||
|
||||
## Known limits
|
||||
|
||||
|
||||
+36
-9
@@ -59,6 +59,7 @@ type Outcome =
|
||||
| 'tests-timeout'
|
||||
| 'tests-error'
|
||||
| 'verify-error'
|
||||
| 'verdict-unreadable'
|
||||
| 'test-agent'
|
||||
| 'review-agent'
|
||||
| 'no-clone'
|
||||
@@ -369,10 +370,7 @@ async function verifyRound($: EngineInterface, agentId: string, rec: AgentRec, c
|
||||
const verifier = verifierFor(rec.tier)
|
||||
entry.verifier = verifier
|
||||
$.ui.status(`router: ${rec.taskId} round ${round} verifying on ${verifier}`)
|
||||
const v = await $.model.complete({
|
||||
model: verifier,
|
||||
system: VERIFIER_SYSTEM,
|
||||
prompt: [
|
||||
const question = [
|
||||
`## Task\n${clip(rec.prompt, 12_000)}`,
|
||||
rec.lastIssues?.length
|
||||
? `## Issues raised last round (check they are fixed)\n${rec.lastIssues.map(i => `- ${i}`).join('\n')}`
|
||||
@@ -381,17 +379,46 @@ async function verifyRound($: EngineInterface, agentId: string, rec: AgentRec, c
|
||||
cfg.testCommand ? `## Test run in the clone: \`${cfg.testCommand}\`\n${tests}` : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n\n'),
|
||||
maxTokens: 2000,
|
||||
timeoutMs: 180_000,
|
||||
})
|
||||
.join('\n\n')
|
||||
const v = await $.model.complete({ model: verifier, system: VERIFIER_SYSTEM, prompt: question, maxTokens: 2000, timeoutMs: 180_000 })
|
||||
await chargeOverhead($, rec.taskId, verifier, v.usage, cfg.prices)
|
||||
if (!v.isAnswered) {
|
||||
await log($, entry)
|
||||
await tell($, `${head}: NOT VERIFIED - the verifier (${verifier}) could not run (${v.reason}). Review the change yourself before the PR.`)
|
||||
return
|
||||
}
|
||||
const verdict = parseVerdict(v.text)
|
||||
let reading = parseVerdict(v.text)
|
||||
// the first reply is where the verifier judged the code: quote that one
|
||||
const firstPreview = v.text.slice(0, 300)
|
||||
if (!reading.isReadable) {
|
||||
// Ask once more, for the JSON alone. A reply that still cannot be read is
|
||||
// no verdict on the code: hand it back, never count it as a FAIL.
|
||||
$.ui.status(`router: ${rec.taskId} round ${round} verdict unreadable, asking ${verifier} again`)
|
||||
const again = await $.model.complete({
|
||||
model: verifier,
|
||||
system: VERIFIER_SYSTEM,
|
||||
prompt:
|
||||
`${question}\n\n## Your previous reply could not be read\n${clip(v.text, 4000)}\n\n` +
|
||||
'Reply again with the JSON object only: no code fence, no text before or after it.',
|
||||
maxTokens: 2000,
|
||||
timeoutMs: 180_000,
|
||||
})
|
||||
await chargeOverhead($, rec.taskId, verifier, again.usage, cfg.prices)
|
||||
if (again.isAnswered) reading = parseVerdict(again.text)
|
||||
}
|
||||
if (!reading.isReadable) {
|
||||
entry.outcome = 'verdict-unreadable'
|
||||
await log($, entry)
|
||||
$.ui.status(`router: ${rec.taskId} verdict unreadable, handed back`)
|
||||
await tell(
|
||||
$,
|
||||
`${head}: NOT VERIFIED - verdict unreadable. The verifier (${verifier}) did not give a readable PASS or FAIL, also when asked again. ` +
|
||||
`This says nothing about the code: do not resume or rewrite the agent for it. Review the change yourself.\n` +
|
||||
`Start of its reply: ${firstPreview}`,
|
||||
)
|
||||
return
|
||||
}
|
||||
const verdict = reading
|
||||
const issues = verdict.issues.length ? verdict.issues.map(i => `- ${i}`).join('\n') : '- (none listed)'
|
||||
|
||||
// 4. Remember where this round ended.
|
||||
|
||||
+65
-12
@@ -182,20 +182,73 @@ export const onFail = (fails: number, writer: Tier, maxFails: number, allowFable
|
||||
|
||||
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
|
||||
/** A verifier reply: a verdict that could be read, or the reason it could not. */
|
||||
export type Reading = ({ isReadable: true } & Verdict) | { isReadable: false; preview: string }
|
||||
|
||||
/** A JSON object with a PASS or FAIL verdict, or undefined. */
|
||||
const asVerdict = (json: string): Verdict | undefined => {
|
||||
let v: unknown
|
||||
try {
|
||||
v = JSON.parse(json)
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
if (!v || typeof v !== 'object' || Array.isArray(v)) return undefined
|
||||
const o = v as { verdict?: unknown; issues?: unknown; rewrite_brief?: unknown }
|
||||
const word = typeof o.verdict === 'string' ? o.verdict.trim().toUpperCase() : ''
|
||||
if (word !== 'PASS' && word !== 'FAIL') return undefined
|
||||
return {
|
||||
pass: word === 'PASS',
|
||||
issues: Array.isArray(o.issues) ? o.issues.map(String) : [],
|
||||
brief: typeof o.rewrite_brief === 'string' ? o.rewrite_brief : '',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Every balanced {...} in the text, in order of where it starts. Braces inside
|
||||
* JSON strings do not count, so `"a } b"` stays inside its object.
|
||||
*/
|
||||
export const balancedObjects = (text: string): string[] => {
|
||||
const found: string[] = []
|
||||
for (let start = text.indexOf('{'); start !== -1; start = text.indexOf('{', start + 1)) {
|
||||
let depth = 0
|
||||
let isInString = false
|
||||
for (let i = start; i < text.length; i++) {
|
||||
const ch = text[i]
|
||||
if (isInString) {
|
||||
if (ch === '\\') i++
|
||||
else if (ch === '"') isInString = false
|
||||
continue
|
||||
}
|
||||
if (ch === '"') isInString = true
|
||||
else if (ch === '{') depth++
|
||||
else if (ch === '}' && --depth === 0) {
|
||||
found.push(text.slice(start, i + 1))
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return { pass: false, issues: ['Verifier reply was not valid JSON: ' + text.slice(0, 300)], brief: '' }
|
||||
return found
|
||||
}
|
||||
|
||||
const FENCE = /```[ \t]*(?:json)?[ \t]*\r?\n([\s\S]*?)```/gi
|
||||
|
||||
/**
|
||||
* Reads the verifier's reply, tolerating prose before and after: a fenced
|
||||
* ```json block first, then the first balanced {...} that parses and has a
|
||||
* PASS or FAIL verdict. Anything else is unreadable, which is not a FAIL.
|
||||
*/
|
||||
export const parseVerdict = (text: string): Reading => {
|
||||
for (const m of text.matchAll(FENCE)) {
|
||||
const body = m[1] ?? ''
|
||||
const v = asVerdict(body.trim()) ?? balancedObjects(body).map(asVerdict).find(Boolean)
|
||||
if (v) return { isReadable: true, ...v }
|
||||
}
|
||||
for (const candidate of balancedObjects(text)) {
|
||||
const v = asVerdict(candidate)
|
||||
if (v) return { isReadable: true, ...v }
|
||||
}
|
||||
return { isReadable: false, preview: text.slice(0, 300) }
|
||||
}
|
||||
|
||||
export const REWRITE_MARK = /^\s*\[router-rewrite (T\d+)\]/
|
||||
|
||||
+91
-5
@@ -129,11 +129,55 @@ test('C5: first fail resumes, second starts a stronger agent, then hand back', a
|
||||
expect(rewriteTier('haiku', false)).toBe('sonnet')
|
||||
})
|
||||
|
||||
test('verdict parsing fails closed', async () => {
|
||||
expect(parseVerdict('{"verdict":"PASS","issues":[]}').pass).toBe(true)
|
||||
expect(parseVerdict('text {"verdict":"FAIL","issues":["a.py:3"]}').issues).toEqual(['a.py:3'])
|
||||
expect(parseVerdict('looks fine').pass).toBe(false)
|
||||
expect(parseVerdict('verdict: pass').pass).toBe(false)
|
||||
// ---------- reading the verdict (TOOL-02) ----------
|
||||
|
||||
// The SAFETY-129 reply shape: a fenced block, then prose that contains a `}`.
|
||||
const SAFETY_129 =
|
||||
'Checked the diff against the brief.\n\n```json\n{"verdict": "PASS", "issues": [], "rewrite_brief": ""}\n```\n\n' +
|
||||
'Notes: the guard keeps `if (stale) { hold() }` as before, and the new test covers it.'
|
||||
|
||||
test('TOOL-02: fenced JSON followed by prose with a } reads as PASS', async () => {
|
||||
expect(parseVerdict(SAFETY_129)).toEqual({ isReadable: true, pass: true, issues: [], brief: '' })
|
||||
})
|
||||
|
||||
test('TOOL-02: the fenced answer wins over an earlier object in the prose', async () => {
|
||||
const r = parseVerdict('Last round said {"verdict":"FAIL","issues":["x"]}. Fixed now:\n```json\n{"verdict":"PASS","issues":[]}\n```')
|
||||
expect(r).toMatchObject({ isReadable: true, pass: true })
|
||||
})
|
||||
|
||||
test('TOOL-02: bare JSON reads, with or without prose around it', async () => {
|
||||
expect(parseVerdict('{"verdict":"PASS","issues":[]}')).toMatchObject({ isReadable: true, pass: true })
|
||||
expect(parseVerdict('Verdict below. {"verdict": "pass"} That is all {really}.')).toMatchObject({ isReadable: true, pass: true })
|
||||
})
|
||||
|
||||
test('TOOL-02: FAIL keeps its issues and brief', async () => {
|
||||
const r = parseVerdict('```json\n{"verdict":"FAIL","issues":["loop.py:40 no clamp"],"rewrite_brief":"clamp it"}\n```')
|
||||
expect(r).toEqual({ isReadable: true, pass: false, issues: ['loop.py:40 no clamp'], brief: 'clamp it' })
|
||||
})
|
||||
|
||||
test('TOOL-02: braces inside strings do not end the object', async () => {
|
||||
const r = parseVerdict('{"verdict":"FAIL","issues":["handler } has a stray brace","quote \\" and }"]} trailing {x}')
|
||||
expect(r).toMatchObject({ isReadable: true, pass: false })
|
||||
if (r.isReadable) expect(r.issues).toEqual(['handler } has a stray brace', 'quote " and }'])
|
||||
})
|
||||
|
||||
test('TOOL-02: the first object with a verdict wins; other objects are skipped', async () => {
|
||||
const r = parseVerdict('Config seen: {"limit": 2000}. Verdict: {"verdict":"FAIL","issues":["x"]} and later {"verdict":"PASS"}')
|
||||
expect(r).toMatchObject({ isReadable: true, pass: false })
|
||||
})
|
||||
|
||||
test('TOOL-02: a fence without a verdict falls through to the text', async () => {
|
||||
const r = parseVerdict('```json\n{"limit": 2000}\n```\nResult: {"verdict":"PASS"}')
|
||||
expect(r).toMatchObject({ isReadable: true, pass: true })
|
||||
})
|
||||
|
||||
test('TOOL-02: garbage, prose and odd verdicts are unreadable, never a FAIL', async () => {
|
||||
for (const t of ['looks fine', 'verdict: pass', '{"verdict":"MAYBE"}', '{"issues":[]}', '{broken json', '']) {
|
||||
const r = parseVerdict(t)
|
||||
expect(r.isReadable).toBe(false)
|
||||
}
|
||||
const long = parseVerdict('x'.repeat(500))
|
||||
expect(long.isReadable ? '' : long.preview).toBe('x'.repeat(300))
|
||||
})
|
||||
|
||||
// ---------- the hooks, with the engine mocked beneath ----------
|
||||
@@ -449,3 +493,45 @@ test('the timeout line on stdout (a test printing it) is not a fired timeout', {
|
||||
await finish($, clock, r.agentId as string)
|
||||
expect(w.verifier).toEqual(['opus'])
|
||||
})
|
||||
|
||||
// ---------- TOOL-02 end to end ----------
|
||||
|
||||
test('TOOL-02: the SAFETY-129 reply passes the round, no rewrite', async ($, on) => {
|
||||
const w = fresh()
|
||||
w.diffs = ['diff']
|
||||
w.verdicts = [SAFETY_129]
|
||||
const clock = world(on, w)
|
||||
const r = await $.agent.spawn({ prompt: devBrief(), description: 'clamp' } as never)
|
||||
await finish($, clock, r.agentId as string)
|
||||
expect(w.verifier).toEqual(['opus'])
|
||||
expect(w.told[0]).toContain('PASS from verifier opus')
|
||||
})
|
||||
|
||||
test('TOOL-02: an unreadable reply is asked again once, and a readable answer then counts', async ($, on) => {
|
||||
const w = fresh()
|
||||
w.diffs = ['diff']
|
||||
w.verdicts = ['I think it is fine overall.', '{"verdict":"FAIL","issues":["x"]}']
|
||||
const clock = world(on, w)
|
||||
const r = await $.agent.spawn({ prompt: devBrief(), description: 'clamp' } as never)
|
||||
await finish($, clock, r.agentId as string)
|
||||
expect(w.verifier).toEqual(['opus', 'opus'])
|
||||
expect(w.told[0]).toContain('Resume agent')
|
||||
})
|
||||
|
||||
test('TOOL-02: unreadable twice is handed back, no fail counted, no rewrite', async ($, on) => {
|
||||
const w = fresh()
|
||||
w.diffs = ['diff', 'diff 2']
|
||||
w.verdicts = ['Looks good to me.', 'Still prose, sorry.', '{"verdict":"FAIL","issues":["y"]}']
|
||||
const clock = world(on, w)
|
||||
const r = await $.agent.spawn({ prompt: devBrief(), description: 'clamp' } as never)
|
||||
await finish($, clock, r.agentId as string)
|
||||
expect(w.verifier).toEqual(['opus', 'opus'])
|
||||
expect(w.told[0]).toContain('NOT VERIFIED - verdict unreadable')
|
||||
expect(w.told[0]).toContain('Start of its reply: Looks good to me.')
|
||||
expect(w.told[0]).not.toContain('[router-rewrite')
|
||||
expect(w.told[0]).not.toContain('Resume agent')
|
||||
// the next round's FAIL is the first one counted: it resumes, it does not rewrite
|
||||
await finish($, clock, r.agentId as string)
|
||||
expect(w.told[1]).toContain('Resume agent')
|
||||
expect(w.told[1]).not.toContain('[router-rewrite')
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user