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