CoolFace
Apppublic

Nomearod/agentbench

sourceHugging Faceupdated 2mo agoView on Hugging Face
0likes
run_epochs.py347 linesDownload Raw Back to scripts
1"""Repeat harness runs k times per configuration with injected provenance.2 3This script can make real, PAID API calls. The free path is narrow: a *custom*4entry run with a mock config (provider.default: mock). It is the only path5exercised in CI. langchain entries always build a real ChatOpenAI/ChatAnthropic6from their --provider regardless of any config, so a mock config does NOT make7them free; and a custom entry without a mock config uses its real provider.8run_config_epochs therefore refuses any paid run unless allow_paid is set9(--allow-paid on the CLI), so invoking this script directly cannot silently10spend money -- the Makefile epochs target is not the only guard (guardrail 2).11Provenance is injected by post-processing each entry point's --output JSON into12an envelope file (design spec section 6); harness internals are never edited.13"""14 15import argparse16import datetime17import hashlib18import json19import os20import subprocess21import sys22from pathlib import Path23 24import yaml25 26# Repo root on path so the preflight can import agent_bench when this file is27# run as a script (python scripts/run_epochs.py puts scripts/ on sys.path[0],28# not the repo root). The heavy work still happens in subprocesses.29sys.path.insert(0, str(Path(__file__).resolve().parent.parent))30 31CROCKFORD = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"32 33PROVIDER_KEY_ENV = {"openai": "OPENAI_API_KEY", "anthropic": "ANTHROPIC_API_KEY"}34 35# Provider -> model recorded in langchain config_id (audit #7) AND imported by36# run_langchain_eval to build the client, so the recorded provenance and the37# model that actually bills are the same constant. Do not duplicate it.38MODEL_DEFAULTS = {"openai": "gpt-4o-mini", "anthropic": "claude-haiku-4-5-20251001"}39 40 41def _is_mock_config(path: Path | None) -> bool:42    """True only if the config actually sets provider.default: mock.43 44    The free-path guard must verify config CONTENT, not just that --mock-config45    was passed: a real config handed to --mock-config would otherwise be46    classified free and bill silently (paid-path audit finding #6).47    """48    if path is None:49        return False50    data = yaml.safe_load(Path(path).read_text()) or {}51    return bool(data.get("provider", {}).get("default") == "mock")52 53 54def new_ulid() -> str:55    ts = int(datetime.datetime.now(tz=datetime.timezone.utc).timestamp() * 1000)56    time_part = ""57    for _ in range(10):58        time_part = CROCKFORD[ts % 32] + time_part59        ts //= 3260    rand = int.from_bytes(os.urandom(10), "big")61    rand_part = ""62    for _ in range(16):63        rand_part = CROCKFORD[rand % 32] + rand_part64        rand //= 3265    return time_part + rand_part66 67 68def write_envelope(69    raw_output: Path,70    dest_dir: Path,71    run_id: str,72    config_id: str,73    epoch: int,74    code_version: str,75    dataset_version: str,76    timestamp: str,77) -> Path:78    dest_dir.mkdir(parents=True, exist_ok=True)79    envelope = {80        "run_id": run_id,81        "timestamp": timestamp,82        "config_id": config_id,83        "code_version": code_version,84        "dataset_version": dataset_version,85        "epoch": epoch,86        "results": json.loads(raw_output.read_text()),87    }88    out = dest_dir / f"{config_id.split('+')[0]}_e{epoch}.json"89    out.write_text(json.dumps(envelope, indent=1))90    return out91 92 93def _config_hash(path: Path | None) -> str:94    if path is None:95        return "00000000"96    return hashlib.sha256(path.read_bytes()).hexdigest()[:8]97 98 99def _dataset_version(golden_path: Path) -> str:100    return "sha-" + hashlib.sha256(golden_path.read_bytes()).hexdigest()[:8]101 102 103def _config_id(name: str, spec: dict, mock_config: Path | None) -> str:104    if spec["entry"] == "langchain":105        # Encode the real model so langchain-openai/-anthropic carry meaningful106        # provenance instead of the constant +00000000 sentinel (audit #7); the107        # model is what actually bills. config_id is free-form, schema untouched.108        return f"{name}+{MODEL_DEFAULTS[spec['provider']]}"109    return f"{name}+{_config_hash(mock_config or spec.get('config'))}"110 111 112def _code_version() -> str:113    sha = subprocess.run(114        ["git", "rev-parse", "--short", "HEAD"], capture_output=True, text=True, check=True115    ).stdout.strip()116    dirty = subprocess.run(117        ["git", "status", "--porcelain"], capture_output=True, text=True, check=True118    ).stdout.strip()119    return sha + ("-dirty" if dirty else "")120 121 122# name -> (entry, config yaml or None, provider flag or None, corpus, golden path)123REGISTRY: dict[str, dict] = {124    "custom-openai": {125        "entry": "custom",126        "config": Path("configs/default.yaml"),127        "corpus": "fastapi",128        "golden": Path("agent_bench/evaluation/datasets/tech_docs_golden.json"),129    },130    "custom-anthropic": {131        "entry": "custom",132        "config": Path("configs/anthropic.yaml"),133        "corpus": "fastapi",134        "golden": Path("agent_bench/evaluation/datasets/tech_docs_golden.json"),135    },136    "langchain-openai": {137        "entry": "langchain",138        "provider": "openai",139        "golden": Path("agent_bench/evaluation/datasets/tech_docs_golden.json"),140    },141    "langchain-anthropic": {142        "entry": "langchain",143        "provider": "anthropic",144        "golden": Path("agent_bench/evaluation/datasets/tech_docs_golden.json"),145    },146    # --- k8s corpus (campaign executed 2026-06-22, paid) --------------------147    # The full 25-question k8s campaign has run: K=5 per config, one run_id each.148    # Envelopes live under results/epochs/ (gitignored, force-added like the149    # fastapi run); tidy rows in results/long/k8s/; the report's k8s section150    # regenerates with `make evaluate-stats`. langchain has no --corpus flag (see151    # _entry_cmd), so k8s is custom-only and the framework-equivalence (TOST)152    # section is empty by construction, not a regression. Re-run with:153    #   make epochs K=5 CONFIGS=custom-openai-k8s,custom-anthropic-k8s CONFIRM_PAID=1154    "custom-openai-k8s": {155        "entry": "custom",156        "config": Path("configs/default.yaml"),157        "corpus": "k8s",158        "golden": Path("agent_bench/evaluation/datasets/k8s_golden.json"),159    },160    "custom-anthropic-k8s": {161        "entry": "custom",162        "config": Path("configs/anthropic.yaml"),163        "corpus": "k8s",164        "golden": Path("agent_bench/evaluation/datasets/k8s_golden.json"),165    },166}167 168 169def _entry_cmd(spec: dict, raw_out: Path, mock_config: Path | None) -> list[str]:170    config = mock_config or spec.get("config")171    if spec["entry"] == "custom":172        cmd = [173            sys.executable,174            "scripts/evaluate.py",175            "--mode",176            "deterministic",177            "--output",178            str(raw_out),179        ]180        if config:181            cmd += ["--config", str(config)]182        if spec.get("corpus"):183            cmd += ["--corpus", spec["corpus"]]184        return cmd185    cmd = [186        sys.executable,187        "scripts/run_langchain_eval.py",188        "--provider",189        spec["provider"],190        "--output",191        str(raw_out),192    ]193    if config:194        cmd += ["--config", str(config)]195    return cmd196 197 198def run_config_epochs(199    name: str,200    k: int,201    dest_root: Path,202    mock_config: Path | None = None,203    golden_override: Path | None = None,204    allow_paid: bool = False,205) -> list[Path]:206    spec = REGISTRY[name]207    # Guardrail 2 (no silent paid calls): the only free path is a custom entry208    # with a mock config. langchain entries always use a real LLM; a custom209    # entry without a mock config uses its real provider. Refuse otherwise so210    # direct script invocation cannot bill around the Makefile's CONFIRM_PAID.211    if mock_config is not None and not _is_mock_config(mock_config):212        raise SystemExit(213            f"refusing: --mock-config {mock_config} does not set provider.default: mock, "214            "so it would make real (paid) API calls. Pass an actual mock config, "215            "or --allow-paid to confirm you intend to spend money."216        )217    is_free = spec["entry"] == "custom" and mock_config is not None218    if not is_free and not allow_paid:219        raise SystemExit(220            f"refusing: config {name!r} would make real (paid) API calls "221            f"(entry={spec['entry']}, mock_config={'set' if mock_config else 'none'}); "222            "pass --allow-paid to confirm you intend to spend money"223        )224    golden = golden_override or spec["golden"]225    config_id = _config_id(name, spec, mock_config)226    run_id = new_ulid()227    written = []228    for epoch in range(1, k + 1):229        # Raw harness output lives UNDER the run dir, not a sibling results/epochs/raw/,230        # so results/epochs/ contains only run_id dirs. The WP5 convert loop globs231        # results/epochs/*/ and would otherwise feed raw EvalResult lists (no envelope232        # wrapper) to convert_envelopes and crash (audit #4).233        raw_out = dest_root / run_id / "raw" / f"{name}_e{epoch}.json"234        raw_out.parent.mkdir(parents=True, exist_ok=True)235        subprocess.run(_entry_cmd(spec, raw_out, mock_config), check=True)236        written.append(237            write_envelope(238                raw_output=raw_out,239                dest_dir=dest_root / run_id,240                run_id=run_id,241                config_id=config_id,242                epoch=epoch,243                code_version=_code_version(),244                dataset_version=_dataset_version(golden),245                timestamp=datetime.datetime.now(tz=datetime.timezone.utc).isoformat(),246            )247        )248    return written249 250 251def _preflight(names: list[str], mock_config: Path | None) -> None:252    """Validate every requested config BEFORE any subprocess runs, so a broken253    config fails at zero cost instead of after earlier configs have already254    billed (paid-path audit finding #2). Checks the registry name, that a custom255    entry's corpus resolves in its config, that the golden file and corpus store256    exist, and that the API key for any paying entry is present in os.environ257    (load_dotenv has already run in main()).258    """259    from agent_bench.core.config import load_config260 261    problems: list[str] = []262    for name in names:263        spec = REGISTRY.get(name)264        if spec is None:265            problems.append(f"{name}: unknown config (known: {sorted(REGISTRY)})")266            continue267        if not Path(spec["golden"]).exists():268            problems.append(f"{name}: golden dataset missing: {spec['golden']}")269        if spec["entry"] == "custom":270            cfg_path = mock_config or spec.get("config")271            try:272                cfg = load_config(Path(cfg_path) if cfg_path else None)273            except Exception as exc:274                problems.append(f"{name}: config {cfg_path} failed to load ({exc})")275                continue276            provider = cfg.provider.default277            corpus = spec.get("corpus")278            if corpus and corpus not in cfg.corpora:279                problems.append(280                    f"{name}: corpus {corpus!r} not in {cfg_path} corpora "281                    f"{sorted(cfg.corpora)}; evaluate.py would exit 1 mid-run"282                )283            elif corpus and not Path(cfg.corpora[corpus].store_path).exists():284                problems.append(285                    f"{name}: corpus {corpus!r} store {cfg.corpora[corpus].store_path} "286                    "missing; build it (make ingest) before the campaign"287                )288        else:289            provider = spec["provider"]290        free = spec["entry"] == "custom" and _is_mock_config(mock_config)291        if not free and provider != "mock":292            env = PROVIDER_KEY_ENV.get(provider)293            if env and not os.environ.get(env):294                problems.append(f"{name}: {env} not set; a real {provider} run needs it")295    if problems:296        raise SystemExit(297            "preflight failed (no API calls made); fix before spending:\n  - "298            + "\n  - ".join(problems)299        )300 301 302def main() -> None:303    from dotenv import load_dotenv304 305    # Load the gitignored .env so the provider key reads (os.environ, in the306    # subprocesses) and the preflight key check below see the keys (audit #3).307    load_dotenv()308    parser = argparse.ArgumentParser(description=__doc__)309    parser.add_argument("--k", type=int, required=True)310    parser.add_argument("--configs", required=True, help="comma-separated registry names")311    parser.add_argument("--dest", default="results/epochs")312    parser.add_argument(313        "--mock-config", default=None, help="config YAML forcing provider mock (free)"314    )315    parser.add_argument("--golden", default=None, help="override golden path (tests only)")316    parser.add_argument(317        "--allow-paid",318        action="store_true",319        help="confirm real (paid) API calls for non-mock runs (langchain or no mock config)",320    )321    parser.add_argument(322        "--dry-run",323        action="store_true",324        help="run preflight only: validate every config, make no API calls, spend nothing",325    )326    args = parser.parse_args()327    names = args.configs.split(",")328    mock_config = Path(args.mock_config) if args.mock_config else None329    _preflight(names, mock_config=mock_config)330    if args.dry_run:331        print(f"preflight OK for {names}; --dry-run, no API calls made")332        return333    for name in names:334        files = run_config_epochs(335            name,336            args.k,337            Path(args.dest),338            mock_config=mock_config,339            golden_override=Path(args.golden) if args.golden else None,340            allow_paid=args.allow_paid,341        )342        print(f"{name}: wrote {len(files)} epoch envelopes")343 344 345if __name__ == "__main__":346    main()347