mirror of
https://github.com/stablyai/orca.git
synced 2026-11-01 09:37:33 +00:00
* test(computer): measure independently verified tasks * ci(computer): fit benchmark to repository guards * fix(computer): stabilize benchmark fixtures and CI
379 lines
18 KiB
Python
379 lines
18 KiB
Python
#!/usr/bin/env python3
|
|
"""Compare direct native providers on disposable, independently observed task fixtures."""
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import platform
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
import uuid
|
|
|
|
from process_scope import ProcessScope
|
|
from trial_contract import (DiscoveryError, MARKER, SELECTORS, TASKS, acknowledged, prepared,
|
|
schedule, select_unique, summarize, verdict)
|
|
|
|
HERE = Path(__file__).resolve().parent
|
|
REPO = HERE.parents[2]
|
|
WIN = sys.platform == "win32"
|
|
|
|
|
|
def read_json(path):
|
|
try:
|
|
return json.loads(Path(path).read_text(encoding="utf-8-sig"))
|
|
except (OSError, ValueError):
|
|
return None
|
|
|
|
|
|
def write_json(path, value):
|
|
temporary = Path(str(path) + ".tmp")
|
|
temporary.write_text(json.dumps(value, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
temporary.replace(path)
|
|
|
|
|
|
def wait_for(predicate, timeout=10):
|
|
end = time.monotonic() + timeout
|
|
while time.monotonic() < end:
|
|
value = predicate()
|
|
if value:
|
|
return value
|
|
time.sleep(0.02)
|
|
raise TimeoutError("independent setup/observation did not become ready")
|
|
|
|
|
|
def pin_source(ref, output):
|
|
if not re.fullmatch(r"[0-9a-f]{40}", ref):
|
|
raise ValueError("source must be a full commit SHA")
|
|
hashes = {}
|
|
for relative in ("native/computer-use-linux/runtime.py", "native/computer-use-windows/runtime.ps1"):
|
|
data = subprocess.check_output(["git", "show", f"{ref}:{relative}"], cwd=REPO)
|
|
destination = output / relative
|
|
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
destination.write_bytes(data)
|
|
hashes[relative] = hashlib.sha256(data).hexdigest()
|
|
return {"sha": ref, "files": hashes}
|
|
|
|
|
|
class Trial:
|
|
def __init__(self, args, directory, log):
|
|
self.args, self.directory, self.log = args, directory, log
|
|
self.instance = uuid.uuid4().hex
|
|
directory.mkdir()
|
|
self.targets = ProcessScope(str(directory))
|
|
self.engines = ProcessScope(str(directory))
|
|
self.sampler = self.typer = None
|
|
self.state_path = directory / "state.json"
|
|
self.sentinel_path = directory / "sentinel.json"
|
|
self.nonce = None
|
|
|
|
def spawn_target(self, command, **extra):
|
|
env = {**os.environ, "BENCH_INSTANCE": self.instance, "BENCH_STATE_DIR": str(self.directory)}
|
|
return self.targets.spawn(command, stdout=self.log, stderr=self.log, env=env, **extra)
|
|
|
|
def window(self, pid, title):
|
|
return wait_for(lambda: obs.find_window(pid=pid, title=title) if WIN
|
|
else obs.find_client(pid, title), 30)
|
|
|
|
def state(self):
|
|
state = read_json(self.state_path)
|
|
if state and state.get("instance") == self.instance and state.get("pid") == self.pid:
|
|
return state
|
|
return None
|
|
|
|
def launch(self):
|
|
if self.args.target == "electron":
|
|
flags = ["--force-renderer-accessibility"] + ([] if WIN else ["--no-sandbox"])
|
|
proc = self.spawn_target([self.args.electron, *flags, str(HERE / "electron/main.js")])
|
|
title = "BenchElectron"
|
|
elif WIN:
|
|
proc = self.spawn_target(["powershell.exe", "-NoLogo", "-NoProfile", "-STA", "-File",
|
|
str(HERE / "fixture_win.ps1"), "-StateDir", str(self.directory)], hidden=True)
|
|
title = "BenchTarget"
|
|
else:
|
|
proc = self.spawn_target([sys.executable, str(HERE / "fixture_gtk.py"), str(self.directory)])
|
|
title = "BenchTarget"
|
|
self.pid = proc.pid
|
|
self.target = self.window(proc.pid, title)
|
|
obs.place(self.target, 60, 60, 760, 560)
|
|
wait_for(self.state)
|
|
proc = self.spawn_target([sys.executable, str(HERE / "sentinel.py"),
|
|
str(self.sentinel_path), "880x660+20+20"], hidden=True)
|
|
self.sentinel_pid = proc.pid
|
|
self.sentinel = self.window(proc.pid, "BenchSentinel")
|
|
wait_for(lambda: read_json(self.sentinel_path))
|
|
# Tk's initial focus callback must finish before calibration or measurement.
|
|
time.sleep(0.2)
|
|
|
|
def front(self):
|
|
if WIN:
|
|
return obs.foreground() == self.sentinel and not obs.owned_above(self.sentinel, self.pid)
|
|
display = obs.main_display()
|
|
stacking = display.stacking()
|
|
windows = display.windows_of(self.pid)
|
|
return (display.active() == self.sentinel and display.focus() in display.descendants(self.sentinel)
|
|
and self.sentinel in stacking and self.target in stacking and windows
|
|
and all(stacking.index(window) < stacking.index(self.sentinel) for window in windows if window in stacking))
|
|
|
|
def prepare(self, task):
|
|
self.nonce = uuid.uuid4().hex
|
|
write_json(self.directory / "cmd", {"instance": self.instance, "nonce": self.nonce, "task": task})
|
|
wait_for(lambda: prepared(self.state() or {}, task, self.instance, self.pid, self.nonce), 5)
|
|
obs.force_foreground(self.sentinel)
|
|
wait_for(self.front, 5)
|
|
if WIN:
|
|
obs.u.SetCursorPos(2, 2)
|
|
else:
|
|
obs.xdotool("mousemove", 2, 2)
|
|
wait_for(lambda: prepared(self.state() or {}, task, self.instance, self.pid, self.nonce), 5)
|
|
|
|
def sample(self):
|
|
self.sampler = obs.Sampler(self.sentinel, self.target, self.pid)
|
|
self.sampler.start()
|
|
|
|
def stop_sampling(self):
|
|
if self.sampler:
|
|
sampler, self.sampler = self.sampler, None
|
|
result = sampler.stop()
|
|
if result["n_samples"] < 3:
|
|
raise RuntimeError("observer did not collect enough samples")
|
|
return result
|
|
return {}
|
|
|
|
def calibrate(self):
|
|
self.prepare("click_interference")
|
|
self.sample()
|
|
self.typer = obs.Typer()
|
|
self.typer.start()
|
|
time.sleep(0.65)
|
|
self.typer.stop()
|
|
sent = self.typer.sent
|
|
self.typer = None
|
|
if sent < 3:
|
|
raise RuntimeError("simulated user input control failed")
|
|
wait_for(lambda: (read_json(self.sentinel_path) or {}).get("q", 0) == sent, 2)
|
|
idle = self.stop_sampling()
|
|
if any(idle[key] for key in ("sentinel_lost_focus", "target_raised", "pointer_moved", "clipboard_changed")):
|
|
raise RuntimeError("idle observer control failed")
|
|
self.sample()
|
|
obs.force_foreground(self.target)
|
|
time.sleep(0.15)
|
|
steal = self.stop_sampling()
|
|
if not all(steal[key] for key in ("sentinel_lost_focus", "target_activated", "target_raised")):
|
|
raise RuntimeError("intentional focus-steal observer control failed")
|
|
return {"idle": idle, "steal": steal, "keys_sent": sent}
|
|
|
|
def engine(self, name, sources):
|
|
from providers import Cua, CuaEngine, OrcaEngine, OrcaLinux, OrcaWin, cua_sc
|
|
if name == "cua":
|
|
client = Cua(self.args.cua, str(self.directory / "cua-home"), self.log, self.engines)
|
|
response = client.call("list_windows", {"pid": self.pid})
|
|
if response.get("isError") or "_rpc_error" in response:
|
|
raise RuntimeError("cua list_windows failed: " + str(response))
|
|
windows = cua_sc(response).get("windows") or []
|
|
matches = [window for window in windows if window.get("window_id") is not None
|
|
and window.get("title") in ("BenchTarget", "BenchElectron")]
|
|
if len(matches) != 1:
|
|
raise DiscoveryError(f"cua list_windows matched {len(matches)} fixture windows")
|
|
return CuaEngine(client, self.pid, matches[0]["window_id"])
|
|
return OrcaEngine((OrcaWin if WIN else OrcaLinux)(str(sources / name), self.log, self.engines), self.pid)
|
|
|
|
def run(self, cell, sources):
|
|
row = {**cell, "prepared": False, "api_errors": [], "action_read_ms": [], "steps": []}
|
|
state = None
|
|
task_started = None
|
|
snapshots, responses = [], []
|
|
try:
|
|
self.launch()
|
|
self.prepare(cell["task"])
|
|
row["initial_state"] = self.state()
|
|
row["prepared"] = True
|
|
self.sample()
|
|
row["sentinel_before"] = read_json(self.sentinel_path)
|
|
if cell["task"].endswith("_interference"):
|
|
self.typer = obs.Typer()
|
|
self.typer.start()
|
|
task_started = time.perf_counter()
|
|
engine = self.engine(cell["engine"], sources)
|
|
acquire_started = time.perf_counter()
|
|
elements = engine.snapshot()
|
|
row["acquisition_ms"] = (time.perf_counter() - acquire_started) * 1000
|
|
snapshots.append(engine.last_snapshot)
|
|
for action, selector in TASKS[cell["task"]]:
|
|
element = select_unique(elements, SELECTORS[selector])
|
|
started = time.perf_counter()
|
|
result = engine.act(action, element, cell["profile"] == "foreground", MARKER)
|
|
row["steps"].append({"action": action, "result": result,
|
|
"action_ms": (time.perf_counter() - started) * 1000})
|
|
if result.get("error"):
|
|
row["api_errors"].append(result)
|
|
responses.append(engine.last_response)
|
|
# Identical extra full no-screenshot read for all providers; reuse fresh refs next step.
|
|
elements = engine.snapshot()
|
|
row["action_read_ms"].append((time.perf_counter() - started) * 1000)
|
|
snapshots.append(engine.last_snapshot)
|
|
if result.get("error"):
|
|
break
|
|
except DiscoveryError as error:
|
|
row["discovery_error"] = str(error)
|
|
except Exception as error:
|
|
row["api_errors"].append(repr(error))
|
|
finally:
|
|
if task_started is not None:
|
|
row["attempt_ms"] = (time.perf_counter() - task_started) * 1000
|
|
try:
|
|
# No late operation can mutate the fixture after the terminal oracle starts.
|
|
self.engines.close()
|
|
if self.typer:
|
|
self.typer.stop()
|
|
row["keys_sent"] = self.typer.sent
|
|
self.typer = None
|
|
if row["prepared"]:
|
|
if task_started is not None:
|
|
row["elapsed_to_quiescent_ms"] = (time.perf_counter() - task_started) * 1000
|
|
before = (self.state() or {}).get("seq", -1)
|
|
time.sleep(0.4)
|
|
def fresh_state():
|
|
value = self.state()
|
|
return value if value and value.get("seq", -1) > before and acknowledged(
|
|
value, self.instance, self.pid, self.nonce) else None
|
|
state = wait_for(fresh_state, 2)
|
|
if task_started is not None:
|
|
row["terminal_readback_ms"] = (time.perf_counter() - task_started) * 1000
|
|
row["sentinel_after"] = read_json(self.sentinel_path)
|
|
sentinel_before = row["sentinel_before"]
|
|
sentinel_after = row["sentinel_after"]
|
|
if not all(value and value.get("pid") == self.sentinel_pid
|
|
for value in (sentinel_before, sentinel_after)):
|
|
raise RuntimeError("missing independent sentinel counters")
|
|
if cell["task"].endswith("_interference") and row.get("keys_sent", 0) == 0:
|
|
row["input_observer_error"] = "no simulated user input was delivered"
|
|
row["keys_received"] = sentinel_after.get("q", 0) - sentinel_before.get("q", 0)
|
|
row["keys_lost"] = max(0, row.get("keys_sent", 0) - row["keys_received"])
|
|
row["agent_text_in_sentinel"] = sentinel_after.get("other", 0) - sentinel_before.get("other", 0)
|
|
row["target_q_characters"] = state["text"].count("q") + state["decoyText"].count("q")
|
|
except Exception as error:
|
|
row["terminal_observation_error"] = repr(error)
|
|
try:
|
|
row["observation"] = self.stop_sampling()
|
|
except Exception as error:
|
|
row["observer_error"] = repr(error)
|
|
row["final_state"] = state
|
|
row["verdict"] = verdict(row, state)
|
|
for index, snapshot in enumerate(snapshots):
|
|
write_json(self.directory / f"snapshot-{index}.json", snapshot)
|
|
for index, response in enumerate(responses):
|
|
write_json(self.directory / f"response-{index}.json", response)
|
|
return row
|
|
|
|
def close(self):
|
|
if self.typer:
|
|
self.typer.stop()
|
|
try:
|
|
self.engines.close()
|
|
finally:
|
|
try:
|
|
self.stop_sampling()
|
|
finally:
|
|
self.targets.close()
|
|
|
|
|
|
def markdown(meta, summary):
|
|
lines = [f"## {meta['os']} / {meta['target']}", "",
|
|
f"Run complete: {meta['complete']}. Cells: {meta['executed']}/{meta['planned']}. Seed: {meta['seed']}.", "",
|
|
"| task | profile | engine | completed/scored | invalid | unobserved | API errors | task ms | action + read ms | focus lost | raised |",
|
|
"|---|---|---|---|---|---|---|---|---|---|---|"]
|
|
for row in summary:
|
|
lines.append(f"| {row['task']} | {row['profile']} | {row['engine']} | {row['completed']}/{row['scored']} | "
|
|
f"{row['invalid']} | {row['unobserved']} | {row['api_errors']} | {row['task_ms']} | "
|
|
f"{row['action_read_ms']} | {row['sentinel_lost_focus']} | {row['target_raised']} |")
|
|
if meta.get("fatal"):
|
|
lines.extend(["", f"Incomplete: `{meta['fatal']}`"])
|
|
return "\n".join(lines) + "\n"
|
|
|
|
|
|
def main():
|
|
if (os.environ.get("ORCA_COMPUTER_BENCH_ISOLATED") != "1" or os.environ.get("GITHUB_ACTIONS") != "true"
|
|
or os.environ.get("ORCA_BACKGROUND_LAUNCH") != "1" or sys.platform not in ("linux", "win32")):
|
|
raise SystemExit("Native input benchmark requires the isolated Linux/Windows GitHub Actions desktop")
|
|
global obs
|
|
if WIN:
|
|
import obs_win as obs
|
|
else:
|
|
import obs_x11 as obs
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--target", choices=["fixture", "electron"], required=True)
|
|
parser.add_argument("--cua", required=True)
|
|
parser.add_argument("--electron")
|
|
parser.add_argument("--out", type=Path, required=True)
|
|
parser.add_argument("--baseline", default="030c88deecf3adc212d22f57b085e4bd5c6e48e6")
|
|
parser.add_argument("--candidate", required=True)
|
|
parser.add_argument("--n", type=int, default=3)
|
|
parser.add_argument("--seed", type=int, default=417)
|
|
parser.add_argument("--budget", type=int, default=1800)
|
|
args = parser.parse_args()
|
|
if args.n < 1 or (args.target == "electron" and not args.electron):
|
|
parser.error("positive --n and an Electron executable for Electron targets are required")
|
|
args.out = args.out.resolve()
|
|
args.out.mkdir(parents=True, exist_ok=False)
|
|
sources = args.out / "sources"
|
|
cells = []
|
|
for cell in schedule(["baseline", "candidate", "cua"], TASKS, args.n, args.seed):
|
|
profiles = ["background", "foreground"] if not cell["task"].endswith("_interference") else ["background"]
|
|
cells.extend({**cell, "profile": profile} for profile in profiles)
|
|
meta = {"os": platform.platform(), "target": args.target, "seed": args.seed, "n": args.n,
|
|
"python": sys.version, "image": os.environ.get("ImageVersion"), "planned": len(cells),
|
|
"complete": False, "executed": 0, "sources": {}}
|
|
rows = []
|
|
started = time.monotonic()
|
|
try:
|
|
meta["sources"] = {name: pin_source(sha, sources / name)
|
|
for name, sha in (("baseline", args.baseline), ("candidate", args.candidate))}
|
|
meta["cua_sha256"] = hashlib.sha256(Path(args.cua).read_bytes()).hexdigest()
|
|
meta["cua_version"] = subprocess.check_output([args.cua, "--version"], text=True, timeout=15).strip()
|
|
if "0.38.0" not in meta["cua_version"]:
|
|
raise RuntimeError("expected pinned cua-driver 0.38.0")
|
|
with (args.out / "engines.log").open("ab") as log:
|
|
control = Trial(args, args.out / "controls", log)
|
|
try:
|
|
control.launch()
|
|
meta["calibration"] = control.calibrate()
|
|
finally:
|
|
control.close()
|
|
for index, cell in enumerate(cells):
|
|
if time.monotonic() - started >= args.budget:
|
|
raise TimeoutError("budget exhausted; planned cells remain")
|
|
trial = Trial(args, args.out / f"trial-{index:03d}", log)
|
|
try:
|
|
row = trial.run(cell, sources)
|
|
rows.append(row)
|
|
finally:
|
|
trial.close()
|
|
meta["executed"] = len(rows)
|
|
write_json(args.out / "results.json", {"meta": meta, "rows": rows})
|
|
print(f"{index + 1}/{len(cells)} {cell}: {row['verdict']}", flush=True)
|
|
meta["complete"] = (len(rows) == len(cells) and all(row["prepared"] and not row.get("observer_error")
|
|
and not row.get("terminal_observation_error") and not row.get("input_observer_error") and row["verdict"] != "unobserved" for row in rows))
|
|
except Exception as error:
|
|
import traceback
|
|
traceback.print_exc()
|
|
meta["fatal"] = repr(error)
|
|
finally:
|
|
meta["executed"] = len(rows)
|
|
meta["elapsed_seconds"] = time.monotonic() - started
|
|
summary = summarize(rows)
|
|
write_json(args.out / "results.json", {"meta": meta, "summary": summary, "rows": rows, "plan": cells})
|
|
report = markdown(meta, summary)
|
|
(args.out / "summary.md").write_text(report, encoding="utf-8")
|
|
print(report)
|
|
if os.environ.get("GITHUB_STEP_SUMMARY"):
|
|
with open(os.environ["GITHUB_STEP_SUMMARY"], "a", encoding="utf-8") as stream:
|
|
stream.write(report)
|
|
return 0 if meta["complete"] else 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|