Skip to content

Commit 9477471

Browse files
committed
feat(evals): wire @corbits/evals and add dispatch-routing eval
1 parent c861890 commit 9477471

6 files changed

Lines changed: 307 additions & 0 deletions

File tree

‎.gitignore‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ worktrees
2525
evals/capability/results/*
2626
!evals/capability/results/baseline-0286.json
2727
evals/public/results/*
28+
evals/prompts/dispatch-routing/last.json
2829
local-harness-bench/
2930
subagents/
3031

‎bun.lock‎

Lines changed: 5 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
# Dispatch routing eval (first `@corbits/evals` eval)
2+
3+
First eval wired through the published [`@corbits/evals`](https://github.com/corbitsdev/corbits-evals#readme)
4+
package (CL-10452). It exercises the **real dispatch director system prompt**
5+
(`agents/dispatch/src/prompt.ts`, exported by `@corbits/code-agent-dispatch`)
6+
against **brand-new routing requests**: each case is a user request that the
7+
dispatch director must route to one specialist.
8+
9+
## Run
10+
11+
```sh
12+
bun scripts/eval-prompts-dispatch.ts
13+
# or
14+
bun run eval:prompts-dispatch
15+
```
16+
17+
The run calls a model through local Ollama by default (matching the package's
18+
own examples). Override with env:
19+
20+
```sh
21+
EVAL_MODEL=tev1:0.8b bun scripts/eval-prompts-dispatch.ts
22+
EVAL_OLLAMA_URL=http://localhost:11434 EVAL_MODEL=qwen3:0.6b bun scripts/eval-prompts-dispatch.ts
23+
```
24+
25+
## What it measures
26+
27+
The run composes the dispatch card **verbatim** + the case request + a fixed
28+
reply-format stanza (harness glue so the routing decision is measurable), then
29+
asks the model which specialist it would spawn. Checks:
30+
31+
| Check | Helper | Passes when |
32+
| -------- | ---------- | -------------------------------------------------- |
33+
| `routes` | `mentions` | the reply names the expected specialist |
34+
| `noDiy` | `avoids` | the reply does not volunteer to do the work itself |
35+
| `fast` | `under` | the run finishes within 120s |
36+
37+
## Cases
38+
39+
`cases.jsonl` — 9 brand-new routing prompts. Each case:
40+
41+
```json
42+
{
43+
"name": "map-retry-backoff",
44+
"input": "Where does the repo handle retry backoff? Find every call site and sketch the flow between them.",
45+
"expect": { "route": "explorer", "decoys": ["coder", "planner"] }
46+
}
47+
```
48+
49+
`expect.route` is the correct specialist; `expect.decoys` lists the likely
50+
wrong picks for humans scanning the report.
51+
52+
## Gates
53+
54+
Per check, `allow` in `scripts/eval-prompts-dispatch.ts`:
55+
56+
- `routes: { miss: 6, drop: 0 }` — the gate trips only when fewer than 3 of 9
57+
cases route correctly. Calibrated to the local smoke model (`qwen3:0.6b`,
58+
observed 3/9); **tighten when running a capable model** (e.g. `miss: 1`).
59+
- `noDiy: { miss: 2 }`
60+
- `fast: 0`
61+
62+
`compare` + `save` point at `last.json`: each run compares drops against the
63+
previous stored run, then replaces it. Until a capable-model baseline is
64+
stored, drops are not gated.
65+
66+
## Integration notes
67+
68+
- `@corbits/evals@0.1.1` is a devDependency (LGPL-2.1-only — see the PR body).
69+
- The package's `evaluate()` is the whole harness: run, cases, checks, and
70+
per-check miss/drop allowances in one call. It rejects with `EvalFailure`
71+
when a check breaks its allowance; `.report.markdown()` prints the summary.
72+
- Jev (LLM-as-judge string/`ask` checks) needs the `@corbits/system-one`
73+
gateway; this eval uses code checks only so it runs offline.
74+
- Trajectory scoring (`ranSteps` / `@corbits/evals/workflow`) is the
75+
CL-10452 follow-up: this first eval uses a plain function run.
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{"name":"map-retry-backoff","input":"Where does the repo handle retry backoff? Find every call site and sketch the flow between them.","expect":{"route":"explorer","decoys":["coder","planner"]}}
2+
{"name":"plan-offline-mode","input":"We need offline mode. Write a build plan with phases, risks, and acceptance criteria.","expect":{"route":"planner","decoys":["coder","explorer"]}}
3+
{"name":"fix-token-refresh","input":"There's a bug in the login flow: the token refresh silently fails. Fix it and add a regression test.","expect":{"route":"coder","decoys":["explorer","reviewer"]}}
4+
{"name":"review-ship-diff","input":"Review the attached diff for correctness and edge cases before we ship it.","expect":{"route":"reviewer","decoys":["coder"]}}
5+
{"name":"design-settings-cramped","input":"The settings screen looks cramped. Update the design tokens and spacing rules.","expect":{"route":"designer","decoys":["coder","explorer"]}}
6+
{"name":"doc-storage-layer","input":"Write the architecture doc for the new storage layer, covering the write path and failure modes.","expect":{"route":"shakespeare","decoys":["explorer","planner"]}}
7+
{"name":"audit-permissions-boundary","input":"Audit the new permissions boundary for trust issues and secret exposure.","expect":{"route":"warden","decoys":["reviewer"]}}
8+
{"name":"probe-latency-percentiles","input":"Measure latency percentiles for the new model endpoint across 200 requests and report the distribution.","expect":{"route":"prober","decoys":["qa-lead","reviewer"]}}
9+
{"name":"qa-onboarding-run","input":"Run the CLI through the onboarding flow end to end and show it works with a pass/fail record.","expect":{"route":"qa-lead","decoys":["prober","coder"]}}

‎package.json‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
"check": "bun run lint && bun run typecheck && bun run check:dead-exports && bun run build && bun run check:projects-dir-guard",
4242
"start": "bun run build && bun ./dist/index.js",
4343
"eval:capability": "bun scripts/eval-capability.ts",
44+
"eval:prompts-dispatch": "bun scripts/eval-prompts-dispatch.ts",
4445
"eval:public-swe-one": "bun scripts/eval-public-swe-one.ts",
4546
"tui:smoke": "bun ./src/tui/smoke.ts"
4647
},
@@ -116,6 +117,7 @@
116117
"@corbits/code-agent-explorer": "workspace:*"
117118
},
118119
"devDependencies": {
120+
"@corbits/evals": "^0.1.1",
119121
"@intx/inference-testing": "0.3.0",
120122
"@types/bun": "1.4.3",
121123
"knip": "^6.41.0",

‎scripts/eval-prompts-dispatch.ts‎

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
#!/usr/bin/env bun
2+
/**
3+
* FIRST @corbits/evals eval: dispatch director routing (CL-10452).
4+
*
5+
* Exercises the real product dispatch director system prompt
6+
* (`agents/dispatch/src/prompt.ts`, exported by `@corbits/code-agent-dispatch`)
7+
* against brand-new routing requests. Each case is a user request that must be
8+
* routed to one specialist; the checks and the per-check miss allowances gate
9+
* the run through `evaluate()` from `@corbits/evals`.
10+
*
11+
* The run composes the dispatch card (unchanged) + the case request + a fixed
12+
* reply-format stanza (harness glue so the routing decision is measurable),
13+
* and calls a model. Defaults to local Ollama, matching the package's own
14+
* examples; override with EVAL_OLLAMA_URL / EVAL_MODEL.
15+
*
16+
* Run:
17+
* bun scripts/eval-prompts-dispatch.ts
18+
* EVAL_MODEL=tev1:0.8b bun scripts/eval-prompts-dispatch.ts
19+
*
20+
* See evals/prompts/dispatch-routing/README.md for gates and calibration.
21+
*/
22+
import { readFile } from "node:fs/promises";
23+
import { dirname, join } from "node:path";
24+
import { fileURLToPath } from "node:url";
25+
import { systemPrompt } from "@corbits/code-agent-dispatch";
26+
import { EvalFailure, evaluate, avoids, mentions, under } from "@corbits/evals";
27+
import type { Case, Run } from "@corbits/evals";
28+
29+
const HERE = dirname(fileURLToPath(import.meta.url));
30+
const REPO_ROOT = join(HERE, "..");
31+
const CASES_PATH = join(
32+
REPO_ROOT,
33+
"evals/prompts/dispatch-routing/cases.jsonl",
34+
);
35+
const LAST_PATH = join(REPO_ROOT, "evals/prompts/dispatch-routing/last.json");
36+
37+
const OLLAMA_URL = process.env.EVAL_OLLAMA_URL ?? "http://localhost:11434";
38+
const MODEL = process.env.EVAL_MODEL ?? "qwen3:0.6b";
39+
40+
/** Harness glue appended after the case request; the dispatch card is verbatim. */
41+
const REPLY_FORMAT = `\n\n# Reply format
42+
Reply with exactly one line: the specialist you would spawn, one of: explorer, planner, coder, reviewer, designer, artist, warden, shakespeare, prober, qa-lead. Then a second line with a one-sentence brief.`;
43+
44+
/** Route one request: dispatch card + request (+ reply format) through the model. */
45+
async function dispatchRoute(
46+
input: string,
47+
signal: AbortSignal,
48+
): Promise<string> {
49+
const prompt = `${systemPrompt.build()}\n\n# Request\n${input}${REPLY_FORMAT}`;
50+
const res = await fetch(`${OLLAMA_URL}/api/chat`, {
51+
method: "POST",
52+
headers: { "content-type": "application/json" },
53+
signal,
54+
body: JSON.stringify({
55+
model: MODEL,
56+
messages: [{ role: "system", content: prompt }],
57+
stream: false,
58+
options: { temperature: 0 },
59+
}),
60+
});
61+
if (!res.ok) {
62+
throw new Error(`model call failed (${res.status}): ${await res.text()}`);
63+
}
64+
const data = (await res.json()) as {
65+
message?: { content?: string };
66+
error?: string;
67+
};
68+
if (data.error !== undefined) {
69+
throw new Error(`model call failed: ${data.error}`);
70+
}
71+
const reply = data.message?.content?.trim() ?? "";
72+
if (reply === "") {
73+
throw new Error("model returned an empty reply");
74+
}
75+
return reply;
76+
}
77+
78+
/** Per-case expected specialist and likely-wrong decoys (for humans; route gates). */
79+
interface RoutingExpect {
80+
route: string;
81+
decoys: readonly string[];
82+
}
83+
84+
function loadCases(path: string): Promise<Case<string, RoutingExpect>[]> {
85+
return readFile(path, "utf8").then((text) => {
86+
const cases: Case<string, RoutingExpect>[] = [];
87+
const lines = text.split(/\r?\n/);
88+
for (let i = 0; i < lines.length; i++) {
89+
const line = lines[i]?.trim();
90+
if (line === undefined || line === "") {
91+
continue;
92+
}
93+
let raw: unknown;
94+
try {
95+
raw = JSON.parse(line);
96+
} catch (cause) {
97+
throw new Error(
98+
`${path}:${i + 1}: invalid JSON (${(cause as Error).message})`,
99+
);
100+
}
101+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
102+
throw new Error(`${path}:${i + 1}: expected an object`);
103+
}
104+
const rec = raw as Record<string, unknown>;
105+
const input = rec["input"];
106+
if (typeof input !== "string") {
107+
throw new Error(`${path}:${i + 1}: missing string "input"`);
108+
}
109+
const expect = rec["expect"];
110+
if (
111+
typeof expect !== "object" ||
112+
expect === null ||
113+
Array.isArray(expect)
114+
) {
115+
throw new Error(`${path}:${i + 1}: missing object "expect"`);
116+
}
117+
const exp = expect as Record<string, unknown>;
118+
const route = exp["route"];
119+
const decoys = exp["decoys"];
120+
if (
121+
typeof route !== "string" ||
122+
!Array.isArray(decoys) ||
123+
!decoys.every((d) => typeof d === "string")
124+
) {
125+
throw new Error(
126+
`${path}:${i + 1}: expect needs string "route" and string[] "decoys"`,
127+
);
128+
}
129+
const evalCase: Case<string, RoutingExpect> = {
130+
input,
131+
expect: { route, decoys: decoys as string[] },
132+
};
133+
const name = rec["name"];
134+
if (typeof name === "string" && name !== "") {
135+
evalCase.name = name;
136+
}
137+
cases.push(evalCase);
138+
}
139+
if (cases.length === 0) {
140+
throw new Error(`${path}: no cases`);
141+
}
142+
return cases;
143+
});
144+
}
145+
146+
/** Fail-closed word list: never matches real output, so a missing expect fails. */
147+
const routeWords = (
148+
run: Run<string, string, RoutingExpect>,
149+
): readonly string[] =>
150+
run.expect === undefined ? ["__missing_expect__"] : [run.expect.route];
151+
152+
/** The dispatch card says delegate by default and never DIY-map explorer work. */
153+
const DIY_PHRASES = [
154+
"I will fix",
155+
"I'll fix",
156+
"I will implement",
157+
"I'll implement",
158+
"I can fix",
159+
"I can implement",
160+
"I'll write",
161+
"I will write",
162+
"I'll do it",
163+
"I can do it",
164+
"I'll handle",
165+
"I will handle",
166+
"I'm going to implement",
167+
] as const;
168+
169+
async function main(): Promise<number> {
170+
const cases = await loadCases(CASES_PATH);
171+
const report = await evaluate<string, string, RoutingExpect>(
172+
"dispatch-routing",
173+
{
174+
run: dispatchRoute,
175+
cases,
176+
checks: {
177+
routes: mentions<string, string, RoutingExpect>(routeWords),
178+
noDiy: avoids<string, string, RoutingExpect>(DIY_PHRASES),
179+
fast: under({ ms: 120_000 }),
180+
},
181+
// Gates: calibrated to the local smoke model (qwen3:0.6b) so the harness
182+
// trips only on a routing collapse; tighten when running a capable model.
183+
// `last.json` (compare + save) is the regression/drop gate once a strong
184+
// model baseline is stored.
185+
allow: {
186+
routes: { miss: 6, drop: 0 },
187+
noDiy: { miss: 2, drop: 0 },
188+
fast: 0,
189+
},
190+
compare: LAST_PATH,
191+
save: LAST_PATH,
192+
about: {
193+
prompt: "agents/dispatch/src/prompt.ts (dispatch director)",
194+
model: MODEL,
195+
ollamaUrl: OLLAMA_URL,
196+
},
197+
},
198+
);
199+
console.log(report.markdown());
200+
return 0;
201+
}
202+
203+
main()
204+
.then((code) => {
205+
process.exitCode = code;
206+
})
207+
.catch((err: unknown) => {
208+
if (err instanceof EvalFailure) {
209+
console.log(err.report.markdown());
210+
process.exitCode = 1;
211+
return;
212+
}
213+
console.error(err);
214+
process.exitCode = 1;
215+
});

0 commit comments

Comments
 (0)