Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
#!/usr/bin/env python3
"""Agent-loop eval harness: does an LLM agent actually drive akcli correctly?
The rest of the test suite proves the *tool* is correct; this harness measures
the other half of the AI-native claim — an agent reading the skills/docs must
produce an op-list that akcli validates, applies behind the safety rails, and
that lands the intended netlist. Each task under ``tasks/`` is a small,
self-contained design request with a committed ground truth:
* ``task.md`` — the natural-language prompt an agent receives
* ``expected_nets.json`` — the target netlist (named nets -> pin members)
* ``reference_ops.json`` — a known-good solution (also the harness self-check:
``tests/test_agent_eval.py`` asserts every reference scores 1.0, so the
tasks can never drift from real CLI behavior)
Scoring is deterministic and offline: the candidate op-list is structurally
validated (``akcli ops validate``), applied to a fresh sheet
(``akcli new`` + ``draw --apply --strict-nets``), and the resulting named nets
are compared against the ground truth (exact pin-membership match; unnamed
nets ignored; spurious extra named nets are reported and fail the task).
Modes::
python tools/agent_eval/run_eval.py --reference # harness self-check
python tools/agent_eval/run_eval.py --ops-dir OUT/ # score OUT/<task>.json
python tools/agent_eval/run_eval.py --agent-cmd 'claude -p "$(cat "$TASK_FILE")" > "$OPS_OUT"'
``--agent-cmd`` runs a shell command once per task with ``TASK_FILE`` (the
prompt path) and ``OPS_OUT`` (where the command must write the op-list JSON)
in the environment — plug in any LLM agent. Not wheel-packaged; dev-only.
"""
from __future__ import annotations
import argparse
import contextlib
import io
import json
import os
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
from types import SimpleNamespace
REPO = Path(__file__).resolve().parents[2]
TASKS_DIR = Path(__file__).resolve().parent / "tasks"
SYMBOLS = [
REPO / "tests/fixtures/kicad/symbols/Device.kicad_sym",
REPO / "tests/fixtures/kicad/symbols/power.kicad_sym",
]
if str(REPO / "src") not in sys.path:
sys.path.insert(0, str(REPO / "src"))
def _akcli(argv: list[str]) -> SimpleNamespace:
"""Run akcli IN-PROCESS (the golden-corpus tests' pattern).
Scoring calls akcli 4x per task; a subprocess per call would pay an
interpreter start each time for no isolation benefit — only the external
agent command (``--agent-cmd``) runs as a subprocess.
"""
from akcli.cli import main as _main # lazy: after sys.path setup
out, err = io.StringIO(), io.StringIO()
with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err):
rc = _main(list(argv))
return SimpleNamespace(returncode=rc, stdout=out.getvalue(),
stderr=err.getvalue())
def _load_expected(task_dir: Path) -> dict[str, frozenset[str]]:
doc = json.loads((task_dir / "expected_nets.json").read_text(encoding="utf-8"))
return {n["name"]: frozenset(n["members"]) for n in doc["nets"]}
def score_ops(task_dir: Path, ops_path: Path) -> dict:
"""Score one candidate op-list against a task's ground-truth netlist."""
result: dict = {"task": task_dir.name, "valid": False, "applied": False,
"score": 0.0, "matched": 0, "total": 0, "errors": []}
expected = _load_expected(task_dir)
result["total"] = len(expected)
try:
oplist = json.loads(ops_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
result["errors"].append(f"unreadable op-list: {exc}")
return result
with tempfile.TemporaryDirectory(prefix="akcli-eval-") as td:
tmp = Path(td)
sheet = tmp / "board.kicad_sch"
new = _akcli(["new", str(sheet)])
if new.returncode != 0:
result["errors"].append(f"akcli new failed: {new.stderr.strip()}")
return result
# Hierarchy tasks ship pre-made child sheets (setup/*.kicad_sch,
# committed so their root uuids — and thus every derived uuid — are
# deterministic); they land next to the board so a `sheets`-routed
# op-list resolves them.
setup = task_dir / "setup"
if setup.is_dir():
for f in sorted(setup.glob("*.kicad_sch")):
shutil.copy2(f, tmp / f.name)
# The harness owns the target filename; everything else is the agent's.
if isinstance(oplist, dict):
oplist["target_file"] = sheet.name
cand = tmp / "candidate.json"
cand.write_text(json.dumps(oplist), encoding="utf-8", newline="\n")
val = _akcli(["ops", "validate", str(cand), "--json"])
try:
val_doc = json.loads(val.stdout)
except json.JSONDecodeError:
result["errors"].append(f"ops validate emitted no JSON: "
f"{val.stderr.strip()[:300]}")
return result
result["valid"] = bool(val_doc.get("valid"))
if not result["valid"]:
result["errors"].extend(
f"[{e.get('op_index')}] {e.get('code')}: {e.get('message')}"
for e in val_doc.get("errors", []))
return result
draw = _akcli(["draw", str(sheet), "--ops", str(cand), "--apply",
"--strict-nets", "--no-erc",
*(a for s in SYMBOLS for a in ("--symbols", str(s)))])
result["applied"] = draw.returncode == 0
if not result["applied"]:
result["errors"].append(
f"draw --apply failed (exit {draw.returncode}): "
f"{(draw.stderr or draw.stdout).strip()[:500]}")
return result
# Optional post-check: tasks that teach the SAFE-RE-PACK discipline
# ship a postcheck_arrange.json; the drawn sheet must survive an
# `arrange --groups --apply` (the net-preservation gate refuses a
# sheet whose cross-block connectivity is not label-on-pin), and the
# ground-truth nets are compared AFTER the re-pack.
post = task_dir / "postcheck_arrange.json"
if post.is_file():
params = json.loads(post.read_text(encoding="utf-8"))
argv = ["arrange", str(sheet), "--groups", "--apply",
*(a for s in SYMBOLS for a in ("--symbols", str(s)))]
for flag in ("group-gap", "page-width", "row-width", "margin"):
key = flag.replace("-", "_")
if key in params:
argv += [f"--{flag}", str(params[key])]
arr = _akcli(argv)
result["arranged"] = arr.returncode == 0
if not result["arranged"]:
result["errors"].append(
f"postcheck arrange --groups failed (exit "
f"{arr.returncode}): the re-pack must be net-preserving "
f"— {(arr.stderr or arr.stdout).strip()[:400]}")
return result
nets = _akcli(["nets", str(sheet), "--json"])
doc = json.loads(nets.stdout)
observed = {n["name"]: frozenset(n["members"])
for n in doc["nets"] if n["name"]}
matched = sum(1 for name, members in expected.items()
if observed.get(name) == members)
extras = sorted(set(observed) - set(expected))
result["matched"] = matched
result["extra_named_nets"] = extras
for name, members in expected.items():
got = observed.get(name)
if got != members:
result["errors"].append(
f"net {name!r}: expected {sorted(members)}, "
f"got {sorted(got) if got else 'MISSING'}")
if extras:
result["errors"].append(f"unexpected named nets: {extras}")
result["score"] = round(matched / len(expected), 4) if expected else 1.0
result["pass"] = (result["applied"] and matched == len(expected)
and not extras)
return result
def _tasks() -> list[Path]:
return sorted(d for d in TASKS_DIR.iterdir() if (d / "task.md").is_file())
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
mode = ap.add_mutually_exclusive_group(required=True)
mode.add_argument("--reference", action="store_true",
help="score the committed reference solutions "
"(harness self-check; must be 100%%)")
mode.add_argument("--ops-dir", type=Path,
help="score pre-generated <task-name>.json op-lists")
mode.add_argument("--agent-cmd",
help="shell command run once per task with TASK_FILE / "
"OPS_OUT in the environment")
ap.add_argument("--json", action="store_true", help="emit a JSON report")
args = ap.parse_args()
results = []
with tempfile.TemporaryDirectory(prefix="akcli-eval-out-") as td:
for task in _tasks():
if args.reference:
ops_path = task / "reference_ops.json"
elif args.ops_dir:
ops_path = args.ops_dir / f"{task.name}.json"
else:
ops_path = Path(td) / f"{task.name}.json"
env = dict(os.environ, TASK_FILE=str(task / "task.md"),
OPS_OUT=str(ops_path))
proc = subprocess.run(args.agent_cmd, shell=True, env=env,
capture_output=True, text=True)
if proc.returncode != 0 or not ops_path.is_file():
results.append({"task": task.name, "pass": False,
"score": 0.0,
"errors": [f"agent command failed: "
f"{proc.stderr.strip()[:300]}"]})
continue
if not ops_path.is_file():
results.append({"task": task.name, "pass": False, "score": 0.0,
"errors": [f"no op-list at {ops_path}"]})
continue
results.append(score_ops(task, ops_path))
passed = sum(1 for r in results if r.get("pass"))
if args.json:
print(json.dumps({"tasks": results, "passed": passed,
"total": len(results)}, indent=2))
else:
for r in results:
mark = "PASS" if r.get("pass") else "FAIL"
print(f"{mark} {r['task']} score={r.get('score', 0.0)}")
for e in r.get("errors", []):
print(f" {e}")
print(f"\n{passed}/{len(results)} tasks passed")
return 0 if passed == len(results) else 1
if __name__ == "__main__":
raise SystemExit(main())
2126c3284This file is pinned to the selected release. Download the original to open it in your design tool.