Spaces:
Paused
Paused
File size: 12,824 Bytes
f10d14f | 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 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 | """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())
|