Skip to content

Commit bbd19f5

Browse files
burtenshawTest Usercursoragent
authored
Fix BrowserGym server reset (#840)
* fix: harden browsergym server * fix: keep BrowserGym task switches atomic Ensure failed BrowserGym task recreation leaves the previous live environment and task state intact so callers can retry safely. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Test User <test@example.com> Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 7fe8a86 commit bbd19f5

4 files changed

Lines changed: 271 additions & 27 deletions

File tree

envs/browsergym_env/server/app.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@
1414
viewport_width = int(os.environ.get("BROWSERGYM_VIEWPORT_WIDTH", "1280"))
1515
viewport_height = int(os.environ.get("BROWSERGYM_VIEWPORT_HEIGHT", "720"))
1616
timeout = float(os.environ.get("BROWSERGYM_TIMEOUT", "10000"))
17+
include_screenshot = (
18+
os.environ.get("BROWSERGYM_INCLUDE_SCREENSHOT", "false").lower() == "true"
19+
)
1720
port = int(os.environ.get("BROWSERGYM_PORT", "8000"))
1821

1922
max_concurrent = int(os.environ.get("MAX_CONCURRENT_ENVS", "8"))
@@ -27,6 +30,7 @@
2730
viewport_width=viewport_width,
2831
viewport_height=viewport_height,
2932
timeout=timeout,
33+
include_screenshot=include_screenshot,
3034
),
3135
BrowserGymAction,
3236
BrowserGymObservation,

envs/browsergym_env/server/browsergym_environment.py

Lines changed: 79 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,27 @@
2424
logger = logging.getLogger(__name__)
2525

2626

27+
def _to_jsonable(value: Any) -> Any:
28+
"""Convert BrowserGym payload values into JSON-safe Python objects."""
29+
if value is None or isinstance(value, (str, int, float, bool)):
30+
return value
31+
if isinstance(value, dict):
32+
return {str(key): _to_jsonable(item) for key, item in value.items()}
33+
if isinstance(value, (list, tuple)):
34+
return [_to_jsonable(item) for item in value]
35+
if hasattr(value, "tolist"):
36+
try:
37+
return _to_jsonable(value.tolist())
38+
except Exception: # noqa: BLE001 - third-party array-likes
39+
pass
40+
if hasattr(value, "item"):
41+
try:
42+
return _to_jsonable(value.item())
43+
except Exception: # noqa: BLE001 - third-party scalar-likes
44+
pass
45+
return str(value)
46+
47+
2748
def _get_axtree_txt(obs: Dict[str, Any]) -> str:
2849
"""Extract accessibility tree text from BrowserGym observation.
2950
@@ -106,6 +127,7 @@ def __init__(
106127
viewport_width: int = 1280,
107128
viewport_height: int = 720,
108129
timeout: float = 10000.0,
130+
include_screenshot: bool = False,
109131
**gym_kwargs: Any,
110132
):
111133
"""Initialize the BrowserGym environment.
@@ -128,14 +150,9 @@ def __init__(
128150
self.viewport_width = viewport_width
129151
self.viewport_height = viewport_height
130152
self.timeout = timeout
153+
self.include_screenshot = include_screenshot
131154
self.gym_kwargs = dict(gym_kwargs)
132155

133-
# Build environment ID
134-
if task_name:
135-
self.env_id = f"browsergym/{benchmark}.{task_name}"
136-
else:
137-
self.env_id = f"browsergym/{benchmark}"
138-
139156
# force import the benchmark module
140157
benchmark_modules = {
141158
"miniwob": "browsergym.miniwob",
@@ -158,23 +175,8 @@ def __init__(
158175
)
159176
raise ValueError(message) from import_error
160177

161-
# Create the BrowserGym environment
162-
try:
163-
self.gym_env = gym.make(
164-
self.env_id,
165-
headless=headless,
166-
viewport={"width": viewport_width, "height": viewport_height},
167-
timeout=timeout,
168-
**self.gym_kwargs,
169-
)
170-
except Exception as e: # noqa: BLE001 - gym.make
171-
message = (
172-
"Failed to create BrowserGym environment "
173-
f"'{self.env_id}': {e}\n"
174-
"Make sure the benchmark package is installed "
175-
f"(e.g., pip install browsergym-{benchmark})."
176-
)
177-
raise ValueError(message) from e
178+
self.env_id = self._build_env_id(task_name)
179+
self.gym_env = self._create_gym_env(self.env_id)
178180

179181
# State tracking
180182
self._state = BrowserGymState(
@@ -187,6 +189,51 @@ def __init__(
187189
self._last_obs: Optional[Dict[str, Any]] = None
188190
self._last_info: Optional[Dict[str, Any]] = None
189191

192+
def _build_env_id(self, task_name: Optional[str]) -> str:
193+
if task_name:
194+
return f"browsergym/{self.benchmark}.{task_name}"
195+
return f"browsergym/{self.benchmark}"
196+
197+
def _create_gym_env(self, env_id: str):
198+
try:
199+
return gym.make(
200+
env_id,
201+
headless=self.headless,
202+
viewport={
203+
"width": self.viewport_width,
204+
"height": self.viewport_height,
205+
},
206+
timeout=self.timeout,
207+
**self.gym_kwargs,
208+
)
209+
except Exception as e: # noqa: BLE001 - gym.make
210+
message = (
211+
"Failed to create BrowserGym environment "
212+
f"'{env_id}': {e}\n"
213+
"Make sure the benchmark package is installed "
214+
f"(e.g., pip install browsergym-{self.benchmark})."
215+
)
216+
raise ValueError(message) from e
217+
218+
def _switch_task_if_needed(self, requested_task: str) -> None:
219+
if requested_task == (self.task_name or ""):
220+
return
221+
222+
next_task_name = requested_task or None
223+
next_env_id = self._build_env_id(next_task_name)
224+
next_gym_env = self._create_gym_env(next_env_id)
225+
226+
previous_gym_env = getattr(self, "gym_env", None)
227+
self.task_name = next_task_name
228+
self.env_id = next_env_id
229+
self.gym_env = next_gym_env
230+
231+
if previous_gym_env is not None:
232+
try:
233+
previous_gym_env.close()
234+
except Exception as exc: # noqa: BLE001 - browsergym/playwright cleanup
235+
logger.warning("BrowserGym cleanup failed during task switch: %s", exc)
236+
190237
def reset(
191238
self,
192239
seed: Optional[int] = None,
@@ -202,11 +249,14 @@ def reset(
202249
Initial observation for the task
203250
"""
204251
# Generate new episode ID
252+
resolved_task_name = task_name or self.task_name or ""
253+
self._switch_task_if_needed(resolved_task_name)
254+
205255
self._state = BrowserGymState(
206256
episode_id=str(uuid4()),
207257
step_count=0,
208258
benchmark=self.benchmark,
209-
task_name=task_name or self.task_name or "",
259+
task_name=resolved_task_name,
210260
)
211261

212262
# Reset options
@@ -343,7 +393,9 @@ def _create_observation(
343393
self._state.goal = goal
344394

345395
# Extract additional observation modalities
346-
screenshot = obs.get("screenshot") if isinstance(obs, dict) else None
396+
screenshot = None
397+
if self.include_screenshot and isinstance(obs, dict):
398+
screenshot = _to_jsonable(obs.get("screenshot"))
347399

348400
# Extract last_action_error from obs (BrowserGym includes this)
349401
last_action_error = False
@@ -357,11 +409,11 @@ def _create_observation(
357409
if isinstance(obs, dict):
358410
# Include useful fields but exclude large raw objects
359411
browsergym_metadata["browsergym_obs"] = {
360-
k: v
412+
k: _to_jsonable(v)
361413
for k, v in obs.items()
362414
if k not in ("dom_object", "axtree_object", "screenshot")
363415
}
364-
browsergym_metadata["browsergym_info"] = info
416+
browsergym_metadata["browsergym_info"] = _to_jsonable(info)
365417

366418
return BrowserGymObservation(
367419
text=text,
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
"""Unit tests for BrowserGym observation serialization."""
2+
3+
from __future__ import annotations
4+
5+
import os
6+
import sys
7+
8+
import pytest
9+
10+
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../..")))
11+
12+
pytest.importorskip("gymnasium", reason="gymnasium is not installed")
13+
14+
from envs.browsergym_env.models import BrowserGymState
15+
from envs.browsergym_env.server.browsergym_environment import BrowserGymEnvironment
16+
17+
18+
class _ArrayLike:
19+
def tolist(self):
20+
return [[[255, 0, 0]]]
21+
22+
23+
class _ScalarLike:
24+
def item(self):
25+
return 3.5
26+
27+
28+
def test_create_observation_serializes_array_like_fields() -> None:
29+
env = BrowserGymEnvironment.__new__(BrowserGymEnvironment)
30+
env.include_screenshot = True
31+
env._state = BrowserGymState()
32+
33+
result = BrowserGymEnvironment._create_observation(
34+
env,
35+
obs={
36+
"goal": "Pick the highlighted option",
37+
"url": "http://example.test",
38+
"screenshot": _ArrayLike(),
39+
"extra": {"score": _ScalarLike()},
40+
},
41+
info={"reward_estimate": _ScalarLike()},
42+
done=False,
43+
reward=0.0,
44+
)
45+
46+
assert result.screenshot == [[[255, 0, 0]]]
47+
assert result.metadata["browsergym_obs"]["extra"]["score"] == 3.5
48+
assert result.metadata["browsergym_info"]["reward_estimate"] == 3.5
49+
50+
51+
def test_create_observation_omits_screenshot_by_default() -> None:
52+
env = BrowserGymEnvironment.__new__(BrowserGymEnvironment)
53+
env.include_screenshot = False
54+
env._state = BrowserGymState()
55+
56+
result = BrowserGymEnvironment._create_observation(
57+
env,
58+
obs={"goal": "Pick the highlighted option", "screenshot": _ArrayLike()},
59+
info={},
60+
done=False,
61+
reward=0.0,
62+
)
63+
64+
assert result.screenshot is None
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
"""Unit tests for BrowserGym task switching behavior."""
2+
3+
from __future__ import annotations
4+
5+
import os
6+
import sys
7+
import types
8+
from typing import Any
9+
10+
import pytest
11+
12+
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../..")))
13+
14+
try:
15+
import gymnasium # noqa: F401
16+
except ModuleNotFoundError:
17+
sys.modules["gymnasium"] = types.SimpleNamespace(
18+
make=lambda *_args, **_kwargs: None
19+
)
20+
_INSERTED_GYMNASIUM_STUB = True
21+
else:
22+
_INSERTED_GYMNASIUM_STUB = False
23+
24+
from envs.browsergym_env.server import browsergym_environment
25+
from envs.browsergym_env.server.browsergym_environment import BrowserGymEnvironment
26+
27+
if _INSERTED_GYMNASIUM_STUB:
28+
sys.modules.pop("gymnasium", None)
29+
30+
31+
class _FakeGymEnv:
32+
def __init__(self, env_id: str) -> None:
33+
self.env_id = env_id
34+
self.closed = False
35+
self.reset_calls: list[dict[str, Any]] = []
36+
37+
def reset(self, **kwargs: Any):
38+
self.reset_calls.append(dict(kwargs))
39+
return {"goal": f"goal for {self.env_id}", "url": "http://example.test"}, {}
40+
41+
def close(self) -> None:
42+
self.closed = True
43+
44+
45+
def test_reset_rebuilds_browsergym_env_when_task_changes(
46+
monkeypatch: pytest.MonkeyPatch,
47+
) -> None:
48+
created_envs: list[tuple[str, dict[str, Any], _FakeGymEnv]] = []
49+
50+
monkeypatch.setattr(
51+
browsergym_environment.importlib,
52+
"import_module",
53+
lambda _name: object(),
54+
)
55+
56+
def fake_make(env_id: str, **kwargs: Any) -> _FakeGymEnv:
57+
env = _FakeGymEnv(env_id)
58+
created_envs.append((env_id, dict(kwargs), env))
59+
return env
60+
61+
monkeypatch.setattr(browsergym_environment.gym, "make", fake_make)
62+
63+
env = BrowserGymEnvironment(
64+
benchmark="miniwob",
65+
task_name="click-test",
66+
headless=True,
67+
)
68+
original_env = env.gym_env
69+
70+
observation = env.reset(task_name="enter-text", seed=7)
71+
72+
assert [env_id for env_id, _, _ in created_envs] == [
73+
"browsergym/miniwob.click-test",
74+
"browsergym/miniwob.enter-text",
75+
]
76+
assert original_env.closed is True
77+
assert env.gym_env is created_envs[1][2]
78+
assert env.task_name == "enter-text"
79+
assert env.state.task_name == "enter-text"
80+
assert created_envs[1][2].reset_calls == [{"seed": 7}]
81+
assert observation.goal == "goal for browsergym/miniwob.enter-text"
82+
83+
84+
def test_failed_task_switch_keeps_previous_browsergym_env(
85+
monkeypatch: pytest.MonkeyPatch,
86+
) -> None:
87+
fail_enter_text = True
88+
created_envs: list[_FakeGymEnv] = []
89+
90+
monkeypatch.setattr(
91+
browsergym_environment.importlib,
92+
"import_module",
93+
lambda _name: object(),
94+
)
95+
96+
def fake_make(env_id: str, **_kwargs: Any) -> _FakeGymEnv:
97+
if env_id == "browsergym/miniwob.enter-text" and fail_enter_text:
98+
raise RuntimeError("task is unavailable")
99+
env = _FakeGymEnv(env_id)
100+
created_envs.append(env)
101+
return env
102+
103+
monkeypatch.setattr(browsergym_environment.gym, "make", fake_make)
104+
105+
env = BrowserGymEnvironment(benchmark="miniwob", task_name="click-test")
106+
original_env = env.gym_env
107+
108+
with pytest.raises(ValueError, match="browsergym/miniwob.enter-text"):
109+
env.reset(task_name="enter-text")
110+
111+
assert env.gym_env is original_env
112+
assert original_env.closed is False
113+
assert env.task_name == "click-test"
114+
assert env.env_id == "browsergym/miniwob.click-test"
115+
assert env.state.task_name == "click-test"
116+
117+
fail_enter_text = False
118+
observation = env.reset(task_name="enter-text")
119+
120+
assert original_env.closed is True
121+
assert env.gym_env is created_envs[-1]
122+
assert env.task_name == "enter-text"
123+
assert env.env_id == "browsergym/miniwob.enter-text"
124+
assert observation.goal == "goal for browsergym/miniwob.enter-text"

0 commit comments

Comments
 (0)