sparklabuofu's picture
Update the demo
22de727 verified
Raw History Blame Contribute Delete
8.78 kB
"""BrowserGym task wrapper for MiniWeb — PoC (browser-gym branch).
Bridges MiniWeb onto BrowserGym's AbstractBrowserTask:
setup(page) -> reset session, navigate to start URL, return goal
validate(page, chat) -> fetch THIS session's trajectory, run verify_task
teardown() -> noop (server is shared)
The grade reuses MiniWeb's existing verifier verbatim; the only new bit is fetching
the trajectory session-scoped THROUGH the browser (page.request carries the
_data_overlay_sid cookie), so many BrowserGym envs can share one MiniWeb server.
"""
from helpers.paths import REPO_ROOT
from helpers.paths import SITES_DIR
import json
import os
import pathlib
from browsergym.core.task import AbstractBrowserTask
from miniweb.evaluation.verifiers import verify_task
from miniweb.evaluation.trajectory import merge_server_log
ROOT = REPO_ROOT
ANN = ROOT / "data" / "annotations"
def _start_url(base, tdir, task):
"""Where the agent starts, mapped onto the local server.
Multi-site tasks start on the PORTAL directory (`/`): crossing between sites
is part of the task, so neither site's deep page is a fair start. Single-site
tasks start where the reference recording began — the first OBSERVATION url
(the page the human was actually on), then the first ACTION url. NEVER a
network/XHR event — those are API calls (e.g. /api/prices/live?symbols=SPY)
that render as raw JSON/CSV, not the page.
"""
if len(task.get("sites") or []) > 1:
return base + "/"
tj = tdir / "trajectory.json"
if tj.exists():
try:
traj = json.loads(tj.read_text())
for kind in ("observation", "action"):
for e in traj:
u = e.get("url")
if e.get("type") == kind and u and "/sites/" in u:
return base + u[u.find("/sites/"):]
except (json.JSONDecodeError, OSError):
pass
sites = task.get("sites") or []
sid = (sites[0].get("id") if isinstance(sites[0], dict) else sites[0]) if sites else ""
return f"{base}/sites/{sid}/"
def _assemble(recorded, log, beacons):
"""Same assembly as run_agent_verify.build_trajectory, but from session-scoped feeds.
Network events come from the server request log (union with any client-recorded
ones) — queries intact, nothing synthesized from actions."""
traj = [e for e in recorded if e.get("type") != "network"]
if not any(e.get("type") == "action" for e in traj):
for b in beacons:
traj.append({"type": "action", **b})
return merge_server_log(traj, log)
def fetch_session_trajectory(page, base):
"""Pull THIS browser session's trajectory (the cookie selects the session)."""
def entries(path):
try:
return page.request.get(base + path).json().get("entries", []) or []
except Exception:
return []
return _assemble(entries("/_admin/record"), entries("/_admin/log"),
entries("/_admin/beacon"))
def _site_brand(sid):
"""The site's brand/display name from site.json (falls back to the id)."""
try:
return json.loads((SITES_DIR / sid / "site.json").read_text()).get("name", sid)
except Exception:
return sid
def _nav_preamble(task):
"""Natural, assistant-style orientation (kept free of evaluation cues so the
agent behaves as it would for a real user): describes the browser home page and
how to move between the user's sites, and how to wrap up. No goto tool — the
agent uses the page's own controls (search bar, site shortcuts, tab bar)."""
names = [_site_brand(s.get("id") if isinstance(s, dict) else s)
for s in (task.get("sites") or []) if (s.get("id") if isinstance(s, dict) else s)]
sites_block = (" You may need: " + ", ".join(names) + ".\n\n") if names else " "
return ("You are a helpful assistant operating the user's web browser. You are on "
"the user's home page, which lists their websites — open and move between "
"them using the page's search box, the site shortcuts, and the tab bar." +
sites_block +
"When you have done what the user asked, call finish_task() to wrap up. If "
"they asked a question, reply with report_answer(<answer>) instead. Use "
"send_msg_to_user only for brief progress updates.\n\n"
"The user says: ")
from miniweb.browsergym.actions import FINAL_ANSWER_PREFIX, TASK_DONE_PREFIX
def _agent_texts(chat_messages):
"""The messages the AGENT produced this episode. BrowserGym seeds a stock
assistant greeting ("Hi! I am your UI assistant...") BEFORE the goal, so we take
only assistant messages that come AFTER the goal (the first user-role message)."""
msgs = list(chat_messages or [])
start = 0
for i, m in enumerate(msgs):
if isinstance(m, dict) and m.get("role") == "user":
start = i + 1
break
out = []
for m in msgs[start:]:
if isinstance(m, dict):
if m.get("role") in (None, "assistant", "infeasible") and m.get("message"):
out.append(str(m["message"]))
elif isinstance(m, str) and m:
out.append(m)
return out
def _final_answer(chat_messages):
"""The agent's explicitly-reported final answer, via report_answer (last marked
message, marker stripped). '' if the agent never reported one."""
for t in reversed(_agent_texts(chat_messages)):
if FINAL_ANSWER_PREFIX in t:
return t.split(FINAL_ANSWER_PREFIX, 1)[1].strip()
return ""
def _task_finished(chat_messages):
"""True once the agent has declared it is done — via finish_task (no answer) or
report_answer (with an answer). Both end the task; grading runs at that point."""
return any(FINAL_ANSWER_PREFIX in t or TASK_DONE_PREFIX in t
for t in _agent_texts(chat_messages))
def _collect_answer(chat_messages):
"""Fallback for agents that don't report an explicit answer: everything the agent
said to the human, joined (so a multi-part answer split across messages is whole)."""
return "\n".join(_agent_texts(chat_messages)).strip()
class MiniWebTask(AbstractBrowserTask):
def __init__(self, seed: int = 0, task_id: str = None, base_url: str = None):
super().__init__(seed)
self.task_id = task_id
self.base = (base_url or os.environ.get("MINIWEB_URL", "http://localhost:8099")).rstrip("/")
self.tdir = ANN / task_id
self.task = json.loads((self.tdir / "task.json").read_text())
self.verifier = json.loads((self.tdir / "verifier.json").read_text())
self.expected = (self.task.get("expected_answer") or "").strip()
def setup(self, page):
# session-scoped reset (safe on a shared server), then land on the start page.
# authenticate_by_form tasks must start LOGGED OUT: ?no_autologin=1 sets the
# session flag the auto-login guard honours (per-session equivalent of the
# native runner's MINIWEB_NO_AUTOLOGIN server env).
reset = f"{self.base}/_reset_data"
if "authenticate_by_form" in (self.verifier.get("macros") or {}):
reset += "?no_autologin=1"
try:
page.goto(reset)
except Exception:
pass
page.goto(_start_url(self.base, self.tdir, self.task))
goal = _nav_preamble(self.task) + self.task["instruction"]
return goal, {"task_id": self.task_id}
def validate(self, page, chat_messages):
# The agent's answer: its explicit report_answer/finish_task value if given,
# else its messages to the human (BrowserGym's seed greeting is excluded).
answer = _final_answer(chat_messages) or _collect_answer(chat_messages)
traj = fetch_session_trajectory(page, self.base)
report = verify_task(self.verifier, traj, answer,
question=self.task.get("instruction", ""))
passed = report["passed"]
# The agent ends the task by calling finish_task (or report_answer); grading
# runs at that point, pass or fail. A solved task also ends immediately.
# Otherwise the agent keeps going until the step limit (BrowserGym truncates).
finished = _task_finished(chat_messages)
done = passed or finished
reward = 1.0 if passed else 0.0
return reward, done, "", {"task_id": self.task_id,
"by_macro": report["by_macro"],
"ended_by": "verifier" if passed else
("agent" if finished else "")}
def teardown(self):
pass