File size: 6,426 Bytes
4ba2447 9a6b377 4ba2447 6c7e6d8 4ba2447 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 | #!/usr/bin/env python3
"""Harness-edit v2 - rollout BEHAVIOR scorer (the SkillOpt gate that catches behavior, not law text).
v1 (scripts/score_skill.py) lints the SKILL TEXT for rule coverage: necessary, not sufficient. A rule
can be present in the law and the agent still violates it (observed: an agent passed 8/8 law-coverage
yet committed several of these same mistakes in one session). v2 scores the AGENT'S RESPONSE to a
realistic prompt -- with the skill vs without -- and gates on whether the skill actually changes
behavior on the held-out scenarios.
Harness-agnostic via a pluggable adapter (SkillOpt's adapter interface, arXiv 2605.23904):
transcript : read a pre-recorded response (offline; no API key) -> CI / self-test
echo : returns the prompt (plumbing only)
claude : Anthropic API, needs ANTHROPIC_API_KEY -> the Claude Code / acer harness
codex : OpenAI API, needs OPENAI_API_KEY -> the Codex / liris harness
Live adapters error clearly if the key is absent; no key is read into the report or stored.
Usage:
python scripts/rollout_score.py --scenarios examples/asolaria-scenarios.json \
--harness transcript --transcript examples/transcripts/good.json \
--skill examples/asolaria-claims-gate-sample.md
"""
from __future__ import annotations
import argparse
import json
import os
import sys
from pathlib import Path
def norm(t: str) -> str:
return " ".join((t or "").lower().split())
# ---- adapters: (skill_text, prompt, scenario_id) -> response text (the rollout trajectory) ----
def run_adapter(harness: str, skill: str, prompt: str, sid: str, store: dict) -> str:
if harness == "echo":
return prompt
if harness == "transcript":
if sid not in store:
raise KeyError(f"no transcript for scenario id '{sid}'")
return store[sid]
if harness == "claude":
key = os.environ.get("ANTHROPIC_API_KEY")
if not key:
raise RuntimeError("claude adapter needs ANTHROPIC_API_KEY; use --harness transcript for offline CI")
import anthropic # noqa: PLC0415
client = anthropic.Anthropic(api_key=key)
model = os.environ.get("HARNESS_CLAUDE_MODEL", "claude-opus-4-8")
msg = client.messages.create(
model=model, max_tokens=1024,
system=(skill.strip() or "You are a careful agent."),
messages=[{"role": "user", "content": prompt}],
)
return "".join(b.text for b in msg.content if getattr(b, "type", None) == "text")
if harness == "codex":
key = os.environ.get("OPENAI_API_KEY")
if not key:
raise RuntimeError("codex adapter needs OPENAI_API_KEY; use --harness transcript for offline CI")
from openai import OpenAI # noqa: PLC0415
client = OpenAI(api_key=key)
model = os.environ.get("HARNESS_CODEX_MODEL", "gpt-5.5")
r = client.chat.completions.create(model=model, messages=[
{"role": "system", "content": (skill.strip() or "You are a careful agent.")},
{"role": "user", "content": prompt},
])
return r.choices[0].message.content or ""
raise ValueError(f"unknown harness '{harness}'")
# ---- rubric: judge the RESPONSE (behavior), not the law text ----
def judge(resp: str, rubric: dict) -> dict:
r = norm(resp)
applied = any(norm(m) in r for m in rubric.get("apply_any", []))
failed = [m for m in rubric.get("fail_any", []) if norm(m) in r]
return {"pass": bool(applied and not failed), "applied": applied, "failed": failed}
def run(scenarios, skill, harness, store, baseline):
rows = []
for s in scenarios:
sid, prompt, rub = s["id"], s["prompt"], s["rubric"]
row = {"id": sid, "with_skill": judge(run_adapter(harness, skill, prompt, sid, store), rub)}
if baseline:
bid = sid + "::baseline" if harness == "transcript" else sid
row["no_skill"] = judge(run_adapter(harness, "", prompt, bid, store), rub)
rows.append(row)
return rows
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--scenarios", required=True)
ap.add_argument("--skill", default="", help="path to skill/law markdown (empty = no skill)")
ap.add_argument("--harness", default="transcript", choices=["transcript", "echo", "claude", "codex"])
ap.add_argument("--transcript", help="transcript JSON map for --harness transcript")
ap.add_argument("--baseline", action="store_true", help="also run no-skill and report the skill delta")
ap.add_argument("--report")
a = ap.parse_args()
try:
scenarios = json.loads(Path(a.scenarios).read_text(encoding="utf-8"))
skill = Path(a.skill).read_text(encoding="utf-8") if a.skill else ""
store = (json.loads(Path(a.transcript).read_text(encoding="utf-8"))
if (a.harness == "transcript" and a.transcript) else {})
rows = run(scenarios, skill, a.harness, store, a.baseline)
except Exception as exc: # noqa: BLE001
print(f"ROLLOUT|ok=0|error={type(exc).__name__}|detail={exc}", file=sys.stderr)
return 2
passed = sum(1 for r in rows if r["with_skill"]["pass"])
total = len(rows)
rep = {"ok": total > 0 and passed == total, "passed": passed, "total": total,
"score": passed / total if total else 0.0, "harness": a.harness, "results": rows}
if a.baseline:
base = sum(1 for r in rows if r.get("no_skill", {}).get("pass"))
rep["baseline_passed"] = base
rep["skill_delta"] = passed - base
if a.report:
Path(a.report).parent.mkdir(parents=True, exist_ok=True)
Path(a.report).write_text(json.dumps(rep, indent=2, sort_keys=True) + "\n", encoding="utf-8")
line = f"ROLLOUT|ok={1 if rep['ok'] else 0}|passed={passed}|total={total}|score={rep['score']:.3f}|harness={a.harness}"
if a.baseline:
line += f"|baseline={rep['baseline_passed']}|skill_delta={rep['skill_delta']}"
print(line)
for r in rows:
extra = f"|no_skill={1 if r['no_skill']['pass'] else 0}" if "no_skill" in r else ""
bad = f"|hit_fail={','.join(r['with_skill']['failed'])}" if r["with_skill"]["failed"] else ""
print(f"ROLLOUTCASE|id={r['id']}|with_skill={1 if r['with_skill']['pass'] else 0}{extra}{bad}")
return 0 if rep["ok"] else 1
if __name__ == "__main__":
raise SystemExit(main())
|