Sentinel / eval /field_study.py
Anbu-00001's picture
Itemator runner: token-guarded evaluation jobs (replaces the probe)
f10d14f verified
Raw History Blame Contribute Delete
12.8 kB
"""Field study: real Duo Developer Flow runs on this repository, measured with the Itemator job.
Pre-registered issue list: eval/field_study_issues.json (committed before the first run). For each issue, one at a time:
create the issue, assign the Duo Developer service account, wait for the merge request it opens and for that merge
request's pipeline, read the `itemator` job's itemator.json, count the session's tool calls, then close the merge
request and the issue and delete the branch. Nothing is merged. Raw records go to results/field_study_raw.json after
every run, so a stopped run resumes where it stopped.
usage (from the repository root, glab authenticated):
python3 eval/field_study.py --dry-run # print the plan only
python3 eval/field_study.py --max 2 --credit-stop 160 # run at most 2 issues; stop if the meter reads 160 or more
python3 eval/field_study.py --summarize # write results/field_study.json from the raw records
Standard library only. Network: the GitLab API through `glab api` (this project only).
"""
from __future__ import annotations
import argparse, json, pathlib, re, statistics, subprocess, sys, time, urllib.parse
ROOT = pathlib.Path(__file__).resolve().parent.parent
ISSUES = ROOT / "eval" / "field_study_issues.json"
RAW = ROOT / "results" / "field_study_raw.json"
OUT = ROOT / "results" / "field_study.json"
PROJECT = 87291130
NAMESPACE = "gitlab-ai-hackathon"
DEVELOPER = "duo-developer-gitlab-ai-hackathon"
MR_WAIT, PIPELINE_WAIT, POLL = 15 * 60, 12 * 60, 20 # seconds: agent run, pipeline, spacing of polls
def glab(*args: str, method: str = "GET", fields: dict | None = None, retries: int = 3):
cmd = ["glab", "api", *(["-X", method] if method != "GET" else []), *args]
for k, v in (fields or {}).items():
cmd += ["-f", f"{k}={v}"]
for attempt in range(retries):
r = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
if r.returncode == 0:
try:
return json.loads(r.stdout) if r.stdout.strip() else None
except json.JSONDecodeError:
return r.stdout
time.sleep(5 * (attempt + 1))
raise RuntimeError(f"glab {' '.join(args)[:80]} failed: {r.stderr.strip()[:160]}")
def meter() -> float | None:
r = subprocess.run(["glab", "api", "graphql", "-f",
f'query={{ currentUser {{ creditsUsage(namespacePath:"{NAMESPACE}") {{ creditsUsed }} }} }}'],
capture_output=True, text=True, timeout=60)
try:
return float(json.loads(r.stdout)["data"]["currentUser"]["creditsUsage"]["creditsUsed"])
except Exception:
return None
def tool_calls(session_id: str) -> int | None:
try:
cps = glab(f"ai/duo_workflows/workflows/{session_id}/checkpoints?per_page=100")
best = 0
for c in cps or []:
ck = c["checkpoint"]
ck = json.loads(ck) if isinstance(ck, str) else ck
conv = ck["channel_values"].get("conversation_history", {})
n = sum(len(m.get("kwargs", m).get("tool_calls") or []) for msgs in conv.values() for m in msgs)
best = max(best, n)
return best
except Exception:
return None
def session_done(issue_iid: int) -> bool:
notes = glab(f"projects/{PROJECT}/issues/{issue_iid}/notes?per_page=50") or []
return any("completed session" in (n.get("body") or "") or "failed session" in (n.get("body") or "") for n in notes)
def recount(records: list[dict]) -> int:
"""Read every session's checkpoints again, after all sessions have ended. The first reading (kept as
tool_calls_first_read) was taken while some sessions were still running."""
changed = 0
for r in records:
if not r.get("session_id"):
continue
n = tool_calls(r["session_id"])
if n is not None and n != r.get("tool_calls"):
r.setdefault("tool_calls_first_read", r.get("tool_calls"))
r["tool_calls"] = n
changed += 1
return changed
def run_one(issue: dict) -> dict:
t0 = time.time()
rec = {"id": issue["id"], "kind": issue["kind"], "title": issue["title"], "started": time.strftime("%Y-%m-%dT%H:%M:%S")}
made = glab(f"projects/{PROJECT}/issues", method="POST", fields={"title": issue["title"], "description": issue["description"]})
iid = made["iid"]; rec["issue_iid"] = iid
glab(f"projects/{PROJECT}/issues/{iid}/notes", method="POST", fields={"body": f"/assign @{DEVELOPER}"})
mr = None
while time.time() - t0 < MR_WAIT and mr is None:
time.sleep(POLL)
for m in glab(f"projects/{PROJECT}/merge_requests?state=opened&per_page=30") or []:
if re.search(rf"(work_items|issues)/{iid}\b|#{iid}\b", m.get("description") or ""):
mr = m; break
if mr is None:
rec["outcome"] = "no merge request within the time limit"
cleanup(iid, None, None)
return rec
rec["mr_iid"], rec["branch"], rec["seconds_to_mr"] = mr["iid"], mr["source_branch"], int(time.time() - t0)
rec["mr_title"] = mr["title"]; rec["mr_description"] = mr.get("description") or ""
notes = glab(f"projects/{PROJECT}/issues/{iid}/notes?per_page=50") or []
sess = [m.group(1) for n in notes for m in [re.search(r"agent-sessions/(\d+)", n.get("body") or "")] if m]
rec["session_id"] = sess[0] if sess else None
rec["session_id"] = sess[0] if sess else None
diffs = glab(f"projects/{PROJECT}/merge_requests/{mr['iid']}/diffs?per_page=100") or []
rec["files_changed"] = [d["new_path"] for d in diffs]
t1, status, job = time.time(), None, None
while time.time() - t1 < PIPELINE_WAIT:
pls = [p for p in glab(f"projects/{PROJECT}/merge_requests/{mr['iid']}/pipelines") or [] if p["source"] == "merge_request_event"]
if pls and pls[0]["status"] in ("success", "failed", "canceled"):
status = pls[0]["status"]
jobs = glab(f"projects/{PROJECT}/pipelines/{pls[0]['id']}/jobs") or []
job = next((j for j in jobs if j["name"] == "itemator"), None)
rec["pipeline_jobs"] = {j["name"]: {"stage": j["stage"], "status": j["status"]} for j in jobs}
if job and job["status"] in ("success", "failed"):
break
time.sleep(POLL)
rec["pipeline_status"] = status
while sess and time.time() - t0 < MR_WAIT + PIPELINE_WAIT and not session_done(iid): # the MR appears before the session ends
time.sleep(POLL)
rec["tool_calls"] = tool_calls(sess[0]) if sess else None
if job:
try:
art = glab(f"projects/{PROJECT}/jobs/{job['id']}/artifacts/itemator.json")
rec["itemator"] = {"rules_version": art.get("rules_version"), "state": art.get("state"),
"extras": [{"kind": e["kind"], "name": e["name"], "said": (e.get("plain_facts") or {}).get("said")} for e in art.get("extras", [])],
"unplaced": len(art.get("unplaced") or []), "card": art.get("card"), "checker": art.get("checker")}
except Exception as e:
rec["itemator_error"] = str(e)[:160]
rec.update(beyond_section(rec["mr_description"]))
rec["seconds_total"] = int(time.time() - t0)
cleanup(iid, mr["iid"], mr["source_branch"])
return rec
def beyond_section(description: str) -> dict:
"""The skill asks for a "## Beyond the issue" section ending in `Nothing.` when the change stays inside the issue."""
m = re.search(r"beyond the issue\s*\n(.*?)(?:\n#|\Z)", description, re.I | re.S)
body = m.group(1).strip() if m else ""
return {"has_beyond_section": bool(m), "beyond_names_something": bool(m) and body.rstrip(".").lower() != "nothing" and bool(body)}
def cleanup(issue_iid, mr_iid, branch):
if mr_iid:
glab(f"projects/{PROJECT}/merge_requests/{mr_iid}", method="PUT", fields={"state_event": "close"})
glab(f"projects/{PROJECT}/issues/{issue_iid}", method="PUT", fields={"state_event": "close"})
if branch:
try:
glab(f"projects/{PROJECT}/repository/branches/{urllib.parse.quote(branch, safe='')}", method="DELETE")
except Exception:
pass
def summarize(records: list[dict]) -> dict:
ok = [r for r in records if r.get("itemator")]
with_extras = [r for r in ok if r["itemator"]["extras"]]
n_extras = sum(len(r["itemator"]["extras"]) for r in ok)
said = {"listed": 0, "named": 0, "silent": 0, "no_sentence": 0}
for r in ok:
for e in r["itemator"]["extras"]:
said[e["said"] or "no_sentence"] += 1
calls = [r["tool_calls"] for r in records if r.get("tool_calls") is not None]
return {
"what": "Real Duo Developer Flow runs on this repository, one issue each, measured by the itemator job (rules v6). Counts, not precision. Pre-registered issues: eval/field_study_issues.json.",
"limits": "Few runs, one repository, one agent, our own issues, our AGENTS.md and the stay-inside-the-issue skill in place. Nothing was merged. The section test (has_beyond_section, beyond_names_something) was made stricter after the first two runs and applied again to their saved descriptions; the tool-call counts of every run were read again after all sessions had ended (tool_calls_first_read keeps the earlier reading).",
"issues_run": len(records), "merge_requests_opened": len([r for r in records if r.get("mr_iid")]),
"read_by_the_job": len(ok), "unplaced_total": sum((r["itemator"].get("unplaced") or 0) for r in ok),
"descriptions_with_nothing_beyond": len([r for r in records if r.get("has_beyond_section") and not r.get("beyond_names_something")]), "with_extras": len(with_extras), "extras": n_extras,
"extras_said": said,
"descriptions_with_a_beyond_section": len([r for r in records if r.get("has_beyond_section")]),
"descriptions_listing_something_under_the_heading": len([r for r in records if r.get("beyond_names_something")]), # may list what the issue asked for
"tool_calls_median": statistics.median(calls) if calls else None,
"tool_calls_total": sum(calls) if calls else None,
"per_issue": [{"id": r["id"], "kind": r["kind"], "mr_iid": r.get("mr_iid"), "files_changed": r.get("files_changed"),
"state": (r.get("itemator") or {}).get("state"),
"extras": [(e["kind"], e["name"]) for e in (r.get("itemator") or {}).get("extras", [])],
"unplaced": (r.get("itemator") or {}).get("unplaced"), "tool_calls": r.get("tool_calls"), "seconds_total": r.get("seconds_total"),
"pipeline_status": r.get("pipeline_status")} for r in records],
}
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--dry-run", action="store_true"); ap.add_argument("--summarize", action="store_true")
ap.add_argument("--recount", action="store_true"); ap.add_argument("--max", type=int, default=1); ap.add_argument("--credit-stop", type=float, default=0.0)
ap.add_argument("--issues", default=None, help="another pre-registered issue file (default: the eight-run study)")
ap.add_argument("--raw", default=None, help="another raw record file")
ap.add_argument("--out", default=None, help="another summary file")
a = ap.parse_args()
global ISSUES, RAW, OUT
if a.issues: ISSUES = ROOT / a.issues
if a.raw: RAW = ROOT / a.raw
if a.out: OUT = ROOT / a.out
spec = json.loads(ISSUES.read_text())
records = json.loads(RAW.read_text()) if RAW.exists() else []
if a.recount:
n = recount(records)
RAW.write_text(json.dumps(records, indent=1) + "\n"); print(f"recounted: {n} records changed"); return 0
if a.summarize:
OUT.write_text(json.dumps(summarize(records), indent=1) + "\n"); print(f"wrote {OUT.relative_to(ROOT)}"); return 0
todo = [i for i in spec["issues"] if i["id"] not in {r["id"] for r in records}]
print(f"{len(records)} done, {len(todo)} to do; this run: {min(a.max, len(todo))}")
if a.dry_run:
for i in todo[: a.max]: print(" would run:", i["id"], i["kind"], i["title"])
return 0
for issue in todo[: a.max]:
m = meter()
print(f"meter {m}", flush=True)
if a.credit_stop and m is not None and m >= a.credit_stop:
print("credit stop reached; not starting another run"); break
rec = run_one(issue)
records.append(rec); RAW.write_text(json.dumps(records, indent=1) + "\n")
print(f"issue {issue['id']}: mr {rec.get('mr_iid')}, calls {rec.get('tool_calls')}, "
f"extras {[(e['kind'], e['name']) for e in (rec.get('itemator') or {}).get('extras', [])]}", flush=True)
return 0
if __name__ == "__main__":
sys.exit(main())