| #!/usr/bin/env python3 | |
| """Memory against no memory, and harness against harness, on a set of tasks. | |
| Each task in the set is a prompt and a regular expression its answer must match. Each | |
| run is one prompted launch; the conditions are: | |
| memory the harness through `amem launch --prompt`, with the record attached, so T0, | |
| search, recall and the hooks are there as in any session; | |
| empty the same launch over an empty record (`--empty-memory`): the same sandbox, | |
| egress and tools, and nothing in memory, so the only difference from | |
| `memory` is what the record holds; | |
| bare the harness as installed, in an empty working directory, nothing attached: | |
| as people use it today, its web and shell tools included, so it may | |
| rediscover an answer from sources at the cost of doing so. On a gateway a | |
| bare harness may not even connect, where the launcher's egress fixes the | |
| gateway's shape for it; | |
| isolated the bare harness with its web, shell and subagent tools off: what it knows | |
| without memory and without rediscovery. Claude Code only, so far. | |
| Both conditions report the tokens the harness itself counted, read from the transcript | |
| it left, so the two are measured the same way; `amem usage` holds the egress's count | |
| for the memory condition as well. The answer is what the harness printed; the grade is | |
| whether it matches `expect`. | |
| scripts/eval.py evals/demo.toml --root ~/memory-demo2 --harness claude --auth subscription | |
| scripts/eval.py evals/demo.toml --root ~/memory-demo2 --harness codex,claude --auth gateway --model qwen3.8-27b | |
| scripts/eval.py evals/demo.toml --root ~/memory-demo2 --conditions memory,empty | |
| Results go to evals/out/<set>-<stamp>.json and a table to stdout. Bare runs are known | |
| for claude and codex; the memory condition takes every harness the launcher does. | |
| """ | |
| import argparse | |
| import glob | |
| import json | |
| import os | |
| import re | |
| import shutil | |
| import signal | |
| import subprocess | |
| import sys | |
| import tempfile | |
| import threading | |
| import time | |
| import tomllib | |
| from pathlib import Path | |
| HERE = Path(__file__).resolve().parent.parent | |
| AMEM = HERE / "target" / "release" / "amem" | |
| BARE = { | |
| "claude": lambda prompt, model: ["claude", "-p", prompt, "--dangerously-skip-permissions"] + (["--model", model] if model else []), | |
| "codex": lambda prompt, model: ["codex", "exec", "--skip-git-repo-check", "--dangerously-bypass-approvals-and-sandbox", prompt] + (["-m", model] if model else []), | |
| } | |
| # the bare harness with no way to rediscover: no web, no shell, no subagents, and no | |
| # files either, since the sources may well be on the same machine | |
| ISOLATED = { | |
| "claude": lambda prompt, model: BARE["claude"](prompt, model) + ["--disallowedTools", "WebFetch,WebSearch,Bash,Agent,Task,Workflow,Read,Glob,Grep,Edit,Write,NotebookEdit,Skill"], | |
| } | |
| def newest(patterns, since): | |
| files = [] | |
| for pat in patterns: | |
| # the harnesses keep their transcripts under dot-directories, which glob skips | |
| # unless told otherwise | |
| files += [p for p in glob.glob(pat, recursive=True, include_hidden=True) if os.path.getmtime(p) >= since] | |
| return max(files, key=os.path.getmtime) if files else None | |
| def usage_of(path): | |
| """What the harness counted, from its transcript: Claude Code's per-call usage summed | |
| once per message id, or Codex's last running total.""" | |
| if not path: | |
| return None | |
| seen = set() | |
| total = {"input": 0, "output": 0, "cache_read": 0, "cache_write": 0} | |
| codex = None | |
| model = None | |
| with open(path, encoding="utf-8", errors="replace") as f: | |
| for line in f: | |
| try: | |
| v = json.loads(line) | |
| except json.JSONDecodeError: | |
| continue | |
| if v.get("type") == "assistant": | |
| m = v.get("message", {}) | |
| mid = m.get("id") | |
| if mid and mid in seen: | |
| continue | |
| if mid: | |
| seen.add(mid) | |
| u = m.get("usage") | |
| if u: | |
| model = m.get("model", model) | |
| total["input"] += u.get("input_tokens", 0) | |
| total["output"] += u.get("output_tokens", 0) | |
| total["cache_read"] += u.get("cache_read_input_tokens", 0) | |
| total["cache_write"] += u.get("cache_creation_input_tokens", 0) | |
| elif v.get("type") == "turn_context": | |
| model = v.get("payload", {}).get("model", model) | |
| elif v.get("type") == "event_msg" and v.get("payload", {}).get("type") == "token_count": | |
| t = v["payload"].get("info", {}).get("total_token_usage") | |
| if t: | |
| cached = t.get("cached_input_tokens", 0) | |
| codex = { | |
| "input": max(t.get("input_tokens", 0) - cached, 0), | |
| "output": t.get("output_tokens", 0), | |
| "cache_read": cached, | |
| "cache_write": t.get("cache_write_input_tokens", 0), | |
| } | |
| u = codex or total | |
| u["model"] = model | |
| return u | |
| KEYS = ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "OPENAI_API_KEY", "GEMINI_API_KEY", "XAI_API_KEY", "META_API_KEY"] | |
| def bare_env(env, harness, auth): | |
| """The environment a bare harness runs in, matching the auth mode the memory | |
| condition uses: a login means no key in sight, since a harness prefers a key it is | |
| given over its login; a key means that key; the gateway means the harness's own | |
| base-URL override pointed at it with the PAT as the key.""" | |
| e = dict(env) | |
| if auth == "subscription": | |
| for k in KEYS: | |
| e.pop(k, None) | |
| elif auth == "gateway": | |
| url = e.get("LLMAO_URL", "http://127.0.0.1:4000") | |
| key = e.get("LLMAO_KEY", "") | |
| for k in KEYS: | |
| e.pop(k, None) | |
| if harness == "claude": | |
| e["ANTHROPIC_BASE_URL"] = url | |
| e["ANTHROPIC_AUTH_TOKEN"] = key | |
| e["CLAUDE_CODE_EFFORT_LEVEL"] = e.get("CLAUDE_CODE_EFFORT_LEVEL", "medium") | |
| elif harness == "codex": | |
| e["OPENAI_BASE_URL"] = url.rstrip("/") + "/v1" | |
| e["OPENAI_API_KEY"] = key | |
| if harness == "codex" and auth != "subscription": | |
| # a bare codex prefers its ChatGPT login to a key, and a gateway's model is | |
| # not one that login serves; a home with no login makes it use the key, and | |
| # a provider in its config is how it is pointed at a base other than OpenAI's | |
| home = tempfile.mkdtemp(prefix="eval-codex-home-") | |
| e["CODEX_HOME"] = home | |
| if auth == "gateway": | |
| base = e.get("LLMAO_URL", "http://127.0.0.1:4000").rstrip("/") + "/v1" | |
| Path(home, "config.toml").write_text( | |
| 'model_provider = "gateway"\n\n[model_providers.gateway]\nname = "gateway"\n' | |
| f'base_url = "{base}"\nenv_key = "OPENAI_API_KEY"\nwire_api = "responses"\n' | |
| ) | |
| return e | |
| def run(cmd, cwd, env, timeout): | |
| """Run one condition's command, and measure what it cost this host: the wall clock, | |
| and the CPU time and peak memory of its process tree as the kernel counted them when | |
| it was reaped (`wait4`), the same way for every condition. Under `memory` that is the | |
| launcher, the egress and the confined harness; bare, the harness alone.""" | |
| started = time.time() | |
| # a session of its own, so a timeout ends the whole tree, not only its first process | |
| p = subprocess.Popen(cmd, cwd=cwd, env=env, stdin=subprocess.DEVNULL, | |
| stdout=subprocess.PIPE, stderr=subprocess.PIPE, start_new_session=True) | |
| got = {} | |
| readers = [threading.Thread(target=lambda k=k, f=f: got.__setitem__(k, f.read())) | |
| for k, f in (("out", p.stdout), ("err", p.stderr))] | |
| for t in readers: | |
| t.start() | |
| timed_out = threading.Event() | |
| def kill(): | |
| timed_out.set() | |
| try: | |
| os.killpg(p.pid, signal.SIGKILL) | |
| except ProcessLookupError: | |
| pass # it ended as the clock ran out | |
| timer = threading.Timer(timeout, kill) | |
| timer.start() | |
| _, status, ru = os.wait4(p.pid, 0) | |
| timer.cancel() | |
| # reaped here, so the Popen must not wait for it again | |
| p.returncode = os.waitstatus_to_exitcode(status) | |
| for t in readers: | |
| t.join() | |
| text = lambda b: (b or b"").decode("utf-8", "replace") | |
| out, err, code = text(got.get("out")), text(got.get("err")), p.returncode | |
| if timed_out.is_set(): | |
| err, code = err + "\n[timed out]", 124 | |
| secs = time.time() - started | |
| # ru_maxrss is KiB on Linux, bytes on macOS | |
| rss_kib = ru.ru_maxrss / 1024 if sys.platform == "darwin" else ru.ru_maxrss | |
| resources = {"wall_s": round(secs, 1), "cpu_user_s": round(ru.ru_utime, 2), | |
| "cpu_sys_s": round(ru.ru_stime, 2), "peak_mib": round(rss_kib / 1024, 1)} | |
| return out, err, code, secs, resources | |
| def main(): | |
| ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) | |
| ap.add_argument("set", help="the TOML task set") | |
| ap.add_argument("--root", required=True, help="the record the memory condition attaches") | |
| ap.add_argument("--harness", default="claude", help="comma-separated: claude, codex, ...") | |
| ap.add_argument("--auth", default="subscription") | |
| ap.add_argument("--model", default=None) | |
| ap.add_argument("--provider", default=None) | |
| ap.add_argument("--conditions", default="memory,empty,bare") | |
| ap.add_argument("--timeout", type=int, default=600) | |
| ap.add_argument("--only", default=None, help="comma-separated task ids") | |
| args = ap.parse_args() | |
| with open(args.set, "rb") as f: | |
| spec = tomllib.load(f) | |
| tasks = spec["task"] | |
| preamble = spec.get("preamble", "") | |
| for t in tasks: | |
| t["prompt"] = preamble + t["prompt"] | |
| if args.only: | |
| keep = set(args.only.split(",")) | |
| tasks = [t for t in tasks if t["id"] in keep] | |
| harnesses = [h.strip() for h in args.harness.split(",") if h.strip()] | |
| conditions = [c.strip() for c in args.conditions.split(",") if c.strip()] | |
| root = Path(os.path.expanduser(args.root)) | |
| if "memory" in conditions and not AMEM.exists(): | |
| sys.exit("build first: make build") | |
| env = dict(os.environ) | |
| cred = root / "credentials.env" | |
| if cred.exists(): | |
| for line in cred.read_text().splitlines(): | |
| line = line.strip() | |
| if line and not line.startswith("#") and "=" in line: | |
| k, v = line.split("=", 1) | |
| env.setdefault(k.strip(), v.strip().strip("'\"")) | |
| results = [] | |
| for harness in harnesses: | |
| for cond in conditions: | |
| if cond == "bare" and harness not in BARE: | |
| print(f"skip: no bare form known for {harness}", file=sys.stderr) | |
| continue | |
| if cond == "isolated" and harness not in ISOLATED: | |
| print(f"skip: no isolated form known for {harness}", file=sys.stderr) | |
| continue | |
| for t in tasks: | |
| since = time.time() - 1 | |
| if cond in ("memory", "empty"): | |
| cmd = [str(AMEM), "--root", str(root), "launch", "--harness", harness, "--auth", args.auth, "--no-handoff", "--prompt", t["prompt"]] | |
| if cond == "empty": | |
| cmd.append("--empty-memory") | |
| if args.model: | |
| cmd += ["--model", args.model] | |
| if args.provider: | |
| cmd += ["--provider", args.provider] | |
| cwd = tempfile.mkdtemp(prefix="eval-mem-") | |
| out, err, code, secs, resources = run(cmd, cwd, env, args.timeout) | |
| session = None | |
| m = re.search(r"session (sess-[a-z0-9]+)", err) | |
| if m: | |
| session = m.group(1) | |
| roots = [root] | |
| m = re.search(r"empty memory at (\S+)", err) | |
| if m: | |
| roots.append(Path(m.group(1))) | |
| # the record keeps the session's transcript and takes it out of the | |
| # harness's home at the session's end: read the record's copy, by session | |
| homes = [str(r / "record" / "*" / ".transcripts" / f"{session}*.jsonl") for r in roots if session] | |
| homes += [str(r / "state" / "sandbox" / harness / "**" / "*.jsonl") for r in roots] | |
| transcript = newest(homes, since) | |
| empty_root = re.search(r"empty memory at (\S+)", err) | |
| else: | |
| empty_root = None | |
| cwd = tempfile.mkdtemp(prefix=f"eval-{cond}-") | |
| cmd = (ISOLATED if cond == "isolated" else BARE)[harness](t["prompt"], args.model) | |
| # a bare codex speaks to the gateway through its openai provider, which | |
| # reads OPENAI_BASE_URL; it still wants the model on its catalog, which a | |
| # gateway's is not, so the warning is expected | |
| out, err, code, secs, resources = run(cmd, cwd, bare_env(env, harness, args.auth), args.timeout) | |
| home = Path.home() | |
| transcript = newest([str(home / ".claude" / "projects" / "**" / "*.jsonl"), str(home / ".codex" / "sessions" / "**" / "*.jsonl")], since) | |
| session = None | |
| shutil.rmtree(cwd, ignore_errors=True) | |
| answer = out.strip() | |
| passed = re.search(t["expect"], answer, re.I | re.S) is not None | |
| usage = usage_of(transcript) | |
| # an empty record made for one run has served its purpose once read | |
| if cond == "empty" and empty_root and empty_root.group(1).startswith(tempfile.gettempdir()): | |
| shutil.rmtree(empty_root.group(1), ignore_errors=True) | |
| r = { | |
| "task": t["id"], "harness": harness, "condition": cond, "model": args.model, | |
| "pass": passed, "exit": code, "seconds": round(secs, 1), "session": session, | |
| "usage": usage, "resources": resources, "answer": answer[-600:], "transcript": transcript, | |
| "stderr": err.strip()[-600:] if code != 0 else "", | |
| } | |
| results.append(r) | |
| tok = f"{usage['input']}+{usage['cache_read']}c in, {usage['output']} out" if usage else "no transcript" | |
| cpu = resources["cpu_user_s"] + resources["cpu_sys_s"] | |
| print(f"{harness:<10} {cond:<7} {t['id']:<18} {'PASS' if passed else 'FAIL'} {secs:5.0f}s {tok}, cpu {cpu:.1f}s, peak {resources['peak_mib']:.0f} MiB", flush=True) | |
| out_dir = HERE / "evals" / "out" | |
| out_dir.mkdir(parents=True, exist_ok=True) | |
| stamp = time.strftime("%Y%m%dT%H%M%S") | |
| name = Path(args.set).stem | |
| out_path = out_dir / f"{name}-{stamp}.json" | |
| out_path.write_text(json.dumps({"set": args.set, "root": str(root), "args": vars(args), "results": results}, indent=1)) | |
| print() | |
| print("| harness | condition | passed | input tokens | cached | output | seconds | cpu s | peak MiB |") | |
| print("|---|---|---|---|---|---|---|---|---|") | |
| for harness in harnesses: | |
| for cond in conditions: | |
| rs = [r for r in results if r["harness"] == harness and r["condition"] == cond] | |
| if not rs: | |
| continue | |
| ok = sum(1 for r in rs if r["pass"]) | |
| us = [r["usage"] for r in rs if r["usage"]] | |
| tin = sum(u["input"] for u in us) | |
| tc = sum(u["cache_read"] for u in us) | |
| tout = sum(u["output"] for u in us) | |
| secs = sum(r["seconds"] for r in rs) | |
| cpu = sum(r["resources"]["cpu_user_s"] + r["resources"]["cpu_sys_s"] for r in rs) | |
| peak = max(r["resources"]["peak_mib"] for r in rs) | |
| print(f"| {harness} | {cond} | {ok}/{len(rs)} | {tin} | {tc} | {tout} | {secs:.0f} | {cpu:.1f} | {peak:.0f} |") | |
| print(f"\nwritten {out_path.relative_to(HERE)}") | |
| if __name__ == "__main__": | |
| main() |