-
Notifications
You must be signed in to change notification settings - Fork 27
fix(evals): portable hooks + OpenRouter CI + code-exec grader — eval system review-ready #65
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
skillets-the-skill-intern
wants to merge
3
commits into
setup-waza
Choose a base branch
from
waza-openrouter-hooks
base: setup-waza
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
957d9fe
fix(evals): make before_run hooks portable + headless-runnable
skillets-the-skill-intern[bot] 5d60778
ci(evals): rework eval workflow to run waza on OpenRouter (no azd)
skillets-the-skill-intern[bot] 163bd18
feat(evals): add code-execution verification grader (closes gap #4)
skillets-the-skill-intern[bot] File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,98 @@ | ||
| #!/usr/bin/env bun | ||
|
|
||
| import { spawnSync } from "node:child_process"; | ||
| import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; | ||
| import { join } from "node:path"; | ||
|
|
||
| // CI eval runner for OpenRouterTeam/skills. | ||
| // Runs every authored eval suite under evals/ through waza (driven against | ||
| // OpenRouter — see .github/workflows/eval.yml for the COPILOT_PROVIDER_* env). | ||
| // Skips stub suites (description starts with "TODO: scaffolding only"), so CI | ||
| // only grades suites with real tasks. Exits non-zero if any suite fails. | ||
|
|
||
| const STUB_MARKER = "TODO: scaffolding only"; | ||
| const REPO_ROOT = process.cwd(); | ||
| const EVALS_DIR = join(REPO_ROOT, "evals"); | ||
|
|
||
| function parseOutputDir(argv: string[]): string { | ||
| const i = argv.indexOf("--output-dir"); | ||
| return i >= 0 && argv[i + 1] ? argv[i + 1] : "./results"; | ||
| } | ||
|
|
||
| function isStub(specPath: string): boolean { | ||
| const body = readFileSync(specPath, "utf-8"); | ||
| return body.includes(STUB_MARKER); | ||
| } | ||
|
|
||
| function discoverSuites(): { skill: string; spec: string }[] { | ||
| const out: { skill: string; spec: string }[] = []; | ||
| for (const entry of readdirSync(EVALS_DIR)) { | ||
| if (entry.startsWith("_")) { | ||
| continue; | ||
| } | ||
| const spec = join(EVALS_DIR, entry, "eval.yaml"); | ||
| if (!existsSync(spec)) { | ||
| continue; | ||
| } | ||
| if (isStub(spec)) { | ||
| console.error(`⏭ skip (stub): ${entry}`); | ||
| continue; | ||
| } | ||
| out.push({ skill: entry, spec }); | ||
| } | ||
| return out.sort((a, b) => a.skill.localeCompare(b.skill)); | ||
| } | ||
|
|
||
| function main(): void { | ||
| const outputDir = parseOutputDir(process.argv.slice(2)); | ||
| mkdirSync(outputDir, { recursive: true }); | ||
|
|
||
| const model = process.env.EVAL_MODEL ?? "anthropic/claude-opus-4.8"; | ||
| const suites = discoverSuites(); | ||
| if (suites.length === 0) { | ||
| console.error("❌ no authored eval suites found under evals/"); | ||
| process.exit(1); | ||
| } | ||
|
|
||
| console.error(`▶ running ${suites.length} suite(s) on ${model}\n`); | ||
| const results: { skill: string; code: number }[] = []; | ||
|
|
||
| for (const { skill, spec } of suites) { | ||
| console.error(`\n${"=".repeat(60)}\n▶ ${skill}\n${"=".repeat(60)}`); | ||
| const run = spawnSync( | ||
| "waza", | ||
| [ | ||
| "run", | ||
| spec, | ||
| "--judge-model", | ||
| model, | ||
| "--output-dir", | ||
| join(outputDir, skill), | ||
| "--no-update-check", | ||
| "-v", | ||
| ], | ||
| { cwd: REPO_ROOT, env: process.env, stdio: ["ignore", "inherit", "inherit"] }, | ||
| ); | ||
| results.push({ skill, code: run.status ?? 1 }); | ||
| } | ||
|
|
||
| const failed = results.filter((r) => r.code !== 0); | ||
| const summary = { | ||
| model, | ||
| total: results.length, | ||
| passed: results.length - failed.length, | ||
| failed: failed.length, | ||
| suites: results, | ||
| }; | ||
| writeFileSync(join(outputDir, "ci-summary.json"), JSON.stringify(summary, null, 2)); | ||
|
|
||
| console.error(`\n${"=".repeat(60)}\n CI EVAL SUMMARY\n${"=".repeat(60)}`); | ||
| for (const r of results) { | ||
| console.error(` ${r.code === 0 ? "✓" : "✗"} ${r.skill}`); | ||
| } | ||
| console.error(`\n${summary.passed}/${summary.total} suites passed on ${model}`); | ||
|
|
||
| process.exit(failed.length > 0 ? 1 : 0); | ||
| } | ||
|
|
||
| main(); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,44 @@ | ||
| # `_graders` — shared waza grader helpers | ||
|
|
||
| Reusable `program`-grader scripts that any task can reference. | ||
|
|
||
| ## `verify-code-runs.ts` — does the produced code actually run? | ||
|
|
||
| Closes the "does the artifact run" gap from OpenAI's | ||
| [skill-eval methodology](https://developers.openai.com/blog/eval-skills): for | ||
| *building* skills, grading the response text for the right patterns isn't enough | ||
| — the code the agent produces should actually parse/type-check. | ||
|
|
||
| ```yaml | ||
| graders: | ||
| - type: program | ||
| name: produced_code_parses | ||
| config: | ||
| command: "bun" | ||
| args: ["evals/_graders/verify-code-runs.ts"] | ||
| timeout: 60 | ||
| ``` | ||
|
|
||
| ### How it works | ||
|
|
||
| waza pipes the agent's full final response to a `program` grader on **stdin** | ||
| (exit 0 = pass, non-zero = fail). This helper: | ||
|
|
||
| 1. Extracts fenced code blocks from the response. | ||
| 2. Writes JS/TS blocks to temp files. | ||
| 3. Syntax-checks JS with `node --check`; type-checks TS with | ||
| `npx tsc --noEmit --skipLibCheck` (only fails on `TS1xxx` *syntax* errors, so | ||
| missing-import noise in a snippet doesn't false-fail). | ||
| 4. Exits 0 only if every code block parses; 1 otherwise (including "no code | ||
| block found", which is itself a failure for a code-producing task). | ||
|
|
||
| ### waza `program` grader facts (learned the hard way) | ||
|
|
||
| - The agent's response arrives on **stdin** — not as workspace files. The | ||
| copilot agent-under-test answers in text and does **not** populate | ||
| `$WAZA_WORKSPACE_DIR` by default. | ||
| - waza **swallows grader stderr on success** and only surfaces it on failure. | ||
| Exit non-zero (temporarily) if you need to see a grader's diagnostics. | ||
| - Reference a committed script via a single clean `args` entry rather than | ||
| inlining bash — inline shell/YAML escaping is brittle (same lesson as the | ||
| `_hooks/` helpers). |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,93 @@ | ||
| #!/usr/bin/env bun | ||
|
|
||
| import { spawnSync } from "node:child_process"; | ||
| import { mkdtempSync, writeFileSync } from "node:fs"; | ||
| import { tmpdir } from "node:os"; | ||
| import { join } from "node:path"; | ||
|
|
||
| // waza `program` grader: verifies that the code the agent produced actually | ||
| // parses / type-checks, not just that it looks right to a judge (gap #4 from | ||
| // the OpenAI eval methodology — "does the artifact run"). | ||
| // | ||
| // waza pipes the agent's full response to this grader on STDIN. We extract | ||
| // fenced code blocks, write JS/TS to temp files, and syntax/type-check them. | ||
| // Exit 0 = all blocks valid (pass); exit 1 = a block failed to parse (fail). | ||
| // | ||
| // Usage in a task: | ||
| // - type: program | ||
| // name: produced_code_parses | ||
| // config: | ||
| // command: "bun" | ||
| // args: ["evals/_graders/verify-code-runs.ts"] | ||
| // timeout: 60 | ||
|
|
||
| interface Block { | ||
| lang: string; | ||
| code: string; | ||
| } | ||
|
|
||
| function extractBlocks(md: string): Block[] { | ||
| const blocks: Block[] = []; | ||
| const fence = /```([a-zA-Z0-9_+-]*)\n([\s\S]*?)```/g; | ||
| let m: RegExpExecArray | null; | ||
| // biome-ignore lint/suspicious/noAssignInExpressions: standard regex exec loop | ||
| while ((m = fence.exec(md)) !== null) { | ||
| blocks.push({ lang: (m[1] || "").toLowerCase(), code: m[2] }); | ||
| } | ||
| return blocks; | ||
| } | ||
|
|
||
| const JS_LANGS = new Set(["js", "javascript", "jsx", "mjs", "cjs", "node"]); | ||
| const TS_LANGS = new Set(["ts", "typescript", "tsx"]); | ||
|
|
||
| function checkJs(file: string): { ok: boolean; err: string } { | ||
| const r = spawnSync("node", ["--check", file], { encoding: "utf-8" }); | ||
| return { ok: r.status === 0, err: r.stderr || "" }; | ||
| } | ||
|
|
||
| function checkTs(file: string): { ok: boolean; err: string } { | ||
| // Type-check without emitting; skip lib checks for speed and to tolerate | ||
| // missing ambient deps in snippet-sized code. | ||
| const r = spawnSync( | ||
| "npx", | ||
| ["--yes", "tsc", "--noEmit", "--skipLibCheck", "--allowJs", "--moduleResolution", "node", file], | ||
| { encoding: "utf-8", timeout: 50_000 }, | ||
| ); | ||
| // tsc errors about missing imports are common in snippets and not what we're | ||
| // testing; we only fail on genuine syntax errors (TS1xxx). | ||
| const syntaxError = /error TS1\d{3}/.test(r.stdout || ""); | ||
| return { ok: !syntaxError, err: syntaxError ? r.stdout : "" }; | ||
| } | ||
|
|
||
| async function main(): Promise<void> { | ||
| const input = await Bun.stdin.text(); | ||
| const blocks = extractBlocks(input); | ||
| const codeBlocks = blocks.filter((b) => JS_LANGS.has(b.lang) || TS_LANGS.has(b.lang)); | ||
|
|
||
| if (codeBlocks.length === 0) { | ||
| process.stderr.write("verify-code-runs: no JS/TS code blocks found in agent output\n"); | ||
| process.exit(1); | ||
| } | ||
|
|
||
| const dir = mkdtempSync(join(tmpdir(), "verify-code-")); | ||
| let failures = 0; | ||
| for (const [i, b] of codeBlocks.entries()) { | ||
| const isTs = TS_LANGS.has(b.lang); | ||
| const file = join(dir, `block-${i}.${isTs ? "ts" : "js"}`); | ||
| writeFileSync(file, b.code); | ||
| const res = isTs ? checkTs(file) : checkJs(file); | ||
| if (!res.ok) { | ||
| failures++; | ||
| process.stderr.write(`verify-code-runs: block ${i} (${b.lang}) FAILED:\n${res.err.slice(0, 500)}\n`); | ||
| } | ||
| } | ||
|
|
||
| if (failures > 0) { | ||
| process.stderr.write(`verify-code-runs: ${failures}/${codeBlocks.length} block(s) failed to parse\n`); | ||
| process.exit(1); | ||
| } | ||
| process.stderr.write(`verify-code-runs: all ${codeBlocks.length} code block(s) parse cleanly\n`); | ||
| process.exit(0); | ||
| } | ||
|
|
||
| main(); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,41 @@ | ||
| # `_hooks` — shared waza `before_run` helpers | ||
|
|
||
| ## `prepare-skill.ts` | ||
|
|
||
| Syncs a skill into `~/.agents/skills/<name>/` (where the copilot agent discovers | ||
| skills) and runs `npm install` if the skill ships bundled scripts. | ||
|
|
||
| ```yaml | ||
| hooks: | ||
| before_run: | ||
| - command: "bun evals/_hooks/prepare-skill.ts <skill-name>" | ||
| ``` | ||
|
|
||
| ### Two gotchas this helper exists to handle | ||
|
|
||
| 1. **waza runs hook `command:` entries WITHOUT a shell.** `cd X && cmd` fails with | ||
| `cd: executable file not found in $PATH`. Each hook must be a single executable | ||
| invocation — no `&&`, no shell builtins. This helper does the multi-step work | ||
| (mkdir + rsync + npm install) in one process. | ||
|
|
||
| 2. **Hooks run from waza's invocation cwd (the repo root), not the eval dir.** So the | ||
| path is `evals/_hooks/prepare-skill.ts`, not `../_hooks/...`. The helper itself | ||
| resolves the repo root from its own file location, so it's robust regardless. | ||
|
|
||
| ## Running evals against OpenRouter | ||
|
|
||
| waza's embedded GitHub Copilot CLI has native BYOK custom-provider support, and | ||
| OpenRouter is OpenAI-compatible — so waza runs on OpenRouter with **zero adapter | ||
| code**. Set these env vars before `waza run` (the skillet `skill-eval` skill's | ||
| `run-waza.ts` sets them automatically): | ||
|
|
||
| ``` | ||
| COPILOT_PROVIDER_TYPE=openai | ||
| COPILOT_PROVIDER_BASE_URL=https://openrouter.ai/api/v1 | ||
| COPILOT_PROVIDER_WIRE_API=completions # NOT "openai" — waza rejects that | ||
| COPILOT_PROVIDER_API_KEY=$OPENROUTER_API_KEY | ||
| COPILOT_MODEL=anthropic/claude-opus-4.8 # BYOK refuses to start without an explicit model | ||
| COPILOT_DISABLE_KEYTARCLI=1 | ||
| ``` | ||
|
|
||
| Model slugs must be real OpenRouter IDs (family-first, e.g. `anthropic/claude-opus-4.8`). |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[suggestion]
isStub()matches the marker anywhere in the file, but the file-header comment says stubs are gated on the description starting with it — a stray match elsewhere would silently skip a real suite.Details
Why: Line 10 documents the contract as "Skips stub suites (description starts with ...)", but
body.includes(STUB_MARKER)returns true if the marker string appears ANYWHERE in the YAML — a task prompt, a grader rubric, or a comment that happens to quote "TODO: scaffolding only". An authored suite that mentions the phrase in passing would be silently dropped from CI, and the failure mode is invisible (it just printsskip (stub)and exits 0). Today every stub places the marker in the top-leveldescription:, so it works — but the check is looser than its stated intent.Fix: Parse the YAML and test the
descriptionfield specifically, e.g. readdescriptionand checkdescription.trimStart().startsWith(STUB_MARKER), falling back to the current whole-file scan only if parsing fails. A lighter-touch alternative: anchor the marker to a line start (/^\s*TODO: scaffolding only/m) so only a leading description line counts.Prompt for agents
Reviewed at
5d60778