#!/usr/bin/env python3 """Native-harness benchmark runner. Drives the live hack-house Rust TUI headlessly (tmux send-keys / capture-pane) the same way a human tester does, then grades each task by GROUND TRUTH inside the sandbox container (`podman exec`) — not by the model's self-reported summary. Produces a PASS/FAIL table with per-task wallclock latency so harness changes can be compared systematically over time. Prereqs (set up exactly as for a manual live test — see docs/findings-native-harness-2026-06-10.md and the tmux-testing memo): * a running server + Rust client in a tmux window (default hh-house:2), * an AI agent online with drive granted (`/ai start ` + `/grant ai`), * the sandbox container running (default `hack-house`). Usage: python bench/run.py --model qwen2.5:3b python bench/run.py --tier easy --no-net python bench/run.py --only multi-easy,code-medium python bench/run.py --dry-run # just print the selected matrix This runner only SENDS tasks and reads state; it never starts/stops the agent, so a crash leaves your session intact. """ import argparse import json import subprocess import sys import time from datetime import datetime from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent)) import tasks as T # noqa: E402 def sh(cmd, **kw): return subprocess.run(cmd, capture_output=True, text=True, **kw) def tmux(*args): return sh(["tmux", *args]) def capture(target, lines=4000): """Full scrollback of the TUI pane as text.""" r = tmux("capture-pane", "-t", target, "-p", "-S", f"-{lines}") return r.stdout def podman(container, snippet): """Run a POSIX-sh snippet in the container; return (rc, output).""" r = sh(["podman", "exec", container, "sh", "-c", snippet]) return r.returncode, (r.stdout + r.stderr).strip() def agent_online(target, model): return f"{model} (ai) online" in capture(target) or "online — ollama" in capture(target) def is_thinking(buf): return "is thinking" in buf def send_task(target, model, prompt): """Clear the input box, then type the /ai command and submit it.""" # The TUI has no kill-line; clear with backspaces (harmless when empty). tmux("send-keys", "-t", target, *(["BSpace"] * 6)) time.sleep(0.3) tmux("send-keys", "-t", target, "-l", f"/ai {model} !{prompt}") time.sleep(0.3) tmux("send-keys", "-t", target, "Enter") def scrape_summary(target, user): """Best-effort: the last `@` line visible in the chat viewport. Only cosmetic — grading is by ground truth, never by this text.""" buf = capture(target, lines=200) marker = f"@{user}" if marker not in buf: return "" tail = buf.rsplit(marker, 1)[-1] return " ".join(tail.splitlines()[:2]).strip(" │").strip() def wait_done(target, user, timeout, engage=45.0, quiet=9.0, settle=2.0): """Wait for a task to finish using the 'thinking' footer — a signal that is independent of the chat viewport (the TUI is a full-screen app, so capture-pane scrollback is NOT reliable chat history). Phase 1: wait for the agent to engage (thinking appears). Phase 2: consider the task done once thinking has stayed absent for `quiet` seconds (bridges the brief tool-exec gaps between the loop's model turns). Returns (done, summary).""" deadline = time.time() + timeout engage_by = time.time() + engage saw_thinking = False off_since = None while time.time() < deadline: time.sleep(settle) thinking = is_thinking(capture(target, lines=120)) if thinking: saw_thinking = True off_since = None continue if not saw_thinking: # not engaged yet; give it a window before treating quiet as 'done' if time.time() > engage_by: return False, scrape_summary(target, user) continue off_since = off_since or time.time() if time.time() - off_since >= quiet: return True, scrape_summary(target, user) return saw_thinking, scrape_summary(target, user) def run_task(args, task): container, target, user, model = args.container, args.target, args.user, args.model # Per-task isolation: wipe ONLY the suite's known artifacts (never the home # dir), then run any seed setup. podman(container, T.CLEAN) if task.setup: podman(container, task.setup) # Make sure the agent is idle before sending so we don't read a prior task's # 'thinking' as this task's engagement. for _ in range(15): if not is_thinking(capture(target, lines=120)): break time.sleep(2) t0 = time.time() send_task(target, model, task.prompt) done, summary = wait_done(target, user, args.timeout) elapsed = time.time() - t0 if not done: return {"id": task.id, "category": task.category, "tier": task.tier, "result": "TIMEOUT", "secs": round(elapsed, 1), "summary": summary[:160], "verify_out": ""} rc, out = podman(container, task.verify) return {"id": task.id, "category": task.category, "tier": task.tier, "result": "PASS" if rc == 0 else "FAIL", "secs": round(elapsed, 1), "summary": summary[:160], "verify_out": out[:160]} def print_table(rows): w_id = max(len("task"), *(len(r["id"]) for r in rows)) print(f"\n{'task':<{w_id}} {'tier':<6} {'result':<7} {'secs':>6} summary") print("-" * (w_id + 40)) for r in rows: print(f"{r['id']:<{w_id}} {r['tier']:<6} {r['result']:<7} " f"{r['secs']:>6} {r['summary'][:70]}") n = len(rows) p = sum(r["result"] == "PASS" for r in rows) print(f"\n{p}/{n} PASS" + (f" ({n - p} not passing)" if p < n else " — clean sweep")) def main(): ap = argparse.ArgumentParser() ap.add_argument("--model", default="qwen2.5:3b") ap.add_argument("--container", default="hack-house") ap.add_argument("--target", default="hh-house:2", help="tmux window of the TUI client") ap.add_argument("--user", default="dell", help="client username (for @mention detection)") ap.add_argument("--timeout", type=int, default=300, help="per-task wallclock cap (s)") ap.add_argument("--tier", choices=["easy", "medium", "hard"], action="append") ap.add_argument("--category", choices=["shell", "code", "git", "multi"], action="append") ap.add_argument("--only", help="comma-separated task ids") ap.add_argument("--no-net", action="store_true", help="skip git/network tasks") ap.add_argument("--dry-run", action="store_true") ap.add_argument("--out", help="write JSON results to this path") args = ap.parse_args() try: sys.stdout.reconfigure(line_buffering=True) # flush progress when backgrounded except Exception: pass ids = set(args.only.split(",")) if args.only else None selected = T.select(ids=ids, categories=args.category, tiers=args.tier, allow_net=not args.no_net) if not selected: print("no tasks selected", file=sys.stderr) return 2 if args.dry_run: for t in selected: net = " [net]" if t.needs_net else "" print(f"{t.id:<14} {t.tier:<6} {t.category:<6}{net} {t.prompt}") return 0 if not agent_online(args.target, args.model): print(f"! agent '{args.model}' does not look online in {args.target}; " f"start it (/ai start {args.model}) and /grant ai first.", file=sys.stderr) return 1 print(f"running {len(selected)} task(s) against {args.model} " f"(container={args.container}, tui={args.target})") rows = [] for i, task in enumerate(selected, 1): print(f"[{i}/{len(selected)}] {task.id} … ", end="", flush=True) r = run_task(args, task) print(f"{r['result']} ({r['secs']}s)") rows.append(r) print_table(rows) payload = {"ts": datetime.now().isoformat(timespec="seconds"), "model": args.model, "rows": rows} out = args.out or f"bench/results/{datetime.now():%Y%m%d-%H%M%S}-{args.model.replace(':', '_')}.json" Path(out).parent.mkdir(parents=True, exist_ok=True) Path(out).write_text(json.dumps(payload, indent=2)) print(f"\nresults → {out}") return 0 if all(r["result"] == "PASS" for r in rows) else 1 if __name__ == "__main__": raise SystemExit(main())