feat(resources_servers): add interactive browser RL environment (local Playwright + remote CDP backends) - #1865
feat(resources_servers): add interactive browser RL environment (local Playwright + remote CDP backends)#1865waple0820 wants to merge 11 commits into
Conversation
0282bc0 to
c6329fc
Compare
f84df13 to
9959756
Compare
9959756 to
1fb53e2
Compare
…interactive_browser environment The sibling nemo_gym recipe delegates a whole rollout to NeMo Gym through RolloutCollectionHelper and receives a finished result. A browser rollout runs for tens of steps against live sites, and every step can fail for reasons that have nothing to do with the policy: the environment session is lost, a page never loads, the judge endpoint is unreachable. This recipe keeps the loop on the verl side, using verl's own ToolAgentLoop, so the trainer can bound each step, classify an infrastructure failure while it happens, and resample instead of learning from it. Both recipes target the same environments and can run side by side: they attach to different verl extension points (agent_loop_manager_class vs default_agent_loop) and share no state. Nothing under nemo_gym/ is modified. * browser_agent_loop.py — BrowserTool speaking the NeMo Gym HTTP contract (/seed_session, browser_*, /verify), one aiohttp cookie jar per rollout so a rollout maps to exactly one environment session; BrowserToolAgentLoop adds an episode deadline and per-sample env_invalid flags. * judge.py — binary LLM judge for open-ended tasks, because the environment's verifier is deterministic-only today. An unusable verdict is reported as such rather than as a zero. * group_stats.py — grpo_env_aware advantage estimator that keeps flagged samples out of the group baseline. Registered, opt-in; stock GRPO is untouched. * dataset.py — maps NeMo Gym rollout rows onto tools_kwargs. * prepare_webvoyager_data.py — task list (Hugging Face or local JSONL) to rollout inputs. No task data is committed. * tests/ — offline: no GPU, no browser, no NeMo Gym server. Requires a NeMo Gym checkout providing resources_servers/interactive_browser (NVIDIA-NeMo/Gym#1865, not yet merged) and a newer verl than the sibling recipe (V1 trainer, vLLM 0.18); pinned in its own REQUIRED_VERL.txt. Signed-off-by: waple0820 <232305951+waple0820@users.noreply.github.com>
f006b11 to
3edb469
Compare
cmunley1
left a comment
There was a problem hiding this comment.
looks good! some comments
| raise | ||
| self._driver = PlaywrightPageDriver(page) | ||
| if initial_url: | ||
| await self.goto(initial_url) |
There was a problem hiding this comment.
Should goto(initial_url) go in the try/except above? if navigation fails here close() never runs and browser process leaks, and on remote_cdp the provider session is never released. The test in test_registry_and_sessions.py only exercises connect-time failure.
| try: | ||
| # A remote browser usually ships with a default context; borrow it | ||
| # rather than closing it on teardown. | ||
| if browser.contexts: |
There was a problem hiding this comment.
The docstring at line 48 says every rollout gets its own context, but with with static_cdp they share context for all rollouts. So do cookies and storage carry across rollouts, and the page from open() is never closed (page.py:162 skips teardown when _owns_context is False), so tabs accumulate. new_context() might be better?
There was a problem hiding this comment.
Done.
Measured before fixing: it was not just tabs — a value written to localStorage by one
rollout was readable by the next, so an episode inherited the previous one's state. It survived because test_isolated_state_between_episodes did not test isolation: two tabs in one context also have different URLs. Added a test that writes storage in one episode and asserts the next cannot read it.
| TASKS = [ | ||
| ("Navigate from the home page to the About page, then finish.", {"url_contains": "about.html"}), | ||
| ("Go to the form page, type 'neo' as the username, submit, then finish.", {"dom_contains": "Welcome neo"}), | ||
| ("Open the About page, read the secret code, report it via finish(answer=...).", {"dom_contains": "ALPHA-42"}), |
There was a problem hiding this comment.
should the grader here be answer_equals ALPHA-42 instead
| if st is None: | ||
| return self._no_session() | ||
| st.answer = body.answer | ||
| return ToolResponse(observation="", done=True) |
There was a problem hiding this comment.
done isn't read by simple_agent. Its while True at app.py:106 exits only on incomplete_details (163), an assistant message with no tool calls (171), or max_steps (228). The flag does reach the model as text in the serialized tool output, so it works as a hint, but nothing enforces it. The model can finish and keep navigating, and _score reads the live page at verify, so a rollout that solves the task and then goes back to double-check scores 0.0.
Simplest change that stays inside this env - capture current_url() and the page text here on the first finish, store them on _SessionState, and have _score grade the snapshot, falling back to the live page only if the model never finished. The episode still runs to max_steps, but the reward stops depending on what happens after the model commits. Making the loop actually stop would mean implementing done in simple_agent which is used by many environments, or creating a new agent server.
There was a problem hiding this comment.
Done, implemented as you described — snapshot at the first finish, _score grades the snapshot, live page only if the model never finished. Agreed on leaving simple_agent alone.
| # The session this rollout ran in is gone: it was never seeded, it was already | ||
| # verified, or the server lost it. There is nothing to measure, so say so | ||
| # instead of reporting a zero that reads as a policy that solved nothing. | ||
| return self._verify_response(body, reward=0.0, failure_reason="browser session not found at verify") |
There was a problem hiding this comment.
You could set setting mask_sample alongside failure_reason here. Nothing in Gym reads failure_reason, so an unmeasurable rollout reaches training as a plain 0.0, indistinguishable from a policy that never solved the task, which is what the _ScoringUnavailable path exists to prevent. NeMo-RL's GRPO path reads instance_config["mask_sample"] off the verify result and zeroes loss_multiplier for flagged samples, so declaring a response subclass with instance_config: {"mask_sample": False} the way conversational_tool_use_simulation does (app.py:435) should exclude from gradient in training. Same at 260-264.
There was a problem hiding this comment.
You are right that nothing reads failure_reason today — it is human-readable only.
#2611 is exactly this gap: it puts mask_sample on BaseVerifyResponse, and after review there it is consumed centrally in compute_aggregate_metrics() rather than per environment. That is the version I would rather ship here, since the argument that PR makes is that this fact should stop being re-invented privately — and unlike swe_agents, where #2611 mirrors a field that already exists, adding one here would be creating a new one.
If #2611 stalls and this PR is otherwise ready, I will add the instance_config field to unblock, and drop it when the contract field lands. Happy to go either way.
There was a problem hiding this comment.
Actually, correcting myself — there is a path that works on main today, at least for eval. /verify is wrapped in judge_failsafe, so raising JudgeError puts the row in the failures sidecar and aggregation never sees it.
I didn't go that way though. It'd label a dead browser as judge_failed, which seems wrong while #2750 is trying to settle on failure names, and it only helps eval anyway — training reads the verify response directly and never goes through that path.
Which is really the case for #2611: right now the same fact needs _ng_failure_class for eval and instance_config["mask_sample"] for training, depending on who's reading it.
4c1111b to
ec3174f
Compare
…ironment An interactive browser environment for NeMo-Gym (addresses NVIDIA-NeMo#644): one isolated browser per rollout, driven by tool calls (navigate/click/type/observe/finish) and scored by verify(). Complements the read-only web envs (google_search, browsecomp). The browser is pluggable behind a small BrowserBackend contract: - PlaywrightBackend: open-source reference (headless Chromium), for local dev and CI (zero proprietary deps). - LexmountBackend: production; an isolated browser session in the Lexmount cloud, so browser RAM stays off the training node. Reuses the simple_agent harness (tools dispatched by name). Reward is settled once in verify() (final_url / url_contains / dom_contains / answer_equals). Validated end-to-end via a gym rollout (Playwright backend, offline site). Signed-off-by: waple0820 <feng.wang@lexmount.com>
…nd WebVoyager data bridge Make the environment reproducible end-to-end from one script so reviewers can verify it without guessing at the current CLI or the training recipe. - example.sh: idempotent, fail-fast, three explicit stages — Stage A (no GPU: backend test + gym serving stack + rollouts over data/example.jsonl against a policy endpoint), Stage B (1-GPU GRPO smoke via NeMo-RL), Stage C (same rollout on the Lexmount cloud backend via one flag). Policy endpoint supports both a generic OpenAI-compatible endpoint (POLICY_BASE_URL/API_KEY/MODEL) and a local vLLM model. - configs/grpo_lexmount_browser_smoke.yaml: 1-GPU GRPO smoke that ports the validated 0721 Qwen3-8B 2x8-NPU hyperparameters (GRPO group size 8, lr 5e-6 constant, 10 turns; reference reward 0.10 -> 0.29 over 60 steps). Every value is annotated as validated or smoke-scaled. - scripts/convert_webvoyager.py + data/webvoyager_sample.jsonl: map WebVoyager-style task JSON (MIT) into this env's example format, following the validated pipeline's cleaning conventions. Full 600-task set is not bundled; 3 verbatim sample tasks ship for --selftest. - README: how to serve the policy model (the NVIDIA-NeMo#1 reviewer stumbling block); clarify that the in-PR verify() is rule-based and the environment default, while the production-validated recipe uses a trajectory-level LLM judge; refresh the CLI to current main (openai_model policy, repo-relative paths). - data/example_rollouts.jsonl + example_metrics.json: 5 rollouts collected end-to-end against a Responses-API endpoint (reward 1.0 on the offline site). No base URL or key is stored in the committed artifacts. Signed-off-by: waple0820 <feng.wang@lexmount.com>
Fixes found by running the script on a bare container with only the
branch checked out (reviewer simulation):
- Stage C was unrunnable: the backend Hydra override needs a leading
'+' (CLI overrides parse against an empty struct; a bare key dies
with "Key 'lexmount_browser' is not in struct").
- Stage C drove the cloud browser at local file:// tasks, which a
remote browser cannot load (net::ERR_BLOCKED_BY_ADMINISTRATOR).
Stage C now rolls out on the bundled real-web WebVoyager sample
tasks (already in this env's input format) and writes
data/webvoyager_rollouts.jsonl.
- Stage C failed with an opaque mid-rollout HTTP 500 when the Lexmount
SDK was missing from the per-server venv; preflight it and fail fast
with the exact install command.
- The 'servers ready' wait matched the interim '0 / 3 servers ready.
Waiting...' line, so eval raced ahead of the stack and spun on
ClientOSError; match the final 'All N / N servers ready' line only.
- The EXIT trap never fired (SERVER_PID was set inside a subshell) and
killed only the parent; the serving stack outlived every run. Kill
the parent plus its descendants from the main shell.
- Chromium fails host validation on bare containers ('Host system is
missing dependencies to run browsers'); run playwright install-deps
when root/sudo permits, otherwise print the exact command.
- Ignore generated eval artifacts under data/.
Signed-off-by: waple0820 <feng.wang@lexmount.com>
- Policy endpoint: must implement /v1/responses (no chat-completions fallback in the agent path) AND return structured function_call items; without a tool-call parser (vLLM: --enable-auto-tool-choice --tool-call-parser hermes) every rollout silently scores reward 0.0. - Playwright: bare containers need browser system libraries (playwright install-deps); list the network endpoints setup hits. - Stage C: the SDK belongs in the per-server venv at resources_servers/lexmount_browser/.venv (not the repo root venv), and cloud rollouts run on the bundled real-web sample tasks because a cloud browser cannot load local file:// URIs. - Data: webvoyager_sample.jsonl ships already converted (directly usable as rollout input), not in raw upstream form. Signed-off-by: waple0820 <feng.wang@lexmount.com>
Return the PR to the original environment contribution plus minimal documentation fixes: - Remove example.sh, the GRPO smoke config, and the WebVoyager data bridge (converter + sample tasks): training recipes and dataset tooling are out of scope for an environment PR. Training still plugs into NeMo-RL GRPO via examples/nemo_gym/run_grpo_nemo_gym.py. - README: correct the rollout commands for the current-main CLI (openai_model with --model/--model-url/--model-api-key, repo-root relative --input/--output paths). - README: install the Lexmount SDK into the per-server venv (resources_servers/lexmount_browser/.venv), not the repo-root venv. - README: document the policy-endpoint requirements (/v1/responses support + structured tool-call parsing) and the Playwright system- library prerequisite on bare Linux. - README: correct the backend-switch config nesting and note that the cloud backend cannot load the bundled offline file:// tasks. - Keep data/example_rollouts.jsonl (+ dataset metrics) as the new-environment checklist artifact and re-tick the checklist to match what this PR contains; the GRPO training-signal box stays unchecked. Signed-off-by: waple0820 <feng.wang@lexmount.com>
…il loudly on bad specs Five defects that are wrong on the happy path, not only under failure, plus an honest statement of what the cloud backend does not do. - The Lexmount SDK is synchronous and `sessions.create` polls until the session is active (default 150s). Calling it straight from an async handler blocked the event loop for the whole resources server, stalling every *other* rollout's tool calls. Create/close/delete now run via `asyncio.to_thread` and are bounded; `poll_timeout_sec` remains the real deadline because the thread cannot be cancelled. - `except TypeError` around create was used to feature-detect an older SDK signature. A TypeError raised from inside create made the retry allocate a second provider session and leak the first. Feature-detect with `inspect.signature` and create once. - `observe()` probed every interactive node on the page (several browser round-trips each) and discarded the surplus at render time. Collection now stops at `max_elements`, and `Observation.truncated` tells the policy the list is incomplete. - `observe()` stored lazy locators, which are re-resolved against the current DOM at action time: a click after any DOM mutation could silently land on a different element. Bind element handles instead, so a detached element raises rather than acting on the wrong node. - A `verifier_metadata` with no supported scoring key returned reward 0. A misspelled key therefore scored every rollout 0, indistinguishable from a policy that never solves the task. It now raises. - Close failures were silently swallowed in both backends; they are now logged, since an unreported close is a leaked browser that looks like clean teardown. The cloud backend is documented as experimental rather than production: it has no client-side session cap, no episode TTL, and best-effort close, so its quota requirements are now stated in the class docstring, the config, and a README "Session limits" section. The default `playwright` backend has none of these constraints. `poll_timeout_sec` is also plumbed through `make_backend` and the config, where it previously could not be set at all. Tested: `tests/test_backend.py` (3 passed) against the bundled offline site, including a new case for the element budget. Signed-off-by: waple0820 <feng.wang@lexmount.com>
… a provider
The environment was named and shaped around one browser service. It is now a
vendor-neutral interactive-browser environment with two shipped backends, and
the hosted-service integration is an example session provider selected by
config.
resources_servers/lexmount_browser -> resources_servers/interactive_browser
LexmountBrowserResourcesServer -> InteractiveBrowserResourcesServer
PlaywrightBackend -> LocalPlaywrightBackend
LexmountBackend(PlaywrightBackend) -> RemoteCDPBackend + LexmountSessionProvider
* `browser/base.py` keeps `BrowserBackend` as the only contract the server
depends on, and adds `BrowserSessionProvider` — the narrower seam a remote
backend needs: hand me a CDP endpoint, take it back afterwards.
* `browser/page.py` holds the Playwright page driving and teardown that both
backends share, by composition. The remote backend no longer inherits from
the local one, where any launch-specific state added locally would have
leaked into it.
* `RemoteCDPBackend` is a first-class backend, not vendor glue: the built-in
`static_cdp` provider points it at any `--remote-debugging-port` Chromium or
browser container, so it is usable and CI-tested with no third-party SDK.
Every acquired session is released exactly once — including when the CDP
connect fails after create, and however often `close()` is called.
* Backends and providers are selected as single-key `{name: {kwargs}}` mappings
and providers are imported only when selected, matching `nemo_gym.sandbox`.
Out-of-tree providers register through the
`nemo_gym.browser_session_providers` entry-point group.
* Lexmount becomes `providers/lexmount/` plus the `interactive_browser/lexmount`
config flavor: same environment, remote browsers, live-web example tasks
(a remote browser cannot open the bundled offline `file://` site). Its SDK
stays an operator-installed optional dependency; the environment depends on
playwright and nothing else.
* Tests run one contract against both backends (the remote one against a
Chromium the suite starts) plus selection and session-release bookkeeping:
22 tests, no GPU, no serving stack, no account.
…t to it Per review: everything vendor-specific now lives in one directory — providers/lexmount/ holds the provider, its config and its README. The environment's own configs/ keeps only the stock local-browser config, so `resources_servers/interactive_browser/` contains no vendor-specific file outside providers/. A provider config is loaded by path: gym env start --config resources_servers/interactive_browser/providers/lexmount/configs/lexmount.yaml The server directory comes from the config's block keys, not from the config's location, so the entrypoint and dataset paths resolve unchanged. The one consequence is discovery: `gym list resources_servers` scans `resources_servers/<env>/configs/*.yaml`, so the provider config no longer appears as a selectable flavor.
…coring it zero `verify` returned `reward=0.0` whenever the browser session was missing, which is the conflation this environment exists to demonstrate: a rollout whose session was lost is not a policy that solved nothing, and downstream the two are identical. It now reports `failure_reason` on that path, using the field NVIDIA-NeMo#2552 added to `BaseVerifyResponse`. A browser that died mid-episode was worse than mis-scored. `_score` reads the live page, so a dead browser raised out of `verify`; only `JudgeError` is converted to a routed row, so that exception ended the whole collection run rather than the one rollout it belonged to. Browser reads are now wrapped and reported the same way. An unsupported scoring key still raises, because a dataset typo is a configuration error rather than an infrastructure failure, and failing on the first rollout is the intended behaviour. Building the response by spreading the request and also passing `reward` as a keyword was a latent `TypeError: got multiple values`: `BrowserVerifyRequest` allows extra fields, so a caller putting `reward` or `failure_reason` in the body crashed verify. Response-owned fields are now dropped from the spread. The same shape was found and fixed in `anyswe_agent` on NVIDIA-NeMo#2611. Closing the previous browser on a re-seeded session swallowed every exception. A browser we could not close is a resource the run still holds, so it is logged. Tests build and serialize the real response, since the collision only appears at construction time. Reverting any one of the three fixes fails a test. Signed-off-by: waple0820 <feng.wang@lexmount.com>
…t, graders, post-finish drift Four defects found in review. `open()` ran the initial navigation outside the guard that unwinds a failed connect, so a bad URL, a DNS failure or a timeout stranded the browser process and, on a remote backend, the provider session with it. Navigation is now inside the guard. This is the leak this environment exists to demonstrate. `RemoteCDPBackend` borrowed the browser's default context whenever one existed, which is always for a real Chromium. Every rollout on a shared endpoint therefore shared cookies and storage, so state from one episode was visible to the next, and the docstring promising a context per rollout described behaviour the code did not have. Measured before the fix: a value written by one rollout was readable by the next. It now always opens a fresh context, and closes its own page even when the context is borrowed, so no tab is left behind. `test_isolated_state_between_episodes` did not test isolation: two tabs in one context also have different URLs, so its assertions held while storage was fully shared. A companion test writes to localStorage in one episode and asserts the next cannot read it; restoring the borrowed context fails it. Two dataset tasks asked the model to report an answer via finish() but graded `dom_contains` against text that is printed on the page either way, so merely navigating there scored 1.0 without reporting anything. Both now grade `answer_equals`, and the generated data files are regenerated to match. `done` is a hint the agent loop does not enforce, so an episode keeps running after the model finishes, and grading the live page scored wherever it drifted: a rollout that solved the task and then went back to double-check scored 0.0. The page is now snapshotted at the first finish and graded from that snapshot, falling back to the live page only when the model never finished. Reverting any one of these fixes fails a test. Signed-off-by: waple0820 <feng.wang@lexmount.com>
…server now needs `test_task_data` became a repo-wide requirement while this PR was open: every resources server must describe its dataset rows. A row here says where the episode starts and how it is graded. `initial_url` is top-level on the wire; the four grading keys are read only from inside `verifier_metadata`, so they carry `legacy_location` for the row-format migration. The docstring records that exactly one grading key is expected, and that a row carrying none raises rather than scoring every rollout zero. Signed-off-by: waple0820 <feng.wang@lexmount.com>
ec3174f to
0cf5df8
Compare
Summary
Addresses #644 (integrate Browser Gym), which asks for BrowserGym-style interactive web-task environments — an agent navigating and acting on a live page — in the Gym ecosystem. This PR delivers one natively as
interactive_browser: one isolated browser per rollout, driven by tool calls, scored byverify(). Existing web envs (google_search,browsecomp) only read pages; this one operates them.Where the browser runs is a config choice. The remote backend runs it off the training node, so browser RAM does not scale on the GPU node.
What it is
browser_navigate/browser_click/browser_type/browser_observe/browser_finish) +verify(), with one isolated browser context persession_id.simple_agentharness — the model's function calls are dispatched by name to the tool endpoints; no custom harness needed.BrowserBackendcontract (open / goto / click / type / observe / current_url / text / close):backend:local_playwright(default)site/tasks in CI.remote_cdpremote_cdp— where is this rollout's browser, and how do I give it back?static_cdp(built in)chromium --remote-debugging-port=9222, a browser container, another host. No third-party dependency, and what CI testsremote_cdpagainst.lexmount(example)playwrightand nothing else.Selection mirrors
nemo_gym.sandbox: single-key{name: {kwargs}}mappings, providers imported only when selected, and out-of-tree providers publishable through thenemo_gym.browser_session_providersentry-point group (no fork needed).Key result — browser off-node
Browser RAM on the training node, measured (PSS), by concurrency model:
lexmountprovider)The browser engine runs off the node, so training-node RAM stays flat as concurrency grows and is free for vLLM / the model.
(Measured on a single host with a simple-page workload, characterizing each backend's browser engine — PSS of the chrome* process tree for the local backend vs node-side client PSS for the remote one.)
Session lifetime
A browser is released when the rollout is scored (
verify) or when the samesession_idis re-seeded. There is no independent episode TTL and no client-side cap on concurrent sessions, so a metered provider needs its account quota sized above the rollout concurrency (documented in the env README and the provider README).Every acquired provider session is released exactly once — including when the CDP connect fails after the session was created, and however often
close()is called. Both are covered by tests.Integration path
SimpleResourcesServerwith custom tool endpoints; upgradeable toMCPResourcesServer(@gym_toolon/mcp) if an MCP-native harness is preferred.Environmentso it runs through the existingopenenvadapter (OpenEnvResourcesServer, which discovers tools at startup viaListToolsAction). This is the config-selected, backend-agnostic path for the third-party-environment integration tracked in [epic] 3rd Party Environments → Gym Integration #1701.verify()(not per-step accumulation), which sidesteps theTODO(ahmadki)cumulative-reward limitation in the OpenEnv adapter's step-wiseaccumulated_rewardsummation.Status
app.py,browser/, configs, offline test site,generate_data.py+ example tasks, tests)gym env start+ rollout (local backend, offline site): reward 1.0 on the navigate taskremote_cdpagainst a Chromium the suite starts with--remote-debugging-port, so the remote path is covered in CI with no third-party service (22 tests, no GPU, no serving stack, no account)lexmountexample provider implemented + smoke-validated on a live cloud sessionexample_rollouts.jsonlFeedback welcome on the interface (A vs B), and on where you would like the example provider to live — it currently sits in-tree as a self-contained
providers/lexmount/(code + config + docs), mirroring how named sandbox providers ship undernemo_gym/sandbox/providers/. Loaded by path: