basic BMAD setup

This commit is contained in:
Ebbe Baß
2026-10-15 08:57:23 +02:00
parent 44029127dc
commit 5e747654b9
438 changed files with 41818 additions and 0 deletions
@@ -0,0 +1,254 @@
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.11"
# ///
"""Variance benchmark: summarize a metric across N runs, and compare two configs.
A single skill run is noisy. Running the same case N times and summarizing the
spread tells you whether a difference between two versions is real or just noise.
This script computes, per numeric metric, the mean, the sample standard deviation
(n-1, the unbiased estimator for a sample), the min, and the max across N runs.
Given two such config summaries it reports the delta on each shared metric so a
"did the change help" question gets a number instead of a guess.
Input shapes accepted for a single config:
- a list of run records, each a flat dict of metric -> number
[{"elapsed_s": 12.1, "total_tokens": 800}, {"elapsed_s": 11.4, ...}]
- {"runs": [ ...records... ]}
- a directory of run folders, each holding timing.json files written by
run_evals.py (the script reads every timing.json under the directory and
treats each as one run record)
Usage:
Summarize one config across its runs:
uv run aggregate_benchmark.py --runs CONFIG_A.json
uv run aggregate_benchmark.py --runs RUN_DIR/ (reads timing.json files)
Compare two configs (each summarized, then delta = B - A):
uv run aggregate_benchmark.py --baseline A.json --variant B.json
Self-test on a known fixture (no external input needed):
uv run aggregate_benchmark.py --self-test
Output is one JSON object on stdout.
"""
from __future__ import annotations
import argparse
import json
import math
import sys
from pathlib import Path
NUMERIC = (int, float)
# --- statistics -------------------------------------------------------------
def sample_stddev(values: list[float]) -> float:
"""Sample standard deviation using n-1 (Bessel's correction).
Returns 0.0 for fewer than two values, where the sample variance is
undefined and reporting zero spread is the least surprising choice.
"""
n = len(values)
if n < 2:
return 0.0
mean = sum(values) / n
var = sum((x - mean) ** 2 for x in values) / (n - 1)
return math.sqrt(var)
def summarize_metric(values: list[float]) -> dict:
return {
"n": len(values),
"mean": (sum(values) / len(values)) if values else 0.0,
"stddev": sample_stddev(values),
"min": min(values) if values else 0.0,
"max": max(values) if values else 0.0,
}
def collect_numeric_metrics(records: list[dict]) -> dict[str, list[float]]:
"""Group every numeric field across records by metric name."""
by_metric: dict[str, list[float]] = {}
for rec in records:
if not isinstance(rec, dict):
continue
for key, val in rec.items():
if isinstance(val, bool):
continue # bools are ints in Python; not a metric
if isinstance(val, NUMERIC):
by_metric.setdefault(key, []).append(float(val))
return by_metric
def summarize_config(records: list[dict]) -> dict:
by_metric = collect_numeric_metrics(records)
return {
"runs": len(records),
"metrics": {name: summarize_metric(vals) for name, vals in sorted(by_metric.items())},
}
def delta_configs(baseline: dict, variant: dict) -> dict:
"""Per shared metric, delta = variant.mean - baseline.mean, plus context."""
b_metrics = baseline.get("metrics", {})
v_metrics = variant.get("metrics", {})
shared = sorted(set(b_metrics) & set(v_metrics))
out: dict[str, dict] = {}
for name in shared:
b = b_metrics[name]
v = v_metrics[name]
diff = v["mean"] - b["mean"]
pct = (diff / b["mean"] * 100.0) if b["mean"] != 0 else None
out[name] = {
"baseline_mean": b["mean"],
"variant_mean": v["mean"],
"delta": diff,
"delta_pct": pct,
"baseline_stddev": b["stddev"],
"variant_stddev": v["stddev"],
}
return out
# --- input loading ----------------------------------------------------------
def load_records(path: Path) -> tuple[list[dict], int]:
"""Run records from a JSON file, a {'runs': [...]} file, or a dir of timing.json files.
Returns (completed records, number excluded). A record whose `status` is anything but
"ok" did not finish, so its elapsed time would skew the mean; it is left out and counted.
"""
if path.is_dir():
data: list = []
for f in sorted(path.rglob("timing.json")):
try:
data.append(json.loads(f.read_text(encoding="utf-8")))
except (OSError, json.JSONDecodeError):
continue
else:
data = json.loads(path.read_text(encoding="utf-8"))
if isinstance(data, dict) and "runs" in data:
data = data["runs"]
if not isinstance(data, list):
raise ValueError(f"expected a list of run records in {path}")
records = [r for r in data if isinstance(r, dict)]
completed = [r for r in records if r.get("status", "ok") == "ok"]
return completed, len(records) - len(completed)
# --- self-test --------------------------------------------------------------
def run_self_test() -> int:
"""Verify mean/stddev/min/max/delta on a known fixture."""
config_a = [
{"elapsed_s": 10.0, "total_tokens": 100},
{"elapsed_s": 12.0, "total_tokens": 200},
{"elapsed_s": 14.0, "total_tokens": 300},
]
summary_a = summarize_config(config_a)
el = summary_a["metrics"]["elapsed_s"]
# mean of 10,12,14 = 12; n-1 stddev = sqrt(((-2)^2+0+2^2)/2)=sqrt(4)=2
assert el["n"] == 3, el
assert abs(el["mean"] - 12.0) < 1e-9, el
assert abs(el["stddev"] - 2.0) < 1e-9, el
assert el["min"] == 10.0 and el["max"] == 14.0, el
tok = summary_a["metrics"]["total_tokens"]
# mean of 100,200,300 = 200; n-1 stddev = sqrt((10000+0+10000)/2)=100
assert abs(tok["mean"] - 200.0) < 1e-9, tok
assert abs(tok["stddev"] - 100.0) < 1e-9, tok
# single value -> stddev 0
one = summarize_config([{"x": 5}])
assert one["metrics"]["x"]["stddev"] == 0.0, one
# bools are not treated as metrics
with_bool = summarize_config([{"ok": True, "x": 1}, {"ok": False, "x": 3}])
assert "ok" not in with_bool["metrics"], with_bool
assert abs(with_bool["metrics"]["x"]["mean"] - 2.0) < 1e-9, with_bool
# delta: variant slower by 3s on mean, faster question answered by sign
config_b = [
{"elapsed_s": 13.0, "total_tokens": 90},
{"elapsed_s": 15.0, "total_tokens": 110},
{"elapsed_s": 17.0, "total_tokens": 100},
]
summary_b = summarize_config(config_b)
d = delta_configs(summary_a, summary_b)
# elapsed mean: A=12, B=15 -> delta +3, pct +25%
assert abs(d["elapsed_s"]["delta"] - 3.0) < 1e-9, d
assert abs(d["elapsed_s"]["delta_pct"] - 25.0) < 1e-9, d
# tokens mean: A=200, B=100 -> delta -100, pct -50%
assert abs(d["total_tokens"]["delta"] + 100.0) < 1e-9, d
assert abs(d["total_tokens"]["delta_pct"] + 50.0) < 1e-9, d
print(
json.dumps(
{
"self_test": "passed",
"checked": [
"mean",
"stddev_n_minus_1",
"min",
"max",
"single_value_stddev",
"bool_excluded",
"delta",
"delta_pct",
],
}
)
)
return 0
# --- main -------------------------------------------------------------------
def main(argv: list[str] | None = None) -> int:
p = argparse.ArgumentParser(
description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
p.add_argument("--runs", type=Path, help="summarize one config (JSON file or dir of timing.json)")
p.add_argument("--baseline", type=Path, help="baseline config for a two-config comparison")
p.add_argument("--variant", type=Path, help="variant config for a two-config comparison")
p.add_argument("--self-test", action="store_true", help="run the built-in fixture self-test and exit")
args = p.parse_args(argv)
if args.self_test:
return run_self_test()
if args.baseline and args.variant:
b_records, b_excluded = load_records(args.baseline)
v_records, v_excluded = load_records(args.variant)
b = summarize_config(b_records)
v = summarize_config(v_records)
out = {
"baseline": b,
"variant": v,
"delta": delta_configs(b, v),
"excluded_incomplete": {"baseline": b_excluded, "variant": v_excluded},
}
print(json.dumps(out, indent=2))
return 0
if args.runs:
records, excluded = load_records(args.runs)
out = summarize_config(records)
out["excluded_incomplete"] = excluded
print(json.dumps(out, indent=2))
return 0
p.error("provide --runs, or both --baseline and --variant, or --self-test")
return 2
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,119 @@
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.11"
# ///
"""Convert eval cases between the skill-creator and the runner formats.
skill-creator's evals.json:
{"skill_name": "...", "evals": [{"id": 1, "prompt": "...", "expected_output": "...",
"files": [...], "expectations": [...]}]}
The runner's cases.json:
[{"id": "1", "input": "...", "rubric": [...], "state_prefix": null, "files": [...]}]
Toward the runner, `expected_output` lands at the end of the rubric as
"output matches: <text>". Toward skill-creator, that rubric line becomes
`expected_output` again and a numeric id becomes an int.
Usage:
uv run convert_cases.py EVALS.json [--to runner|skill-creator] [--output OUT.json]
[--skill-name NAME]
Without --output the result goes to stdout.
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
OUTPUT_PREFIX = "output matches: "
def to_runner(data: dict) -> list[dict]:
cases = []
for ev in data.get("evals", []):
rubric = [str(e) for e in ev.get("expectations", [])]
expected = ev.get("expected_output")
if expected:
rubric.append(f"{OUTPUT_PREFIX}{expected}")
cases.append(
{
"id": str(ev["id"]),
"input": ev.get("prompt", ""),
"rubric": rubric,
"state_prefix": None,
"files": list(ev.get("files", [])),
}
)
return cases
def to_skill_creator(cases: list[dict], skill_name: str) -> dict:
evals = []
for case in cases:
raw_id = str(case["id"])
expectations = []
expected_output = ""
for item in case.get("rubric", []):
text = str(item)
if text.startswith(OUTPUT_PREFIX) and not expected_output:
expected_output = text[len(OUTPUT_PREFIX) :]
else:
expectations.append(text)
evals.append(
{
"id": int(raw_id) if raw_id.isdigit() else raw_id,
"prompt": case.get("input", ""),
"expected_output": expected_output,
"files": list(case.get("files", [])),
"expectations": expectations,
}
)
return {"skill_name": skill_name, "evals": evals}
def load_cases(data: object) -> list[dict]:
if isinstance(data, dict) and "cases" in data:
data = data["cases"]
if not isinstance(data, list):
raise ValueError("runner cases must be a list or {'cases': [...]}")
return data
def default_skill_name(path: Path) -> str:
# <skill>/evals/cases.json names the skill two levels up.
parent = path.resolve().parent
return parent.parent.name if parent.name == "evals" else parent.name
def main(argv: list[str] | None = None) -> int:
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
p.add_argument("input", type=Path, help="evals.json (skill-creator) or cases.json (runner)")
p.add_argument("--to", choices=("runner", "skill-creator"), default="runner")
p.add_argument("--output", type=Path, default=None, help="write here instead of stdout")
p.add_argument("--skill-name", default=None, help="skill_name for --to skill-creator")
args = p.parse_args(argv)
data = json.loads(args.input.read_text(encoding="utf-8"))
if args.to == "runner":
if not isinstance(data, dict) or "evals" not in data:
print("error: skill-creator input must be an object with an 'evals' list", file=sys.stderr)
return 2
result: object = to_runner(data)
else:
result = to_skill_creator(load_cases(data), args.skill_name or default_skill_name(args.input))
text = json.dumps(result, indent=2, ensure_ascii=False) + "\n"
if args.output:
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(text, encoding="utf-8")
else:
sys.stdout.write(text)
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,317 @@
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.11"
# ///
"""What run_evals.py and run_triggers.py share: the harness, the clean room, containment, run folders.
The harness is the agent CLI the evals run through, described by four keys the model that first
runs an eval records in this skill's customization (`[workflow.harness]`):
command argv for one non-interactive run; "{prompt}" is the input, "{cwd}" the workspace.
skill_dir the folder under the workspace the CLI reads skills from.
env env vars for the run: a value is set as given, with "~" meaning the fresh HOME;
an empty value forwards the host's value when it has one.
home_files files or folders under the host home brought into the fresh HOME at the same
path, so a CLI logged in through a file or keychain stays logged in.
The runners read them through the project's customization resolver, or from `--harness <json>`
for a project without BMad. A run happens in a clean room: a temporary folder outside the
project holding the workspace and the fresh HOME, so the project's instruction files and
installed skills are not ancestors of the run. The workspace is copied back afterwards.
"""
from __future__ import annotations
import json
import os
import re
import shutil
import subprocess
import sys
import tempfile
import time
from collections.abc import Callable, Mapping
from datetime import UTC, datetime
from pathlib import Path
SKILL_ROOT = Path(__file__).resolve().parents[1]
HARNESS_KEY = "workflow.harness"
DEFAULT_SKILL_DIR = ".agents/skills"
SAFE_NAME_RE = re.compile(r"[^A-Za-z0-9._-]+")
# A subprocess on Windows cannot start without these; nothing else crosses uninvited.
PLATFORM_ENV = ("SYSTEMROOT", "COMSPEC", "PATHEXT", "TEMP", "TMP") if sys.platform == "win32" else ()
def utc_now_iso() -> str:
return datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
def write_json(path: Path, data: object) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
def read_json(path: Path) -> object:
return json.loads(path.read_text(encoding="utf-8"))
def find_project_root(start: Path) -> Path | None:
"""Nearest ancestor holding `_bmad/`, else the nearest holding `.git`."""
git_root = None
current = start.resolve()
while True:
if (current / "_bmad").is_dir():
return current
if git_root is None and (current / ".git").exists():
git_root = current
if current.parent == current:
return git_root
current = current.parent
# --- harness -----------------------------------------------------------------
def validate_harness(harness: object) -> dict:
if not isinstance(harness, dict):
raise ValueError("harness must be a table")
command = harness.get("command")
if not isinstance(command, list) or not command or not all(isinstance(t, str) for t in command):
raise ValueError("harness.command must be a non-empty list of strings")
if not any("{prompt}" in t for t in command):
raise ValueError("harness.command needs a {prompt} token")
if not isinstance(harness.get("skill_dir", DEFAULT_SKILL_DIR), str):
raise ValueError("harness.skill_dir must be a string")
env = harness.get("env", {})
if not isinstance(env, dict) or not all(isinstance(v, str) for v in env.values()):
raise ValueError('harness.env is a table of var = "value" ("" forwards the host value, "~" is the fresh HOME)')
if not isinstance(harness.get("home_files", []), list):
raise ValueError("harness.home_files must be a list")
return harness
def resolve_harness(project_root: Path | None, explicit: Path | None) -> tuple[dict | None, str]:
"""The harness table and where it came from; (None, why) when there is none.
The runner never reads the override files itself: the project's resolver merges the layers.
"""
if explicit is not None:
if not explicit.is_file():
return None, f"harness file not found: {explicit}"
return validate_harness(read_json(explicit)), str(explicit)
if project_root is None:
return None, "no project root"
resolver = project_root / "_bmad" / "scripts" / "resolve_customization.py"
if not resolver.is_file():
return None, "BMad is not set up in this project; pass --harness"
argv = [sys.executable, str(resolver), "--skill", str(SKILL_ROOT), "--project-root", str(project_root)]
proc = subprocess.run([*argv, "--key", HARNESS_KEY], capture_output=True, text=True)
if proc.returncode != 0:
return None, f"customization resolver failed: {proc.stderr.strip()[-300:]}"
harness = json.loads(proc.stdout or "{}").get(HARNESS_KEY)
if not isinstance(harness, dict) or not harness.get("command"):
return None, "no harness recorded in bmad-eval's customization"
return validate_harness(harness), f"customization {HARNESS_KEY}"
def build_argv(harness: Mapping, prompt: str, cwd: str) -> list[str]:
"""The command with its placeholders filled."""
return [
str(t).replace("{prompt}", prompt).replace("{query}", prompt).replace("{cwd}", cwd) for t in harness["command"]
]
def build_case_env(harness: Mapping | None, home_dir: Path, host_env: Mapping[str, str]) -> dict[str, str]:
"""The subprocess environment, built from scratch, never from os.environ.
PATH, the fresh HOME (and USERPROFILE on Windows), then the harness's `env`: a value is set
as given with "~" expanded to the fresh HOME; an empty value forwards the host's value when
the host has one set non-empty, and is left out otherwise, since an empty credential would
override the CLI's own login.
"""
env = {"PATH": host_env.get("PATH", ""), "HOME": str(home_dir)}
if sys.platform == "win32":
env["USERPROFILE"] = str(home_dir)
for name in PLATFORM_ENV:
if name in host_env:
env[name] = host_env[name]
for name, value in ((harness or {}).get("env") or {}).items():
if value == "":
if host_env.get(str(name)):
env[str(name)] = host_env[str(name)]
else:
env[str(name)] = expand_home(str(value), home_dir)
return env
def expand_home(value: str, home_dir: Path) -> str:
if value == "~":
return str(home_dir)
if value.startswith("~/"):
return str(home_dir / value[2:])
return value
def make_home(harness: Mapping | None, room: Path) -> Path:
"""The fresh HOME for one run: folders named by `env` values made, `home_files` brought over.
`home_files` are paths relative to the host home (`.codex/auth.json`, `Library/Keychains`),
placed at the same path inside the fresh HOME: a file is copied, a folder is linked.
"""
harness = harness or {}
home = room / ".home"
home.mkdir(parents=True, exist_ok=True)
for value in (harness.get("env") or {}).values():
if str(value).startswith("~/"):
contained(home, str(value)[2:]).mkdir(parents=True, exist_ok=True)
host_home = Path.home()
for entry in harness.get("home_files") or []:
rel = str(entry).removeprefix("~/").removeprefix("~")
src = host_home / rel
dest = contained(home, rel)
if dest.exists() or dest.is_symlink():
continue
dest.parent.mkdir(parents=True, exist_ok=True)
if src.is_dir():
dest.symlink_to(src, target_is_directory=True)
elif src.is_file():
shutil.copy2(src, dest)
else:
print(f"Warning: home_files entry not found on this host: {src}", file=sys.stderr)
return home
# --- the clean room ----------------------------------------------------------
def run_in_clean_room(
harness: Mapping, case_dir: Path, prompt: str, timeout: int, stage: Callable[[Path], None]
) -> dict:
"""One harness run in a temporary room outside the project; the workspace comes back to case_dir/cwd.
`stage(cwd)` fills the workspace before the run. The room holds the workspace, made a git
root so harnesses that look for a project boundary stop there, and the fresh HOME beside it.
Leaves prompt.txt, transcript.jsonl (stdout), stderr.txt and cwd/ in case_dir. Returns
status ("ok", "error", "timeout", "harness-missing"), return_code, elapsed_s, stdout, stderr.
"""
case_dir.mkdir(parents=True, exist_ok=True)
(case_dir / "prompt.txt").write_text(prompt, encoding="utf-8")
with tempfile.TemporaryDirectory(prefix="bmad-eval-", ignore_cleanup_errors=True) as tmp:
room = Path(tmp)
cwd = room / "cwd"
cwd.mkdir()
stage(cwd)
subprocess.run(["git", "init", "-q", str(cwd)], capture_output=True)
env = build_case_env(harness, make_home(harness, room), os.environ)
argv = build_argv(harness, prompt, str(cwd))
status, return_code, stdout, stderr = "ok", 0, b"", b""
start = time.time()
try:
proc = subprocess.run(
argv, capture_output=True, stdin=subprocess.DEVNULL, cwd=str(cwd), env=env, timeout=timeout
)
stdout, stderr, return_code = proc.stdout or b"", proc.stderr or b"", proc.returncode
if return_code != 0:
status = "error"
except FileNotFoundError as e:
status, return_code, stderr = "harness-missing", -1, f"command not found: {e}".encode()
except subprocess.TimeoutExpired as e:
status, return_code = "timeout", -1
stdout, stderr = e.stdout or b"", (e.stderr or b"") + f"\nTIMEOUT after {timeout}s".encode()
elapsed = time.time() - start
shutil.copytree(cwd, case_dir / "cwd", symlinks=True, ignore=shutil.ignore_patterns(".git"), dirs_exist_ok=True)
stdout_text = stdout.decode("utf-8", errors="replace")
stderr_text = stderr.decode("utf-8", errors="replace")
(case_dir / "transcript.jsonl").write_text(stdout_text, encoding="utf-8")
(case_dir / "stderr.txt").write_text(stderr_text, encoding="utf-8")
return {
"status": status,
"return_code": return_code,
"elapsed_s": round(elapsed, 3),
"stdout": stdout_text,
"stderr": stderr_text,
}
def account_transcript(transcript_text: str) -> dict:
"""Best-effort usage from what the harness printed.
When the output is line-delimited JSON, per-message usage on assistant events is summed,
a turn-level usage block on any event overrides the sum, and tool calls are counted;
anything else degrades to zero counts with `tokens_reported` false, and elapsed time is
the metric.
"""
input_tokens = 0
output_tokens = 0
total_steps = 0
tool_calls: dict[str, int] = {}
found_usage = False
for raw in transcript_text.splitlines():
raw = raw.strip()
if not raw:
continue
try:
evt = json.loads(raw)
except json.JSONDecodeError:
continue
if not isinstance(evt, dict):
continue
if evt.get("type") == "assistant":
total_steps += 1
msg = evt.get("message", {})
usage = msg.get("usage") if isinstance(msg, dict) else None
if isinstance(usage, dict):
found_usage = True
input_tokens += int(usage.get("input_tokens", 0) or 0)
output_tokens += int(usage.get("output_tokens", 0) or 0)
for item in msg.get("content", []) if isinstance(msg, dict) else []:
if isinstance(item, dict) and item.get("type") == "tool_use":
name = item.get("name", "?")
tool_calls[name] = tool_calls.get(name, 0) + 1
elif isinstance(evt.get("usage"), dict):
# A turn-level usage block is authoritative over the running sum.
usage = evt["usage"]
found_usage = True
input_tokens = int(usage.get("input_tokens", input_tokens) or input_tokens)
output_tokens = int(usage.get("output_tokens", output_tokens) or output_tokens)
return {
"input_tokens": input_tokens,
"output_tokens": output_tokens,
"total_tokens": input_tokens + output_tokens,
"tokens_reported": found_usage,
"total_steps": total_steps,
"tool_calls": tool_calls,
"total_tool_calls": sum(tool_calls.values()),
}
# --- containment and run folders ----------------------------------------------
def safe_name(value: object) -> str:
"""A folder name from a case id: no separators, no `..`, never empty."""
name = SAFE_NAME_RE.sub("_", str(value)).strip(".")
return name or "unnamed"
def contained(root: Path, rel: str) -> Path:
"""`root / rel` when it stays inside root; ValueError when it would escape."""
root = root.resolve()
target = (root / rel).resolve()
if target != root and root not in target.parents:
raise ValueError(f"path escapes the workspace: {rel}")
return target
def make_run_dir(output_dir: Path, label: str) -> tuple[str, Path]:
"""A new run folder. A second run in the same second gets a suffix, never the same folder."""
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
for n in range(1, 1000):
run_id = f"{stamp}-{label}" if n == 1 else f"{stamp}-{label}-{n}"
run_dir = (output_dir / run_id).resolve()
try:
run_dir.mkdir(parents=True, exist_ok=False)
except FileExistsError:
continue
return run_id, run_dir
raise RuntimeError(f"could not create a run folder under {output_dir}")
@@ -0,0 +1,363 @@
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.11"
# ///
"""Run eval cases through the recorded harness.
A case is `input + rubric + optional state_prefix + optional files`. For each case and
config the runner makes a clean room outside the project, copies the skill under test into
the folder the harness reads skills from, stages the fixtures, runs the harness command once
from the workspace, and records what it printed with timing and token usage. Grading happens
elsewhere, from the transcript and artifacts left behind.
Modes (--mode) decide which configs each case runs under:
quality : one config, "skill", the skill staged in the workspace.
baseline : "skill" and "bare" (nothing staged), same input, so the bare-model floor is
measured under identical conditions.
variant : "skill" (--skill-path) and "variant" (--variant-path).
Run layout: <run-dir>/<config>/<case-id>/ (plus /run-N/ when --runs > 1), each holding
prompt.txt, transcript.jsonl, stderr.txt, timing.json and cwd/ (the workspace after the run),
so `aggregate_benchmark.py --baseline <run-dir>/bare --variant <run-dir>/skill` compares
configs from the timing.json files.
Harness: `[workflow.harness]` in this skill's customization, read through the project's
resolver; `--harness <json>` with the same keys for a project without BMad. The keys and the
clean room are described in eval_common.py. Without a harness the runner stages every case,
records each as skipped and exits 3.
Usage:
uv run run_evals.py --cases CASES.json --skill-path SKILL_DIR --output-dir DIR
[--mode quality|baseline|variant] [--variant-path SKILL_DIR] [--project-root DIR]
[--harness HARNESS.json] [--case-ids A1,B3] [--runs N] [--timeout SECS]
[--workers N] [--label NAME] [--quiet]
CASES.json is a list of cases or {"cases": [...]}. Each case:
{"id": "...", "input": "...", "rubric": [...], "state_prefix": "..."?, "files": ["..."]?}
Exit 0 when every run completed, 1 when any run errored or timed out or the command was
not found, 2 on a usage error, 3 when no harness is recorded.
"""
from __future__ import annotations
import argparse
import json
import shutil
import sys
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
from eval_common import (
DEFAULT_SKILL_DIR,
account_transcript,
contained,
find_project_root,
make_run_dir,
read_json,
resolve_harness,
run_in_clean_room,
safe_name,
utc_now_iso,
write_json,
)
FAILURE_STATUSES = ("error", "timeout", "exception", "harness-missing")
# --- staging: skill under test + fixtures ------------------------------------
def stage_skill(skill_path: Path, cwd: Path, skills_subdir: str) -> Path:
"""Copy the skill where the harness discovers skills inside the workspace.
A copy, not a symlink: a run that edits its staged skill must not touch the source or
the other cases.
"""
dest = cwd / skills_subdir / skill_path.name
shutil.copytree(skill_path, dest, dirs_exist_ok=True, ignore=shutil.ignore_patterns("__pycache__", ".git"))
return dest
def resolve_fixtures(files: list, project_root: Path, cases_dir: Path) -> list[tuple[Path, str]]:
"""Map each `files` entry to (source, dest-relative-path).
The entry's own relative path is preserved inside the workspace, so a bare filename lands
at its root and a nested path keeps its directory structure, matching the path the case
input references.
"""
out: list[tuple[Path, str]] = []
for entry in files or []:
entry = str(entry)
for candidate in (
(project_root / entry).resolve(),
(cases_dir / entry).resolve(),
Path(entry).resolve(),
):
if candidate.is_file():
out.append((candidate, entry))
break
else:
print(f"Warning: fixture not found: {entry}", file=sys.stderr)
return out
def stage_fixtures(fixtures: list[tuple[Path, str]], cwd: Path) -> None:
for src, dest_rel in fixtures:
dest = contained(cwd, dest_rel)
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, dest)
def compose_prompt(case: dict) -> str:
"""Prepend the state_prefix, a bracketed prime that places the skill mid-workflow."""
input_text = str(case.get("input", ""))
prefix = case.get("state_prefix")
if prefix:
return f"{str(prefix).rstrip()}\n\n{input_text}"
return input_text
# --- per-case execution -----------------------------------------------------
def run_case(
case: dict,
case_dir: Path,
run_dir: Path,
harness: dict | None,
timeout: int,
config: str,
skill_path: Path | None,
fixtures: list[tuple[Path, str]],
) -> dict:
case_id = str(case.get("id", "unnamed"))
case_dir.mkdir(parents=True, exist_ok=True)
prompt = compose_prompt(case)
write_json(case_dir / "case.json", case)
def timing(status: str, **extra: object) -> None:
write_json(case_dir / "timing.json", {"case_id": case_id, "config": config, "status": status, **extra})
def result(status: str, **extra: object) -> dict:
return {
"case_id": case_id,
"config": config,
"status": status,
"case_dir": str(case_dir.relative_to(run_dir)),
**extra,
}
def stage(cwd: Path) -> None:
stage_fixtures(fixtures, cwd)
if skill_path is not None:
stage_skill(skill_path, cwd, (harness or {}).get("skill_dir", DEFAULT_SKILL_DIR))
if harness is None:
(case_dir / "prompt.txt").write_text(prompt, encoding="utf-8")
try:
stage(case_dir / "cwd")
except ValueError as e:
timing("error", reason=str(e), captured_at=utc_now_iso())
return result("error", reason=str(e))
timing("skipped", captured_at=utc_now_iso())
return result("skipped", reason="no harness recorded", prompt_chars=len(prompt))
try:
run = run_in_clean_room(harness, case_dir, prompt, timeout, stage)
except ValueError as e:
timing("error", reason=str(e), captured_at=utc_now_iso())
return result("error", reason=str(e))
accounting = account_transcript(run["stdout"])
timing(
run["status"],
elapsed_s=run["elapsed_s"],
return_code=run["return_code"],
input_tokens=accounting["input_tokens"],
output_tokens=accounting["output_tokens"],
total_tokens=accounting["total_tokens"],
tokens_reported=accounting["tokens_reported"],
total_steps=accounting["total_steps"],
total_tool_calls=accounting["total_tool_calls"],
captured_at=utc_now_iso(),
)
return result(
run["status"],
elapsed_s=run["elapsed_s"],
return_code=run["return_code"],
tokens=accounting["total_tokens"],
tool_calls=accounting["tool_calls"],
error_tail=run["stderr"][-2000:],
)
# --- main -------------------------------------------------------------------
def load_cases(cases_file: Path) -> list[dict]:
data = read_json(cases_file)
if isinstance(data, dict) and "cases" in data:
cases = data["cases"]
elif isinstance(data, list):
cases = data
else:
raise ValueError("cases file must be a list or {'cases': [...]}")
if not isinstance(cases, list):
raise ValueError("'cases' must be a list")
return cases
def main(argv: list[str] | None = None) -> int:
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
p.add_argument("--cases", required=True, type=Path)
p.add_argument("--skill-path", required=True, type=Path, help="the skill under test (holds SKILL.md)")
p.add_argument("--output-dir", required=True, type=Path)
p.add_argument("--mode", choices=("quality", "baseline", "variant"), default="quality")
p.add_argument("--variant-path", type=Path, default=None, help="variant mode: the stripped or prior skill")
p.add_argument(
"--project-root",
type=Path,
default=None,
help="holds _bmad/ and anchors fixture paths; found from the skill path when omitted",
)
p.add_argument("--harness", type=Path, default=None, help="harness JSON for a project without BMad")
p.add_argument("--case-ids", default=None, help="comma-separated subset of case ids to run")
p.add_argument("--runs", type=int, default=1, help="repeats per case per config for the variance benchmark")
p.add_argument("--timeout", type=int, default=600)
p.add_argument("--workers", type=int, default=4)
p.add_argument("--label", default="evals", help="label for the run id")
p.add_argument("--quiet", action="store_true")
args = p.parse_args(argv)
cases_file = args.cases.resolve()
if not cases_file.is_file():
print(f"cases file not found: {cases_file}", file=sys.stderr)
return 2
skill_path = args.skill_path.resolve()
if not (skill_path / "SKILL.md").is_file():
print(f"skill path has no SKILL.md: {skill_path}", file=sys.stderr)
return 2
if args.mode == "variant":
if args.variant_path is None:
print("--mode variant requires --variant-path", file=sys.stderr)
return 2
variant_path = args.variant_path.resolve()
if not (variant_path / "SKILL.md").is_file():
print(f"variant path has no SKILL.md: {variant_path}", file=sys.stderr)
return 2
else:
variant_path = None
project_root = args.project_root.resolve() if args.project_root else find_project_root(skill_path)
fixture_root = project_root or cases_file.parent
if args.mode == "baseline":
configs: list[tuple[str, Path | None]] = [("skill", skill_path), ("bare", None)]
elif args.mode == "variant":
configs = [("skill", skill_path), ("variant", variant_path)]
else:
configs = [("skill", skill_path)]
cases = load_cases(cases_file)
if args.case_ids:
wanted = {x.strip() for x in args.case_ids.split(",") if x.strip()}
cases = [c for c in cases if str(c.get("id")) in wanted]
try:
harness, harness_note = resolve_harness(project_root, args.harness)
except (ValueError, json.JSONDecodeError) as e:
print(f"harness invalid: {e}", file=sys.stderr)
return 2
run_id, run_dir = make_run_dir(args.output_dir, safe_name(args.label))
write_json(
run_dir / "run.json",
{
"run_id": run_id,
"cases_file": str(cases_file),
"skill_path": str(skill_path),
"variant_path": str(variant_path) if variant_path else None,
"mode": args.mode,
"configs": [name for name, _ in configs],
"runs_per_case": args.runs,
"harness": harness_note,
"command": (harness or {}).get("command"),
"started_at": utc_now_iso(),
"case_count": len(cases),
},
)
if not args.quiet:
if harness is None:
print(f"[run_evals] no harness ({harness_note}); staging cases only", file=sys.stderr)
else:
print(f"[run_evals] harness: {' '.join(harness['command'])}", file=sys.stderr)
print(
f"[run_evals] {len(cases)} cases x {len(configs)} configs x {args.runs} runs, "
f"mode={args.mode}, run_dir={run_dir}",
file=sys.stderr,
)
jobs: list[tuple[str, dict, Path, Path | None]] = []
for config_name, config_skill in configs:
for c in cases:
base = run_dir / config_name / safe_name(c.get("id", "unnamed"))
for i in range(max(1, args.runs)):
case_dir = base / f"run-{i + 1}" if args.runs > 1 else base
jobs.append((config_name, c, case_dir, config_skill))
results: list[dict] = []
with ThreadPoolExecutor(max_workers=max(1, args.workers)) as pool:
fut_to_case = {
pool.submit(
run_case,
c,
case_dir,
run_dir,
harness,
int(c.get("timeout", args.timeout)),
config_name,
config_skill,
resolve_fixtures(c.get("files", []), fixture_root, cases_file.parent),
): c
for config_name, c, case_dir, config_skill in jobs
}
for fut in as_completed(fut_to_case):
c = fut_to_case[fut]
try:
res = fut.result()
except Exception as e:
res = {"case_id": str(c.get("id")), "status": "exception", "reason": str(e)}
results.append(res)
if not args.quiet:
print(
f" [{res.get('status')}] {res.get('config', '?')}/{res.get('case_id')} "
f"({res.get('elapsed_s', 0)}s)",
file=sys.stderr,
)
failures = sum(1 for r in results if r.get("status") in FAILURE_STATUSES)
summary = {
"run_id": run_id,
"completed_at": utc_now_iso(),
"mode": args.mode,
"harness": harness_note,
"total": len(jobs),
"executed": sum(1 for r in results if r.get("status") == "ok"),
"skipped": sum(1 for r in results if r.get("status") == "skipped"),
"failures": failures,
"run_dir": str(run_dir),
"results": results,
}
write_json(run_dir / "execution-summary.json", summary)
print(json.dumps(summary, indent=2))
if harness is None:
return 3
return 1 if failures else 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,289 @@
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.11"
# ///
"""Trigger evals: does a skill's description fire on each near-miss query?
A trigger query is a should/should-not user message that shares keywords with the skill, so
the description has to discriminate. For each query the runner stages a synthetic skill with
the description under test where the harness reads skills, sends the query through the
harness in a clean room, and checks whether the skill loaded. Each query runs several times
(--runs-per-query) so the rate is stable, not a coin flip.
Detection is a canary, the same on every harness. The synthetic skill's body asks the model
to begin its reply with a token unique to that attempt; the token appearing in what the
harness printed is the load. The skill's name and description never contain the token, so a
harness that lists its discovered skills at startup cannot fake a hit.
A query whose attempts did not all complete (command failed, timed out) is unmeasured, never
passed: a should-not query with no completed attempts would otherwise pass at a rate of zero.
Each attempt keeps prompt.txt, transcript.jsonl (what the harness printed), stderr.txt,
timing.json and cwd/ (the workspace after the run, staged skill included) under
<run-dir>/queries/qNNN-rN/. Harness and clean room are as in run_evals.py.
Usage:
uv run run_triggers.py --skill-path SKILL_DIR --queries QUERIES.json --output-dir DIR
[--project-root DIR] [--harness HARNESS.json] [--runs-per-query N] [--threshold 0.5]
[--timeout SECS] [--workers N] [--quiet]
QUERIES.json is a list of {"query": "...", "should_trigger": true|false}.
Exit 0 when every query was measured, 1 when any attempt failed or the command was not
found, 2 on a usage error, 3 when no harness is recorded. A query failing its threshold is a
result, not an error.
"""
from __future__ import annotations
import argparse
import json
import re
import sys
import uuid
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
from eval_common import (
DEFAULT_SKILL_DIR,
account_transcript,
find_project_root,
make_run_dir,
read_json,
resolve_harness,
run_in_clean_room,
utc_now_iso,
write_json,
)
CANARY_PREFIX = "TRIGGER-LOADED-"
def unquote_yaml(value: str) -> str:
"""A YAML scalar's text: single quotes doubled inside, double quotes with backslash escapes."""
value = value.strip()
if len(value) >= 2 and value[0] == value[-1] == "'":
return value[1:-1].replace("''", "'")
if len(value) >= 2 and value[0] == value[-1] == '"':
return value[1:-1].replace('\\"', '"').replace("\\n", "\n").replace("\\\\", "\\")
return value
def parse_skill_md(skill_path: Path) -> tuple[str, str]:
"""Return (name, description) from SKILL.md frontmatter, quotes and block indicators removed."""
text = (skill_path / "SKILL.md").read_text(encoding="utf-8")
m = re.match(r"^---\s*\n(.*?)\n---\s*\n", text, re.DOTALL)
if not m:
raise ValueError(f"SKILL.md at {skill_path} is missing frontmatter")
fields: dict[str, list[str]] = {}
current = None
for line in m.group(1).splitlines():
if line[:1] not in (" ", "\t") and ":" in line:
key, _, value = line.partition(":")
current = key.strip()
fields[current] = [] if value.strip() in ("|", ">", "|-", ">-") else [value.strip()]
elif current and line.strip():
fields[current].append(line.strip())
name = unquote_yaml(" ".join(fields.get("name", [])))
if not name:
raise ValueError(f"SKILL.md at {skill_path} has no name")
return name, unquote_yaml(" ".join(fields.get("description", [])))
# --- synthetic skill and detection -------------------------------------------
def write_synthetic_skill(skills_dir: Path, skill_name: str, description: str, token: str) -> str:
"""Write a skill the harness can discover, carrying the description and the canary. Returns its name."""
clean_name = f"{skill_name}-trig-{token[len(CANARY_PREFIX) :]}"
root = skills_dir / clean_name
root.mkdir(parents=True, exist_ok=True)
indented = "\n ".join(description.split("\n"))
(root / "SKILL.md").write_text(
f"---\nname: {clean_name}\ndescription: |\n {indented}\n---\n\n"
f"# {skill_name}\n\nThis skill handles: {description}\n\n"
f"Begin your reply with the exact token `{token}`, then continue.\n",
encoding="utf-8",
)
return clean_name
def detect_load(output: str, token: str) -> bool:
"""Did the synthetic skill load? Its canary token in the output says so."""
return token in output
# --- per-attempt execution ----------------------------------------------------
def run_query_once(
query: str, skill_name: str, description: str, harness: dict, attempt_dir: Path, timeout: int
) -> tuple[bool | None, str]:
"""One attempt: (loaded, "") when it completed, (None, why) when it did not."""
token = CANARY_PREFIX + uuid.uuid4().hex[:8]
def stage(cwd: Path) -> None:
write_synthetic_skill(cwd / harness.get("skill_dir", DEFAULT_SKILL_DIR), skill_name, description, token)
run = run_in_clean_room(harness, attempt_dir, query, timeout, stage)
accounting = account_transcript(run["stdout"])
loaded = detect_load(run["stdout"], token) if run["status"] == "ok" else None
write_json(
attempt_dir / "timing.json",
{
"status": run["status"],
"elapsed_s": run["elapsed_s"],
"return_code": run["return_code"],
"loaded": loaded,
"total_tokens": accounting["total_tokens"],
"tokens_reported": accounting["tokens_reported"],
"captured_at": utc_now_iso(),
},
)
if run["status"] != "ok":
return None, f"{run['status']}: {run['stderr'][-500:].strip()}"
return loaded, ""
# --- main -------------------------------------------------------------------
def main(argv: list[str] | None = None) -> int:
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
p.add_argument("--skill-path", required=True, type=Path)
p.add_argument("--queries", required=True, type=Path)
p.add_argument("--output-dir", required=True, type=Path)
p.add_argument(
"--project-root", type=Path, default=None, help="holds _bmad/; found from the skill path when omitted"
)
p.add_argument("--harness", type=Path, default=None, help="harness JSON for a project without BMad")
p.add_argument("--runs-per-query", type=int, default=3)
p.add_argument("--threshold", type=float, default=0.5)
p.add_argument("--timeout", type=int, default=180)
p.add_argument("--workers", type=int, default=4)
p.add_argument("--quiet", action="store_true")
args = p.parse_args(argv)
skill_path = args.skill_path.resolve()
queries_file = args.queries.resolve()
if not queries_file.is_file():
print(f"queries file not found: {queries_file}", file=sys.stderr)
return 2
skill_name, description = parse_skill_md(skill_path)
queries = read_json(queries_file)
if not isinstance(queries, list):
print("queries file must be a JSON list", file=sys.stderr)
return 2
project_root = args.project_root.resolve() if args.project_root else find_project_root(skill_path)
try:
harness, harness_note = resolve_harness(project_root, args.harness)
except (ValueError, json.JSONDecodeError) as e:
print(f"harness invalid: {e}", file=sys.stderr)
return 2
run_id, run_dir = make_run_dir(args.output_dir, f"{skill_name}-triggers")
(run_dir / "queries").mkdir(parents=True, exist_ok=True)
write_json(
run_dir / "run.json",
{
"run_id": run_id,
"skill_name": skill_name,
"description": description,
"harness": harness_note,
"command": (harness or {}).get("command"),
"started_at": utc_now_iso(),
"query_count": len(queries),
"runs_per_query": args.runs_per_query,
"threshold": args.threshold,
},
)
if harness is None:
if not args.quiet:
print(f"[run_triggers] no harness ({harness_note}); staging only", file=sys.stderr)
output = {
"run_id": run_id,
"completed_at": utc_now_iso(),
"skill_name": skill_name,
"description": description,
"status": "skipped",
"reason": "no harness recorded",
"results": [],
"summary": {"total": len(queries), "passed": 0, "failed": 0, "unmeasured": len(queries)},
}
write_json(run_dir / "triggers-result.json", output)
print(json.dumps(output, indent=2))
return 3
def run_one(idx: int, q: dict, run_idx: int) -> tuple[int, bool | None, str]:
attempt_dir = run_dir / "queries" / f"q{idx:03d}-r{run_idx}"
try:
loaded, error = run_query_once(q["query"], skill_name, description, harness, attempt_dir, args.timeout)
except (OSError, ValueError) as e:
loaded, error = None, str(e)
return idx, loaded, error
attempts: dict[int, list[bool]] = {idx: [] for idx in range(len(queries))}
errors: dict[int, list[str]] = {idx: [] for idx in range(len(queries))}
if not args.quiet:
print(f"[run_triggers] harness: {' '.join(harness['command'])}", file=sys.stderr)
print(f"[run_triggers] {len(queries)} queries x {args.runs_per_query} runs", file=sys.stderr)
with ThreadPoolExecutor(max_workers=max(1, args.workers)) as pool:
futures = [
pool.submit(run_one, idx, q, run_idx)
for idx, q in enumerate(queries)
for run_idx in range(args.runs_per_query)
]
for fut in as_completed(futures):
idx, loaded, error = fut.result()
if loaded is None:
errors[idx].append(error)
if not args.quiet:
print(f" attempt failed for query {idx}: {error}", file=sys.stderr)
else:
attempts[idx].append(loaded)
results = []
for idx, q in enumerate(queries):
runs = attempts[idx]
measured = len(runs) == args.runs_per_query
rate = (sum(runs) / len(runs)) if runs else 0.0
should = bool(q.get("should_trigger", True))
passed = ((rate >= args.threshold) if should else (rate < args.threshold)) if measured else None
results.append(
{
"query": q["query"],
"should_trigger": should,
"trigger_rate": round(rate, 3),
"triggers": int(sum(runs)),
"runs": len(runs),
"errors": errors[idx],
"pass": passed,
}
)
unmeasured = sum(1 for r in results if r["pass"] is None)
output = {
"run_id": run_id,
"completed_at": utc_now_iso(),
"skill_name": skill_name,
"description": description,
"harness": harness_note,
"results": results,
"summary": {
"total": len(results),
"passed": sum(1 for r in results if r["pass"] is True),
"failed": sum(1 for r in results if r["pass"] is False),
"unmeasured": unmeasured,
},
}
write_json(run_dir / "triggers-result.json", output)
print(json.dumps(output, indent=2))
return 1 if unmeasured else 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,52 @@
"""A stand-in agent CLI for the runner tests: `fake_harness.py <prompt> <cwd>`.
Behaves like a harness enough to exercise the runners: reads the skills staged under
`.agents/skills` in the cwd, obeys a canary line when the prompt says "yes", writes an
artifact, prints a usage line, and exits non-zero when the prompt says "crash". It also
checks the clean room: HOME must sit inside the stage and the host secret must be absent.
"""
import json
import os
import re
import sys
from pathlib import Path
CANARY_RE = re.compile(r"token `([^`]+)`")
def main() -> int:
prompt, cwd = sys.argv[1], Path(sys.argv[2])
home = Path(os.environ.get("HOME", ""))
# run_evals puts HOME beside the cwd; run_triggers puts it inside the stage it runs from.
if home.resolve().parent not in (cwd.resolve().parent, cwd.resolve()):
print(f"HOME not inside the stage: {home}", file=sys.stderr)
return 4
if "HOST_SECRET" in os.environ:
print("host secret leaked", file=sys.stderr)
return 5
if "crash" in prompt:
print("boom", file=sys.stderr)
return 1
tokens = []
for skill_md in sorted(cwd.glob(".agents/skills/*/SKILL.md")):
m = CANARY_RE.search(skill_md.read_text(encoding="utf-8"))
if m:
tokens.append(m.group(1))
if "edit-me" in prompt:
skill_md.write_text("edited by the run\n", encoding="utf-8")
(cwd / "made.txt").write_text(prompt, encoding="utf-8")
(cwd / "where.txt").write_text(str(cwd.resolve()), encoding="utf-8")
(cwd / "env.json").write_text(
json.dumps({k: v for k, v in os.environ.items() if k in ("FORWARDED", "ABSENT", "FAKE_CONFIG_DIR")}),
encoding="utf-8",
)
(cwd / "login.txt").write_text(str((home / ".fake" / "auth.json").is_file()), encoding="utf-8")
reply = ("yes" in prompt and tokens and tokens[0] + " ") or ""
print(json.dumps({"type": "assistant", "message": {"content": [{"type": "text", "text": reply + "done"}]}}))
print(json.dumps({"type": "result", "usage": {"input_tokens": 10, "output_tokens": 5}}))
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,41 @@
"""aggregate_benchmark.py leaves incomplete runs out of the numbers."""
import json
import sys
import tempfile
import unittest
from pathlib import Path
SCRIPTS_DIR = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(SCRIPTS_DIR))
from aggregate_benchmark import load_records, summarize_config # noqa: E402
class AggregateTest(unittest.TestCase):
def test_incomplete_runs_are_excluded_and_counted(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
rows = [
("a", {"status": "ok", "elapsed_s": 10.0}),
("b", {"status": "ok", "elapsed_s": 12.0}),
("c", {"status": "timeout", "elapsed_s": 600.0}),
("d", {"status": "harness-missing", "elapsed_s": 0.01}),
]
for name, data in rows:
(root / name).mkdir()
(root / name / "timing.json").write_text(json.dumps(data))
records, excluded = load_records(root)
self.assertEqual(excluded, 2)
self.assertEqual(summarize_config(records)["metrics"]["elapsed_s"]["mean"], 11.0)
def test_records_without_status_count_as_complete(self):
with tempfile.TemporaryDirectory() as tmp:
f = Path(tmp) / "runs.json"
f.write_text(json.dumps({"runs": [{"elapsed_s": 1.0}, {"elapsed_s": 3.0}]}))
records, excluded = load_records(f)
self.assertEqual((len(records), excluded), (2, 0))
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,99 @@
"""convert_cases.py maps skill-creator evals.json to runner cases.json and back."""
import json
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
SCRIPT = Path(__file__).resolve().parents[1] / "convert_cases.py"
SKILL_CREATOR = {
"skill_name": "brief-writer",
"evals": [
{
"id": 1,
"prompt": "write a brief for InsuLens from memo.md",
"expected_output": "a brief.md naming InsuLens",
"files": ["evals/files/memo.md"],
"expectations": ["brief.md exists and names InsuLens", "no invented claims"],
},
{"id": 2, "prompt": "just say hi", "expected_output": "", "files": [], "expectations": []},
],
}
def run(*args: str) -> subprocess.CompletedProcess:
return subprocess.run([sys.executable, str(SCRIPT), *args], capture_output=True, text=True)
class ConvertCasesTest(unittest.TestCase):
def setUp(self):
self.tmp = Path(tempfile.mkdtemp())
self.evals = self.tmp / "my-skill" / "evals" / "evals.json"
self.evals.parent.mkdir(parents=True)
self.evals.write_text(json.dumps(SKILL_CREATOR), encoding="utf-8")
def test_to_runner(self):
proc = run(str(self.evals))
self.assertEqual(proc.returncode, 0, proc.stderr)
cases = json.loads(proc.stdout)
self.assertEqual(
cases[0],
{
"id": "1",
"input": "write a brief for InsuLens from memo.md",
"rubric": [
"brief.md exists and names InsuLens",
"no invented claims",
"output matches: a brief.md naming InsuLens",
],
"state_prefix": None,
"files": ["evals/files/memo.md"],
},
)
self.assertEqual(cases[1]["rubric"], [], "empty expected_output adds no rubric line")
self.assertEqual(cases[1]["id"], "2")
def test_output_flag_writes_file(self):
out = self.tmp / "out" / "cases.json"
proc = run(str(self.evals), "--output", str(out))
self.assertEqual(proc.returncode, 0, proc.stderr)
self.assertEqual(proc.stdout, "")
self.assertEqual(len(json.loads(out.read_text(encoding="utf-8"))), 2)
def test_round_trip(self):
cases_path = self.tmp / "my-skill" / "evals" / "cases.json"
run(str(self.evals), "--output", str(cases_path))
proc = run(str(cases_path), "--to", "skill-creator")
self.assertEqual(proc.returncode, 0, proc.stderr)
back = json.loads(proc.stdout)
self.assertEqual(back["skill_name"], "my-skill", "skill_name defaults to the folder above evals/")
self.assertEqual(back["evals"], SKILL_CREATOR["evals"])
def test_skill_name_flag_and_non_numeric_id(self):
cases_path = self.tmp / "cases.json"
cases_path.write_text(
json.dumps(
{"cases": [{"id": "create-1", "input": "x", "rubric": ["a"], "state_prefix": None, "files": []}]}
),
encoding="utf-8",
)
proc = run(str(cases_path), "--to", "skill-creator", "--skill-name", "named")
self.assertEqual(proc.returncode, 0, proc.stderr)
back = json.loads(proc.stdout)
self.assertEqual(back["skill_name"], "named")
self.assertEqual(back["evals"][0]["id"], "create-1")
self.assertEqual(back["evals"][0]["expected_output"], "")
def test_rejects_runner_input_toward_runner(self):
bad = self.tmp / "cases.json"
bad.write_text("[]", encoding="utf-8")
proc = run(str(bad))
self.assertEqual(proc.returncode, 2)
self.assertIn("evals", proc.stderr)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,68 @@
"""The clean-room contract in eval_common.build_case_env, shared by both runners.
The eval result is only honest if nothing from the host shell leaks into the subprocess: exactly
PATH, a fresh HOME, and the harness's `env` table, where a value is set as given with "~" meaning
the fresh HOME and an empty value forwards the host's only when the host has it. Nothing else.
"""
import sys
import unittest
from pathlib import Path
SCRIPTS_DIR = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(SCRIPTS_DIR))
from eval_common import PLATFORM_ENV, build_case_env, validate_harness # noqa: E402
HOST_ENV = {
"PATH": "/usr/bin:/bin",
"HOME": "/Users/host",
"MY_API_KEY": "sk-test-123",
"AWS_SECRET_ACCESS_KEY": "host-secret-must-not-leak",
"AGENT_CONFIG_DIR": "/Users/host/.agent",
"EXTRA_VAR": "extra",
}
HOME = Path("/tmp/eval-case/.home")
def keys(env: dict) -> set[str]:
return set(env) - set(PLATFORM_ENV) - {"USERPROFILE"}
class EnvIsolationTest(unittest.TestCase):
def test_set_forward_and_expand(self):
harness = {"env": {"MY_API_KEY": "", "AGENT_CONFIG_DIR": "~/.agent", "MODE": "quiet"}}
env = build_case_env(harness, HOME, HOST_ENV)
self.assertEqual(keys(env), {"PATH", "HOME", "AGENT_CONFIG_DIR", "MY_API_KEY", "MODE"})
self.assertEqual(env["PATH"], HOST_ENV["PATH"])
self.assertEqual(env["HOME"], str(HOME), "HOME must be the fresh case home")
self.assertEqual(env["AGENT_CONFIG_DIR"], str(HOME / ".agent"), "~ points into the fresh home, not the host's")
self.assertEqual(env["MY_API_KEY"], "sk-test-123", "an empty value forwards the host's")
self.assertEqual(env["MODE"], "quiet")
self.assertNotIn("AWS_SECRET_ACCESS_KEY", env, "host secrets leaked")
def test_forwarded_var_absent_when_host_lacks_it(self):
# An empty credential would override the CLI's own login; the key must be absent, never empty.
for host in ({}, {"MY_API_KEY": ""}):
env = build_case_env({"env": {"MY_API_KEY": ""}}, HOME, {"PATH": "/bin", **host})
self.assertNotIn("MY_API_KEY", env)
def test_no_harness_still_minimal(self):
self.assertEqual(keys(build_case_env(None, HOME, HOST_ENV)), {"PATH", "HOME"})
def test_validation(self):
good = {"command": ["x", "{prompt}"], "env": {"A": ""}, "home_files": []}
self.assertIs(validate_harness(good), good)
for bad in (
{"command": ["x"]},
{"command": "x {prompt}"},
{"command": ["x", "{prompt}"], "env": ["A"]},
{"command": ["x", "{prompt}"], "home_files": "a"},
):
with self.assertRaises(ValueError, msg=bad):
validate_harness(bad)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,213 @@
"""run_evals.py against the fake harness: staging, containment, exit codes, run folders."""
import json
import os
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
SCRIPTS_DIR = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(SCRIPTS_DIR))
import eval_common # noqa: E402
RUNNER = SCRIPTS_DIR / "run_evals.py"
FAKE = Path(__file__).resolve().parent / "fake_harness.py"
def write(path: Path, text: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text, encoding="utf-8")
def make_project(root: Path, command: list[str] | None = None) -> tuple[Path, Path, Path]:
"""A project with a skill, a cases file, a fixture and a harness file. Returns (skill, cases, harness)."""
skill = root / "skills" / "greet"
write(skill / "SKILL.md", "---\nname: greet\ndescription: Greets. Use when greeting.\n---\nSay hi.\n")
write(skill / "scripts" / "__pycache__" / "x.pyc", "")
write(root / "fixtures" / "notes.md", "fixture\n")
cases = root / "evals" / "cases.json"
write(
cases,
json.dumps(
[
{"id": "c1", "input": "yes hello", "rubric": ["says hi"], "files": ["fixtures/notes.md"]},
{"id": "../escape", "input": "hello", "rubric": ["x"]},
]
),
)
harness = root / "harness.json"
write(
harness,
json.dumps(
{
"command": command or [sys.executable, str(FAKE), "{prompt}", "{cwd}"],
"skill_dir": ".agents/skills",
"env": {"FAKE_CONFIG_DIR": "~/.fake", "FORWARDED": "", "ABSENT": ""},
"home_files": [".fake/auth.json"],
}
),
)
return skill, cases, harness
def run(*args: str, host_home: Path | None = None) -> subprocess.CompletedProcess:
"""Run the runner with a host secret set and, when given, a fake host home holding a login file."""
env = {**os.environ, "HOST_SECRET": "must-not-leak", "FORWARDED": "yes"}
env.pop("ABSENT", None)
if host_home is not None:
(host_home / ".fake").mkdir(parents=True, exist_ok=True)
(host_home / ".fake" / "auth.json").write_text("{}", encoding="utf-8")
env.update(HOME=str(host_home), USERPROFILE=str(host_home))
return subprocess.run([sys.executable, str(RUNNER), *args], capture_output=True, text=True, env=env)
class RunEvalsTest(unittest.TestCase):
def test_baseline_stages_a_copy_and_contains_the_run(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
skill, cases, harness = make_project(root)
out = root / "out"
res = run(
"--cases", str(cases), "--skill-path", str(skill), "--output-dir", str(out),
"--mode", "baseline", "--project-root", str(root), "--harness", str(harness), "--quiet",
host_home=root / "host-home",
) # fmt: skip
self.assertEqual(res.returncode, 0, res.stderr)
summary = json.loads(res.stdout)
self.assertEqual(summary["executed"], 4)
run_dir = Path(summary["run_dir"])
staged = run_dir / "skill" / "c1" / "cwd" / ".agents" / "skills" / "greet"
self.assertTrue(staged.is_dir() and not staged.is_symlink(), "the skill is copied, not linked")
self.assertFalse((staged / "scripts" / "__pycache__").exists())
self.assertFalse((run_dir / "bare" / "c1" / "cwd" / ".agents").exists(), "bare config stages nothing")
self.assertEqual((run_dir / "skill" / "c1" / "cwd" / "fixtures" / "notes.md").read_text(), "fixture\n")
case = run_dir / "skill" / "c1"
self.assertTrue((case / "cwd" / "made.txt").is_file(), "the workspace comes back as cwd/")
self.assertFalse((case / "cwd" / ".git").exists())
where = Path((case / "cwd" / "where.txt").read_text())
self.assertNotIn(run_dir.resolve(), where.parents, "the run happened outside the project")
self.assertEqual((case / "cwd" / "login.txt").read_text(), "True", "home_files brought the login over")
seen = json.loads((case / "cwd" / "env.json").read_text())
self.assertEqual(seen["FORWARDED"], "yes", "an empty value forwards the host's")
self.assertNotIn("ABSENT", seen, "a var the host lacks is left out")
self.assertTrue(
seen["FAKE_CONFIG_DIR"].endswith(".fake") and Path(seen["FAKE_CONFIG_DIR"]).is_absolute(), "~ expands"
)
for name in ("prompt.txt", "transcript.jsonl", "stderr.txt", "timing.json", "case.json"):
self.assertTrue((case / name).is_file(), name)
# the id with `..` stays inside the run folder
self.assertTrue((run_dir / "skill" / "_escape").is_dir())
self.assertFalse((out / "escape").exists())
timing = json.loads((run_dir / "skill" / "c1" / "timing.json").read_text())
self.assertEqual(timing["status"], "ok")
self.assertEqual(timing["total_tokens"], 15)
self.assertTrue(timing["tokens_reported"])
run_json = json.loads((run_dir / "run.json").read_text())
self.assertEqual(run_json["command"][2], "{prompt}")
def test_edit_in_a_run_does_not_touch_the_source(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
skill, cases, harness = make_project(root)
write(cases, json.dumps([{"id": "e", "input": "edit-me", "rubric": ["x"]}]))
res = run(
"--cases", str(cases), "--skill-path", str(skill), "--output-dir", str(root / "out"),
"--project-root", str(root), "--harness", str(harness), "--quiet",
) # fmt: skip
self.assertEqual(res.returncode, 0, res.stderr)
self.assertIn("name: greet", (skill / "SKILL.md").read_text())
def test_fixture_escaping_the_workspace_is_an_error(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
skill, cases, harness = make_project(root)
write(root / "secret.txt", "s\n")
write(cases, json.dumps([{"id": "f", "input": "hello", "rubric": ["x"], "files": ["../../secret.txt"]}]))
out = root / "out"
res = run(
"--cases", str(cases), "--skill-path", str(skill), "--output-dir", str(out),
"--project-root", str(root / "evals" / "deep"), "--harness", str(harness), "--quiet",
) # fmt: skip
self.assertEqual(res.returncode, 1)
summary = json.loads(res.stdout)
self.assertEqual(summary["results"][0]["status"], "error")
self.assertIn("escapes", summary["results"][0]["reason"])
# nothing landed above the case's cwd
run_dir = Path(summary["run_dir"])
self.assertEqual(sorted(p.name for p in run_dir.iterdir()), ["execution-summary.json", "run.json", "skill"])
def test_failures_exit_nonzero(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
skill, cases, harness = make_project(root)
write(cases, json.dumps([{"id": "k", "input": "crash", "rubric": ["x"]}]))
res = run(
"--cases", str(cases), "--skill-path", str(skill), "--output-dir", str(root / "out"),
"--project-root", str(root), "--harness", str(harness), "--quiet",
) # fmt: skip
self.assertEqual(res.returncode, 1)
self.assertEqual(json.loads(res.stdout)["failures"], 1)
def test_missing_command_exits_nonzero(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
skill, cases, harness = make_project(root, command=["no-such-harness-xyz", "{prompt}"])
res = run(
"--cases", str(cases), "--skill-path", str(skill), "--output-dir", str(root / "out"),
"--project-root", str(root), "--harness", str(harness), "--quiet",
) # fmt: skip
self.assertEqual(res.returncode, 1)
self.assertEqual({r["status"] for r in json.loads(res.stdout)["results"]}, {"harness-missing"})
def test_no_harness_stages_only_and_exits_3(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
skill, cases, _ = make_project(root)
res = run(
"--cases", str(cases), "--skill-path", str(skill), "--output-dir", str(root / "out"),
"--project-root", str(root), "--quiet",
) # fmt: skip
self.assertEqual(res.returncode, 3, res.stderr)
summary = json.loads(res.stdout)
self.assertEqual(summary["skipped"], 2)
self.assertIn("BMad is not set up", summary["harness"])
def test_invalid_harness_is_a_usage_error(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
skill, cases, harness = make_project(root)
write(harness, json.dumps({"command": ["claude", "-p"]}))
res = run(
"--cases", str(cases), "--skill-path", str(skill), "--output-dir", str(root / "out"),
"--harness", str(harness), "--quiet",
) # fmt: skip
self.assertEqual(res.returncode, 2)
self.assertIn("{prompt}", res.stderr)
def test_run_folders_never_collide(self):
with tempfile.TemporaryDirectory() as tmp:
out = Path(tmp)
a_id, a_dir = eval_common.make_run_dir(out, "x")
b_id, b_dir = eval_common.make_run_dir(out, "x")
self.assertNotEqual(a_dir, b_dir)
self.assertTrue(a_dir.is_dir() and b_dir.is_dir())
self.assertTrue(b_id.startswith(a_id))
def test_contained_and_safe_name(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
self.assertEqual(eval_common.contained(root, "a/b.txt"), (root / "a" / "b.txt").resolve())
with self.assertRaises(ValueError):
eval_common.contained(root, "../b.txt")
with self.assertRaises(ValueError):
eval_common.contained(root, "/etc/passwd")
self.assertEqual(eval_common.safe_name("../escape"), "_escape")
self.assertEqual(eval_common.safe_name(".."), "unnamed")
self.assertEqual(eval_common.safe_name("Case 1/B"), "Case_1_B")
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,127 @@
"""run_triggers.py against the fake harness: canary detection, unmeasured queries, exit codes."""
import json
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
SCRIPTS_DIR = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(SCRIPTS_DIR))
from run_triggers import CANARY_PREFIX, detect_load, parse_skill_md, write_synthetic_skill # noqa: E402
RUNNER = SCRIPTS_DIR / "run_triggers.py"
FAKE = Path(__file__).resolve().parent / "fake_harness.py"
def write(path: Path, text: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text, encoding="utf-8")
class TriggerRunTest(unittest.TestCase):
def test_canary_scores_queries_and_leaves_failed_ones_unmeasured(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
skill = root / "skills" / "greet"
write(
skill / "SKILL.md",
"---\nname: greet\ndescription: 'Greets the user''s friends. Use when greeting.'\n---\nSay hi.\n",
)
queries = root / "triggers.json"
write(
queries,
json.dumps(
[
{"query": "yes greet me", "should_trigger": True},
{"query": "no thanks", "should_trigger": False},
{"query": "yes but wrong", "should_trigger": False},
{"query": "crash", "should_trigger": False},
]
),
)
harness = root / "harness.json"
write(harness, json.dumps({"command": [sys.executable, str(FAKE), "{prompt}", "{cwd}"]}))
res = subprocess.run(
[sys.executable, str(RUNNER), "--skill-path", str(skill), "--queries", str(queries),
"--output-dir", str(root / "out"), "--harness", str(harness), "--runs-per-query", "2", "--quiet"],
capture_output=True, text=True,
) # fmt: skip
self.assertEqual(res.returncode, 1, res.stderr)
out = json.loads(res.stdout)
by_query = {r["query"]: r for r in out["results"]}
self.assertEqual(by_query["yes greet me"]["trigger_rate"], 1.0)
self.assertTrue(by_query["yes greet me"]["pass"])
self.assertEqual(by_query["no thanks"]["trigger_rate"], 0.0)
self.assertTrue(by_query["no thanks"]["pass"])
self.assertFalse(by_query["yes but wrong"]["pass"], "a should-not query that fired fails")
crashed = by_query["crash"]
self.assertIsNone(crashed["pass"], "a query with failed attempts is never a pass")
self.assertEqual(crashed["runs"], 0)
self.assertEqual(len(crashed["errors"]), 2)
self.assertEqual(out["summary"], {"total": 4, "passed": 2, "failed": 1, "unmeasured": 1})
self.assertEqual(
out["description"], "Greets the user's friends. Use when greeting.", "YAML quotes are removed"
)
attempt = root / "out" / out["run_id"] / "queries" / "q000-r0"
for name in ("prompt.txt", "transcript.jsonl", "stderr.txt", "timing.json"):
self.assertTrue((attempt / name).is_file(), name)
staged = list((attempt / "cwd" / ".agents" / "skills").glob("greet-trig-*/SKILL.md"))
self.assertEqual(len(staged), 1, "the workspace with the staged skill comes back")
self.assertIn("user's friends", staged[0].read_text())
self.assertEqual(json.loads((attempt / "timing.json").read_text())["loaded"], True)
def test_no_harness_exits_3(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
skill = root / "greet"
write(skill / "SKILL.md", "---\nname: greet\ndescription: Greets.\n---\n")
queries = root / "triggers.json"
write(queries, json.dumps([{"query": "hi", "should_trigger": True}]))
res = subprocess.run(
[sys.executable, str(RUNNER), "--skill-path", str(skill), "--queries", str(queries),
"--output-dir", str(root / "out"), "--project-root", str(root), "--quiet"],
capture_output=True, text=True,
) # fmt: skip
self.assertEqual(res.returncode, 3, res.stderr)
self.assertEqual(json.loads(res.stdout)["summary"]["unmeasured"], 1)
class ParseSkillTest(unittest.TestCase):
def test_quoted_and_block_descriptions(self):
with tempfile.TemporaryDirectory() as tmp:
cases = {
"description: 'It''s quoted. Use when x.'\n": "It's quoted. Use when x.",
'description: "Say \\"hi\\". Use when x."\n': 'Say "hi". Use when x.',
"description: |\n Block one.\n Block two.\n": "Block one. Block two.",
"description: 'Spans\n two lines.'\n": "Spans two lines.",
"description: Plain. Use when x.\n": "Plain. Use when x.",
}
for frontmatter, expected in cases.items():
write(Path(tmp) / "SKILL.md", f"---\nname: s\n{frontmatter}---\nBody.\n")
self.assertEqual(parse_skill_md(Path(tmp)), ("s", expected), frontmatter)
class CanaryTest(unittest.TestCase):
def test_token_is_in_the_body_not_the_name_or_description(self):
with tempfile.TemporaryDirectory() as tmp:
token = CANARY_PREFIX + "abcd1234"
name = write_synthetic_skill(Path(tmp), "greet", "Greets people.\nUse when greeting.", token)
text = (Path(tmp) / name / "SKILL.md").read_text()
frontmatter = text.split("---")[1]
self.assertNotIn(token, frontmatter)
self.assertNotIn(token, name)
self.assertIn(f"token `{token}`", text)
self.assertIn(" Use when greeting.", frontmatter)
def test_detection_needs_the_token(self):
token = CANARY_PREFIX + "abcd1234"
listing = json.dumps({"type": "system", "skills": ["greet-trig-abcd1234", "other"]})
self.assertFalse(detect_load(listing, token), "a startup listing of skill names is not a load")
self.assertTrue(detect_load(listing + "\n" + json.dumps({"text": f"{token} hello"}), token))
if __name__ == "__main__":
unittest.main()