Files
magnus919_agent-skills/eval_runner/runner.py
T
Magnus HedemarkGitHubmagnus919 <magnus919>
0a80bda800 feat: add release-grade repeated and rubric-based skill evaluation (#136)
Add release evaluation layer on top of deterministic paired execution:

- schemas/release-eval-v1.schema.json: release report schema with freeze
  snapshot, per-case trial aggregation, rubric graders, blinded pairwise
  comparison, calibration tracking, and PASS/CONDITIONAL/HOLD/BLOCK outcomes
- eval_runner/release.py: core module for multi-trial aggregation, versioned
  rubric graders with abstain/insufficient-evidence, pairwise planning with
  position randomization and order-reversal testing, calibration records,
  and release decision computation
- eval_runner/tests/test_release.py: 34 tests covering all acceptance criteria
- schemas/evals-v1.schema.json: optional case_set field (dev/regression/release)
- eval_runner/models.py: case_set on EvalCase
- eval_runner/runner.py: load case_set from manifest
- .github/workflows/skill-eval.yml: run release tests in CI

Gate semantics: hard invariants (privacy, auth, destructive) tolerate zero
violations and cannot be averaged away. Missing evidence produces HOLD, not
PASS. Uncalibrated judge results are advisory only.

Closes #106

Co-authored-by: magnus919 <magnus919>
2026-07-24 23:26:07 -04:00

183 lines
5.6 KiB
Python

"""Eval runner CLI — loads cases, dispatches to an adapter, writes manifests."""
from __future__ import annotations
import argparse
import json
import sys
from datetime import datetime, timezone
from pathlib import Path
from .adapter import HarnessAdapter
from .cli_adapter import CliSubprocessAdapter
from .fake_adapter import FakeAdapter
from .manifest import build_manifest, write_manifest
from .models import AdapterInput, EvalCase
def load_cases(manifest_path: Path) -> list[EvalCase]:
data = json.loads(manifest_path.read_text(encoding="utf-8"))
cases: list[EvalCase] = []
for entry in data.get("evals", []):
cases.append(
EvalCase(
id=entry["id"],
prompt=entry["prompt"],
expected_output=entry["expected_output"],
assertions=entry.get("assertions", []),
files=entry.get("files", []),
case_set=entry.get("case_set", "dev"),
)
)
return cases
def resolve_skill_path(manifest_path: Path) -> Path:
return manifest_path.parent.parent
def build_adapter(args: argparse.Namespace) -> HarnessAdapter:
if args.adapter == "fake":
return FakeAdapter()
if args.adapter == "cli":
if not args.command:
print("error: --command required for cli adapter", file=sys.stderr)
raise SystemExit(2)
import shlex
command = shlex.split(args.command)
return CliSubprocessAdapter(
command,
prompt_mode=args.prompt_mode,
prompt_flag=args.prompt_flag,
timeout_seconds=args.timeout,
extra_args=args.extra_args.split() if args.extra_args else [],
)
print(f"error: unknown adapter '{args.adapter}'", file=sys.stderr)
raise SystemExit(2)
def run_trial(
adapter: HarnessAdapter,
case: EvalCase,
skill_path: Path,
output_dir: Path,
model: str,
) -> Path:
work_dir = output_dir / "work" / case.id
case_output_dir = output_dir / "trials" / case.id
adapter_input = AdapterInput(
skill_path=skill_path,
case=case,
work_dir=work_dir,
output_dir=case_output_dir,
model=model,
limits={"timeout_seconds": 120, "network_policy": "unspecified"},
)
started_at = datetime.now(timezone.utc)
result = adapter.execute(adapter_input)
finished_at = datetime.now(timezone.utc)
manifest = build_manifest(
adapter_name=adapter.name,
adapter_version=adapter.version,
harness_name=adapter.name,
harness_version=adapter.version,
model_provider="unspecified" if not model else model.split("/")[0],
model_id=model or "unspecified",
adapter_input=adapter_input,
adapter_output=result,
started_at=started_at,
finished_at=finished_at,
)
return write_manifest(manifest, output_dir / "manifests")
def main() -> int:
parser = argparse.ArgumentParser(
prog="eval-runner",
description="Run eval cases against a candidate skill via a harness adapter.",
)
parser.add_argument(
"manifest",
type=Path,
help="path to an evals.json manifest",
)
parser.add_argument(
"--adapter",
choices=["fake", "cli"],
default="fake",
help="adapter to use (default: fake)",
)
parser.add_argument(
"--output-dir",
type=Path,
default=Path("eval-output"),
help="directory for run artifacts (default: eval-output)",
)
parser.add_argument(
"--model",
default="",
help="model identifier (e.g. anthropic/claude-sonnet-4-20250514)",
)
parser.add_argument(
"--case",
dest="case_id",
default=None,
help="run only this case ID",
)
parser.add_argument("--command", default=None, help="CLI command for cli adapter")
parser.add_argument("--prompt-mode", default="stdin", choices=["stdin", "arg"])
parser.add_argument("--prompt-flag", default="--prompt")
parser.add_argument("--timeout", type=int, default=120)
parser.add_argument("--extra-args", default=None)
args = parser.parse_args()
manifest_path = args.manifest.resolve()
if not manifest_path.is_file():
print(f"error: manifest not found: {manifest_path}", file=sys.stderr)
return 2
cases = load_cases(manifest_path)
if not cases:
print(f"error: no cases found in {manifest_path}", file=sys.stderr)
return 2
if args.case_id:
cases = [c for c in cases if c.id == args.case_id]
if not cases:
print(f"error: case '{args.case_id}' not found", file=sys.stderr)
return 2
skill_path = resolve_skill_path(manifest_path)
adapter = build_adapter(args)
output_dir = args.output_dir.resolve()
print(f"adapter: {adapter.name} v{adapter.version}")
print(f"skill: {skill_path.name}")
print(f"cases: {len(cases)}")
print(f"output: {output_dir}")
print()
failures = 0
for case in cases:
print(f" [{case.id}] running...", end=" ", flush=True)
manifest_file = run_trial(adapter, case, skill_path, output_dir, args.model)
status = json.loads(manifest_file.read_text(encoding="utf-8"))["status"]
if status == "completed":
print(f"ok -> {manifest_file.name}")
else:
print(f"{status} -> {manifest_file.name}")
failures += 1
print()
print(f"done: {len(cases)} trial(s), {failures} failure(s)")
return 1 if failures else 0
if __name__ == "__main__":
raise SystemExit(main())