Spaces:
Running
Running
File size: 8,775 Bytes
22de727 | 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 | """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
|