Skip to content

feat(resources_servers): add interactive browser RL environment (local Playwright + remote CDP backends) - #1865

Open
waple0820 wants to merge 11 commits into
NVIDIA-NeMo:mainfrom
waple0820:feat/lexmount-browser
Open

feat(resources_servers): add interactive browser RL environment (local Playwright + remote CDP backends)#1865
waple0820 wants to merge 11 commits into
NVIDIA-NeMo:mainfrom
waple0820:feat/lexmount-browser

Conversation

@waple0820

@waple0820 waple0820 commented Jul 1, 2026

Copy link
Copy Markdown

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 by verify(). 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.

Updated per review feedback. The environment is now vendor-neutral: InteractiveBrowserResourcesServer + LocalPlaywrightBackend + RemoteCDPBackend, with hosted browser services plugged in as session providers. The Lexmount integration ships as an example provider in providers/lexmount/, which holds its code, its config and its README — the environment directory contains no vendor-specific file outside providers/.

lexmount_gym_arch

What it is

  • A stateful Resources Server: browser tools (browser_navigate / browser_click / browser_type / browser_observe / browser_finish) + verify(), with one isolated browser context per session_id.
  • Reuses the standard simple_agent harness — the model's function calls are dispatched by name to the tool endpoints; no custom harness needed.
  • Two shipped backends behind a small BrowserBackend contract (open / goto / click / type / observe / current_url / text / close):
backend: Browser runs Notes
local_playwright (default) A Chromium launched on this machine, one per rollout No account, no quota, no network. Grades the bundled offline site/ tasks in CI.
remote_cdp Anywhere reachable over CDP, supplied per rollout by a session provider Keeps browsers off the training node.
  • A session provider answers one question for remote_cdpwhere is this rollout's browser, and how do I give it back?
class BrowserSessionProvider(Protocol):
    name: str
    async def acquire(self, spec: BrowserSessionSpec) -> BrowserSessionHandle: ...   # a live cdp_url
    async def release(self, handle: BrowserSessionHandle) -> None: ...               # idempotent
Provider What it does
static_cdp (built in) Uses an endpoint that already exists — chromium --remote-debugging-port=9222, a browser container, another host. No third-party dependency, and what CI tests remote_cdp against.
lexmount (example) Creates one isolated cloud browser session per rollout and deletes it afterwards. SDK is operator-installed and optional; the environment itself depends on playwright and nothing else.

Selection mirrors nemo_gym.sandbox: single-key {name: {kwargs}} mappings, providers imported only when selected, and out-of-tree providers publishable through the nemo_gym.browser_session_providers entry-point group (no fork needed).

Key result — browser off-node

Browser RAM on the training node, measured (PSS), by concurrency model:

Backend node RAM @ 1000 concurrent rollouts
Local Chromium, one process per rollout ~40–50 GB
Local Chromium, one browser + N contexts ~16 GB
Remote over CDP (measured with the lexmount provider) ~0.14 GB — flat in concurrency

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 same session_id is 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

  • A (this PR): native SimpleResourcesServer with custom tool endpoints; upgradeable to MCPResourcesServer (@gym_tool on /mcp) if an MCP-native harness is preferred.
  • B (follow-up): wrap the environment as an OpenEnv Environment so it runs through the existing openenv adapter (OpenEnvResourcesServer, which discovers tools at startup via ListToolsAction). This is the config-selected, backend-agnostic path for the third-party-environment integration tracked in [epic] 3rd Party Environments → Gym Integration #1701.
  • Reward is settled once in verify() (not per-step accumulation), which sidesteps the TODO(ahmadki) cumulative-reward limitation in the OpenEnv adapter's step-wise accumulated_reward summation.

Status

  • Environment (app.py, browser/, configs, offline test site, generate_data.py + example tasks, tests)
  • Runs end-to-end via a gym env start + rollout (local backend, offline site): reward 1.0 on the navigate task
  • One contract test suite run against both backends — remote_cdp against 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)
  • Off-node concurrency benchmark (per-backend browser-engine RAM)
  • lexmount example provider implemented + smoke-validated on a live cloud session
  • Remote path validated at training scale
  • Pre-generated example_rollouts.jsonl
  • GRPO training-signal run + reward curve on a web benchmark

Feedback 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 under nemo_gym/sandbox/providers/. Loaded by path:

gym env start --config resources_servers/interactive_browser/providers/lexmount/configs/lexmount.yaml ...

@copy-pr-bot

copy-pr-bot Bot commented Jul 1, 2026

Copy link
Copy Markdown

This pull request requires additional validation before any workflows can run on NVIDIA's runners.

Pull request vetters can view their responsibilities here.

Contributors can view more details about this message here.

@nemo-automation-bot nemo-automation-bot Bot added the community-request Issue reported or requested by someone from the community label Jul 1, 2026
@waple0820
waple0820 force-pushed the feat/lexmount-browser branch from 0282bc0 to c6329fc Compare July 1, 2026 12:29
@waple0820
waple0820 force-pushed the feat/lexmount-browser branch 2 times, most recently from f84df13 to 9959756 Compare July 23, 2026 11:24
@waple0820
waple0820 force-pushed the feat/lexmount-browser branch from 9959756 to 1fb53e2 Compare July 30, 2026 13:22
@waple0820
waple0820 marked this pull request as ready for review July 30, 2026 13:23
@svcnvidia-nemo-ci svcnvidia-nemo-ci added the waiting-on-maintainers Waiting on maintainers to respond label Jul 30, 2026
@github-actions github-actions Bot added the sla:triage-overdue Review assignment is over the one-business-day SLA label Jul 31, 2026
@waple0820 waple0820 changed the title feat(resources_servers): add lexmount_browser interactive browser environment feat(resources_servers): add interactive browser RL environment (local Playwright + remote CDP backends) Aug 5, 2026
waple0820 added a commit to waple0820/verl-recipe that referenced this pull request Aug 25, 2026
…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>
@waple0820
waple0820 force-pushed the feat/lexmount-browser branch from f006b11 to 3edb469 Compare August 28, 2026 09:54
@anwithk
anwithk requested a review from cmunley1 August 28, 2026 19:53
@github-actions github-actions Bot removed the sla:triage-overdue Review assignment is over the one-business-day SLA label Aug 28, 2026

@cmunley1 cmunley1 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

looks good! some comments

raise
self._driver = PlaywrightPageDriver(page)
if initial_url:
await self.goto(initial_url)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

try:
# A remote browser usually ships with a default context; borrow it
# rather than closing it on teardown.
if browser.contexts:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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"}),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should the grader here be answer_equals ALPHA-42 instead

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

if st is None:
return self._no_session()
st.answer = body.answer
return ToolResponse(observation="", done=True)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@waple0820 waple0820 Aug 31, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

…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>
@waple0820
waple0820 force-pushed the feat/lexmount-browser branch from ec3174f to 0cf5df8 Compare September 6, 2026 07:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

community-request Issue reported or requested by someone from the community waiting-on-maintainers Waiting on maintainers to respond

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants