Spaces:
Running
Running
Download src/miniwebagent/om2w.py from sparklabuofu/MiniWeb-demo: direct link, hf CLI and curl.
- Browser
- Download file 20.9 kB
-
https://huggingface.co/spaces/sparklabuofu/MiniWeb-demo/resolve/main/src/miniwebagent/om2w.py
- Command line
-
hf download hf://spaces/sparklabuofu/MiniWeb-demo/src/miniwebagent/om2w.py
-
curl -L -o om2w.py https://huggingface.co/spaces/sparklabuofu/MiniWeb-demo/resolve/main/src/miniwebagent/om2w.py
20.9 kB
| """Online-Mind2Web (300 tasks on 136 live websites; osunlp/Online-Mind2Web) and WebVoyager (643 tasks on 15 live | |
| sites; --bench webvoyager) through the WebMix browser-use harness. | |
| python -m miniwebagent.om2w --arm base --model qwen35-4b --out data/webmix/om2w/runs/base --workers 12 | |
| python -m miniwebagent.om2w --arm agent --agent-version v6 --out data/webmix/om2w/runs/agent_v4spec --workers 12 | |
| Each task starts on its specified website (the benchmark's rule), in a browser with the live web reachable | |
| (harness.make_session(online=True); MiniWeb and WebArena stay offline). An episode is saved in the benchmark's v1 | |
| trajectory layout -- <out>/<task_id>/result.json (task, factual action_history without agent text, thoughts, | |
| final_result_response) and trajectory/<i>_full_screenshot.png -- for the official WebJudge (o4-mini), run locally | |
| afterwards (no API keys on the cluster). Arms: `base`, the untrained model as one agent; `agent`, the planner agent | |
| (miniwebagent.planner_agent) delegating to the family specialists served under drag/io/select/rest/reasoning on --vllm. | |
| """ | |
| from __future__ import annotations | |
| from helpers.paths import REPO_ROOT | |
| import argparse | |
| import asyncio | |
| import json | |
| import shutil | |
| import subprocess | |
| import sys | |
| import time | |
| from pathlib import Path | |
| from types import SimpleNamespace | |
| from miniwebagent import harness as H | |
| ROOT = REPO_ROOT | |
| TASKS = ROOT / "data" / "webmix" / "om2w" / "Online_Mind2Web.json" | |
| NOT_ACTIONS = {"done", "macro_done"} # the answer goes to final_result_response, not the action history | |
| WEBVOYAGER = ROOT / "data" / "webmix" / "webvoyager" / "WebVoyager_data_2026-10-01.jsonl" # dates updated, see README | |
| def load_tasks(path=None, bench="om2w"): | |
| """Online-Mind2Web's tasks, or WebVoyager's (643 tasks on 15 live sites, MinorJerry/WebVoyager) in the same shape: | |
| task_id, confirmed_task, website, level (WebVoyager: the site name).""" | |
| if bench == "webvoyager": | |
| rows = [json.loads(line) for line in Path(path or WEBVOYAGER).read_text().split("\n") if line.strip()] | |
| return [{"task_id": r["id"], "confirmed_task": r["ques"], "website": r["web"], "level": r["web_name"]} | |
| for r in rows] | |
| return json.loads(Path(path or TASKS).read_text()) | |
| def _element(el): | |
| """'<tag attr="...">' for an interacted element (the benchmark's action-history style).""" | |
| if el is None: | |
| return "" | |
| tag = getattr(el, "node_name", None) or "element" | |
| attrs = getattr(el, "attributes", None) or {} | |
| keep = {k: v for k, v in attrs.items() if k in ("id", "aria-label", "name", "placeholder", "title", "href", "role", | |
| "type", "value", "alt") and v} | |
| name = getattr(el, "ax_name", None) | |
| text = " ".join(f'{k}="{str(v)[:80]}"' for k, v in keep.items()) | |
| return f"<{str(tag).lower()}{' ' + text if text else ''}>{' ' + str(name)[:80] if name else ''}" | |
| def _action_line(name, params, el): | |
| params = params or {} | |
| if name in ("click", "dropdown_options"): | |
| if el is None: | |
| x, y = params.get("coordinate_x"), params.get("coordinate_y") | |
| if x or y: # a coordinate click (0-1000 screen scale): no element | |
| return f"CLICK at ({x}, {y}) of 1000" | |
| return f"<element index={params.get('index')}> -> CLICK" # no element matched the index | |
| return f"{_element(el)} -> CLICK" | |
| if name == "input": | |
| return f"{_element(el)} -> TYPE: {params.get('text', '')}" | |
| if name == "select_dropdown": | |
| return f"{_element(el)} -> SELECT: {params.get('text', '')}" | |
| if name == "navigate": | |
| return f"NAVIGATE -> {params.get('url', '')}" | |
| if name == "scroll": | |
| return f"SCROLL {'DOWN' if params.get('down', True) else 'UP'}" | |
| if name == "send_keys": | |
| return f"KEYS: {params.get('keys', '')}" | |
| if name == "go_back": | |
| return "GO BACK" | |
| if name in ("drag", "draw"): | |
| return f"{name.upper()} {json.dumps(params)[:120]}" | |
| return f"{name.upper()} {json.dumps(params)[:120]}" | |
| PLANNER_SKIP = {"delegate"} # the specialists' own actions are recorded instead | |
| def _ordered_steps(hists, planner_hist=None): | |
| """The histories' steps in order. With the planner agent's own history (v7, 2026-10-02) its steps are merged in by | |
| start time: each planner turn's screenshot is what it looked at before delegating, and its scrolls are how it read | |
| an answer, so the judge sees the page the answer came from (before v7: specialist screenshots only).""" | |
| if planner_hist is None: | |
| return [st for h in hists for st in h.history] | |
| steps = [(st, False) for h in hists for st in h.history] + [(st, True) for st in planner_hist.history] | |
| key = lambda x: (getattr(getattr(x[0], "metadata", None), "step_start_time", None) or 0.0) # noqa: E731 | |
| return [(st, True) if is_p else st for st, is_p in sorted(steps, key=key)] | |
| def trajectory(hists, planner_hist=None): | |
| """-> (action lines, thoughts, screenshot paths) over one or more browser-use histories, in order (with the | |
| planner's own steps merged in when planner_hist is given).""" | |
| actions, thoughts, shots = [], [], [] | |
| for item in _ordered_steps(hists, planner_hist): | |
| st, planner = item if isinstance(item, tuple) else (item, False) | |
| state = getattr(st, "state", None) | |
| sp = getattr(state, "screenshot_path", None) | |
| if sp and Path(sp).exists(): | |
| shots.append(sp) | |
| mo = st.model_output | |
| if mo is None: | |
| continue | |
| els = list(getattr(state, "interacted_element", None) or []) | |
| for k, a in enumerate(mo.action or []): | |
| d = a.model_dump(exclude_none=True) | |
| name = next(iter(d), "") | |
| if name in NOT_ACTIONS or (planner and name in PLANNER_SKIP): | |
| continue | |
| actions.append(_action_line(name, d.get(name), els[k] if k < len(els) else None)) | |
| thoughts.append(str(getattr(mo, "next_goal", None) or getattr(mo, "memory", None) or "")[:400]) | |
| return actions, thoughts, shots | |
| def pick_model(args): | |
| from miniwebagent.evaluate import sub_model | |
| margs = SimpleNamespace(adapters=set(args.adapters.split(",")) - {"none", ""}, fallback=args.fallback, | |
| base_name=args.model, reasoning_to_fallback=False, alias="") | |
| return lambda fam: sub_model(fam, margs) | |
| async def run_task(task, args, out_dir): | |
| from browser_use import Agent | |
| t0 = time.time() | |
| tid = task["task_id"] | |
| ep = out_dir / tid | |
| (ep / "trajectory").mkdir(parents=True, exist_ok=True) | |
| res = {"task_id": tid, "arm": args.arm, "website": task["website"], "level": task.get("level")} | |
| bs = H.make_session(online=True) | |
| hists, answer, planner_hist = [], None, None | |
| sink = [] # --record-rows: every model call of the base agent | |
| box, agent = {}, None # what ran so far, kept when the safety timeout cuts an episode | |
| try: | |
| await bs.start() | |
| await H.enable_clipboard(bs) | |
| await H.goto(bs, task["website"]) | |
| if args.arm == "agent": | |
| from miniwebagent.planner_agent import run_planner_agent | |
| hist, trace, ph = await asyncio.wait_for( | |
| run_planner_agent(bs, task["confirmed_task"], task["website"], pick_model(args), args.vllm, | |
| args.planner_name, temperature=args.temperature, version=args.agent_version, | |
| expose=box, budget=args.max_steps, turns=args.planner_turns), | |
| timeout=args.timeout) | |
| hists, answer = hist.hists, hist.final_result() | |
| planner_hist = ph | |
| ph.save_to_file(ep / "planner_history.json") | |
| (ep / "subtasks.json").write_text(json.dumps({"trace": trace}, indent=1, default=str, ensure_ascii=False)) | |
| else: | |
| # --hint-field (2026-10-07): a privileged hint appended to the task while acting, and | |
| # stripped from every recorded prompt (--record-rows), as MiniWebGen keeps privileged inputs out of rows | |
| hint = task.get(args.hint_field) if args.hint_field else None | |
| text = task["confirmed_task"] + (f"\n\n{hint}" if hint else "") | |
| llm = (recording_llm(args.model, args.vllm, args.temperature, sink) if args.record_rows else | |
| H.make_llm(args.model, base_url=args.vllm, temperature=args.temperature)) | |
| agent = Agent(task=H.instruction(text, task["website"]), llm=llm, | |
| browser_session=bs, tools=H.make_tools(), max_failures=3, **H.AGENT_KWARGS) | |
| H.use_normalized_coordinates(bs) | |
| h = await asyncio.wait_for(agent.run(max_steps=task.get("max_steps") or args.max_steps, | |
| on_step_start=lambda a: H.enable_clipboard(bs, a)), | |
| timeout=args.timeout) | |
| hists, answer = [h], h.final_result() | |
| try: # the final state, after the last action | |
| final_png = await bs.take_screenshot() | |
| except Exception: | |
| final_png = None | |
| except asyncio.TimeoutError: | |
| # 2026-10-01: a timed-out episode used to lose its whole trajectory (0 steps saved), so the judge saw nothing; | |
| # keep the steps taken (the planner agent's finished and running specialists, or the base agent's history) | |
| res["error"], final_png = "timeout", None | |
| team = box.get("team") | |
| if team is not None: | |
| hists = list(team.hists) + ([team.current.history] if team.current is not None else []) | |
| if box.get("planner") is not None: | |
| planner_hist = box["planner"].history | |
| elif agent is not None: | |
| hists = [agent.history] | |
| try: | |
| final_png = await bs.take_screenshot() | |
| except Exception: | |
| final_png = None | |
| except Exception as e: | |
| res["error"], final_png = f"{type(e).__name__}: {str(e)[:300]}", None | |
| finally: | |
| try: | |
| await bs.kill() | |
| except Exception: | |
| pass | |
| from miniwebagent.planner_agent import VERSIONS as _PV | |
| record_planner = args.arm == "agent" and _PV.get(args.agent_version, {}).get("v7") | |
| actions, thoughts, shots = trajectory(hists, planner_hist if record_planner else None) | |
| for i, sp in enumerate(shots): | |
| shutil.copy(sp, ep / "trajectory" / f"{i}_full_screenshot.png") | |
| if final_png: | |
| (ep / "trajectory" / f"{len(shots)}_full_screenshot.png").write_bytes(final_png) | |
| (ep / "result.json").write_text(json.dumps({ | |
| "task_id": tid, "task": task["confirmed_task"], "final_result_response": answer or "", | |
| "action_history": actions, "thoughts": thoughts}, indent=1, ensure_ascii=False)) | |
| for i, h in enumerate(hists): | |
| try: | |
| h.save_to_file(ep / f"history_{i}.json") | |
| except Exception: | |
| pass | |
| res.update(steps=sum(len(h.history) for h in hists), actions=len(actions), screenshots=len(shots) + bool(final_png), | |
| answer=(answer or "")[:500], duration_s=round(time.time() - t0, 1)) | |
| if args.record_rows and sink: | |
| hint = task.get(args.hint_field) if args.hint_field else None | |
| res["rows"], res["hint_left"] = write_rows(ep / "rows", task, sink, hint) | |
| return res | |
| # ββ --record-rows ββββββββββββββββββββββββββββββββββββββββ | |
| ROW_FAMILY = {"input": "Text entry", "send_keys": "Text entry", "select_dropdown": "Discrete selection", | |
| "done": "Reasoning base"} # the rest (click, navigate, scroll, go_back, tabs ...): Navigation | |
| def recording_llm(model, base_url, temperature, sink): | |
| """The eval's model client, recording each call's messages, response format and reply into `sink`.""" | |
| llm = H.make_llm(model, base_url=base_url, temperature=temperature) | |
| real = llm.get_client() | |
| async def create(model=None, messages=None, response_format=None, **params): | |
| r = await real.chat.completions.create(model=model, messages=messages, response_format=response_format, | |
| **params) | |
| sink.append({"messages": messages, "response_format": response_format, | |
| "content": r.choices[0].message.content}) | |
| return r | |
| client = SimpleNamespace(chat=SimpleNamespace(completions=SimpleNamespace(create=create))) | |
| llm.get_client = lambda: client | |
| return llm | |
| def _strip(messages, full, short): | |
| """Messages with the task-plus-hint text replaced by the task alone.""" | |
| import copy | |
| out = copy.deepcopy(messages) | |
| for m in out: | |
| c = m.get("content") | |
| if isinstance(c, str): | |
| m["content"] = c.replace(full, short) | |
| elif isinstance(c, list): | |
| for part in c: | |
| if isinstance(part, dict) and isinstance(part.get("text"), str): | |
| part["text"] = part["text"].replace(full, short) | |
| return out | |
| def write_rows(rows_dir, task, sink, hint): | |
| """<ep>/rows/{rows.jsonl, step_*.png, episode.json} in miniwebagent.train's format, the hint stripped from every prompt. | |
| -> (rows written, whether any hint text is left in a prompt).""" | |
| from miniwebgen import replay as R | |
| rows_dir.mkdir(parents=True, exist_ok=True) | |
| short = task["confirmed_task"] | |
| full = short + (f"\n\n{hint}" if hint else "") | |
| rows, left = [], False | |
| for i, rec in enumerate(x for x in sink if x["response_format"]): | |
| msgs = _strip(rec["messages"], full, short) | |
| if hint and hint[:60] in json.dumps(msgs, ensure_ascii=False): | |
| left = True | |
| try: | |
| act = json.loads(rec["content"]).get("action") or [{}] | |
| name = next(iter(act[0])) if act and act[0] else "" | |
| except (ValueError, AttributeError, TypeError): | |
| name = "" | |
| rows.append({"task_id": task["task_id"], "step": i, "segment": 0, "macro": None, "op": name, | |
| "family": ROW_FAMILY.get(name, "Navigation"), "messages": R.save_images(msgs, rows_dir, i), | |
| "target": rec["content"]}) | |
| (rows_dir / "rows.jsonl").write_text("".join(json.dumps(r, ensure_ascii=False) + "\n" for r in rows)) | |
| (rows_dir / "episode.json").write_text(json.dumps( | |
| {"task_id": task["task_id"], "instruction": short, "start_url": task["website"], "steps": len(rows), | |
| "source": task.get("source", ""), "hint_left": left}, indent=1, ensure_ascii=False)) | |
| return len(rows), left | |
| def _domain(url): | |
| import re | |
| return re.sub(r"^https?://(www\.)?", "", url).split("/")[0].lower() | |
| class Pacer: | |
| """Polite pacing (2026-10-01, user: "pace OM2W"): at most one episode per website at a time across every run on | |
| this node (an flock per domain in a node-local dir), and `cooldown` seconds between the end of one episode on a site | |
| and the start of the next. Bot checks rose with request volume from one IP; every arm is paced the same way.""" | |
| def __init__(self, lock_dir, cooldown): | |
| self.dir, self.cooldown = Path(lock_dir), cooldown | |
| self.dir.mkdir(parents=True, exist_ok=True) | |
| self.held = None | |
| def try_acquire(self, dom): | |
| import fcntl | |
| f = open(self.dir / f"{dom}.lock", "a+") | |
| try: | |
| fcntl.flock(f, fcntl.LOCK_EX | fcntl.LOCK_NB) | |
| except OSError: | |
| f.close() | |
| return False | |
| stamp = self.dir / f"{dom}.last" | |
| if stamp.exists() and time.time() - stamp.stat().st_mtime < self.cooldown: | |
| fcntl.flock(f, fcntl.LOCK_UN) | |
| f.close() | |
| return False | |
| self.held = f | |
| return True | |
| def release(self, dom): | |
| import fcntl | |
| (self.dir / f"{dom}.last").touch() | |
| if self.held is not None: | |
| fcntl.flock(self.held, fcntl.LOCK_UN) | |
| self.held.close() | |
| self.held = None | |
| def worker(args): | |
| out_dir = Path(args.out) | |
| tasks = load_tasks(args.tasks, args.bench) | |
| if args.task_ids: | |
| tasks = [t for t in tasks if t["task_id"] in args.task_ids] | |
| tasks = [t for k, t in enumerate(tasks) if k % args.n_workers == args.slot] | |
| if args.limit: | |
| tasks = tasks[:args.limit] | |
| res_path = out_dir / f"results_{args.slot}.jsonl" | |
| # every slot file, not just this slot's: the worker count may change between launches and a run may be split | |
| # across nodes (--slot-range), so a finished episode is never re-run (2026-10-01) | |
| done = {json.loads(line)["task_id"] for f in out_dir.glob("results_*.jsonl") | |
| for line in f.read_text().split("\n") if line.strip()} | |
| pending = [t for t in tasks if t["task_id"] not in done] | |
| pacer = Pacer(args.pace_dir, args.pace_cooldown) if args.pace_dir else None | |
| while pending: | |
| if pacer: # the first task whose site is free and cooled down | |
| t = next((x for x in pending if pacer.try_acquire(_domain(x["website"]))), None) | |
| if t is None: | |
| time.sleep(15) | |
| continue | |
| else: | |
| t = pending[0] | |
| pending.remove(t) | |
| try: | |
| res = asyncio.run(run_task(t, args, out_dir)) | |
| finally: | |
| if pacer: | |
| pacer.release(_domain(t["website"])) | |
| with open(res_path, "a") as f: | |
| f.write(json.dumps(res, default=str) + "\n") | |
| print(f"[w{args.slot}] {t['task_id']} {t.get('level')} {res.get('steps')} steps {res['duration_s']}s " | |
| f"{res.get('error') or ''}", flush=True) | |
| def main(argv=None): | |
| ap = argparse.ArgumentParser(prog="python -m miniwebagent.om2w") | |
| ap.add_argument("--arm", required=True, choices=["base", "agent"]) | |
| ap.add_argument("--model", default="qwen35-4b", help="the base model's served name") | |
| ap.add_argument("--vllm", default="http://127.0.0.1:8400/v1") | |
| ap.add_argument("--temperature", type=float, default=0.0) | |
| ap.add_argument("--timeout", type=int, default=1800) | |
| ap.add_argument("--agent-version", default="v6") | |
| ap.add_argument("--planner-name", default="qwen35-4b") | |
| ap.add_argument("--adapters", default="drag,io,select,rest,reasoning") | |
| ap.add_argument("--fallback", default="pooled_v4") | |
| ap.add_argument("--max-steps", type=int, default=H.MAX_STEPS, | |
| help="base: agent steps; agent: the specialists' site-action budget") | |
| ap.add_argument("--planner-turns", type=int, default=None, help="agent: planner-turn cap (default: the version's)") | |
| ap.add_argument("--pace-dir", default=None, help="node-local dir of per-site locks shared by every run (pacing)") | |
| ap.add_argument("--pace-cooldown", type=int, default=120, help="seconds between episodes on one site") | |
| ap.add_argument("--bench", default="om2w", choices=["om2w", "webvoyager"]) | |
| ap.add_argument("--tasks", default=None, help="task file (default: the benchmark's)") | |
| ap.add_argument("--task-ids", type=lambda s: set(s.split(",")), default=None) | |
| ap.add_argument("--limit", type=int, default=0) | |
| ap.add_argument("--workers", type=int, default=8) | |
| ap.add_argument("--out", required=True) | |
| ap.add_argument("--slot-range", default=None, help="a:b = run only slots a..b-1 of --workers here (split a run " | |
| "across nodes; the other node runs the rest)") | |
| ap.add_argument("--slot", type=int, default=None) # internal: one worker | |
| ap.add_argument("--n-workers", type=int, default=1) | |
| ap.add_argument("--hint-field", default=None, help="base arm: a task field appended to the task as a privileged hint") | |
| ap.add_argument("--record-rows", action="store_true", | |
| help="base arm: record every step as a training row (the hint stripped), in <episode>/rows") | |
| args = ap.parse_args(argv) | |
| if args.slot is not None: | |
| return worker(args) | |
| Path(args.out).mkdir(parents=True, exist_ok=True) | |
| base = [sys.executable, "-m", "miniwebagent.om2w"] + [a for a in (argv or sys.argv[1:])] | |
| procs = [] | |
| lo, hi = (int(x) for x in args.slot_range.split(":")) if args.slot_range else (0, args.workers) | |
| for slot in range(lo, hi): | |
| log = open(Path(args.out) / f"worker_{slot}.log", "a") | |
| procs.append(subprocess.Popen(base + ["--slot", str(slot), "--n-workers", str(args.workers)], stdout=log, | |
| stderr=subprocess.STDOUT, cwd=str(ROOT))) | |
| for p in procs: | |
| p.wait() | |
| n = sum(len([x for x in f.read_text().split("\n") if x.strip()]) for f in Path(args.out).glob("results_*.jsonl")) | |
| print(f"{n} episodes in {args.out}") | |
| if __name__ == "__main__": | |
| main() | |