Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
52902a4
Add harness session runtime for training and evaluation
burtenshaw Mar 28, 2026
774f445
Handle MCP tool errors and lazy-load BrowserGym exports
burtenshaw Mar 30, 2026
3dc09ce
Refine experimental harness APIs and reward forwarding
burtenshaw Mar 30, 2026
fc0188e
Merge branch 'main' into feature/harness-interface
burtenshaw Mar 30, 2026
f67070d
docs: mention environment_factory in harness tutorial
burtenshaw Apr 13, 2026
896c8c0
test: cover harness review regressions
burtenshaw Apr 13, 2026
8637512
chore: merge main
burtenshaw Apr 13, 2026
5414298
feat(harness): rollout collection + openenv collect CLI (#560)
sergiopaniego Apr 18, 2026
032b3d3
fix: address browsergym harness review comments
burtenshaw Apr 29, 2026
03c9261
fix: format collect files
burtenshaw Apr 29, 2026
1448944
envs/opencode_env: OpenCode coding-agent harness primitive (#603)
adithya-s-k May 6, 2026
88240b7
Merge branch 'main' into feature/harness-interface
burtenshaw May 6, 2026
dc451de
fix: address ci docs and formatting
burtenshaw May 6, 2026
d4032a1
feat(tutorials): add SFT warm-up tutorial with reasoning_gym collect …
sergiopaniego May 6, 2026
004168d
Merge branch 'main' into feature/harness-interface
burtenshaw May 7, 2026
b943eb1
Merge branch 'main' into feature/harness-interface
burtenshaw May 7, 2026
da5a107
fix: address harness review feedback
burtenshaw May 7, 2026
99cffbb
fix: format harness lint
burtenshaw May 11, 2026
69a6fd7
fix: address harness review comments
burtenshaw May 11, 2026
627948c
Merge branch 'main' into feature/harness-interface
burtenshaw May 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions docs/tutorials/wordle-grpo.md
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,33 @@ def reward_repetition(completions, **kwargs):

---

## Harness-driven rollouts

For environments that should be driven through a tool-oriented harness instead
of a bespoke \`reset()\` / \`step()\` loop, use the harness runtime in
\`openenv.core\`:

\`\`\`python
from openenv.core import HarnessRunLimits, MCPHarnessAdapter, build_harness_rollout_func
from envs.browsergym_env.harness import BrowserGymSessionFactory

session_factory = BrowserGymSessionFactory(client_factory=lambda: BrowserGymEnv(base_url=space_url))

rollout_func = build_harness_rollout_func(
session_factory=session_factory,
harness_adapter=MCPHarnessAdapter(),
model_step_builder=..., # wraps trainer-owned sampling
limits=HarnessRunLimits(max_turns=10),
)
\`\`\`

This keeps token sampling and logprob collection in the trainer for white-box
RL, while moving environment interaction behind a reusable MCP-style session
surface. See \`tutorial/examples/browsergym_harness.py\` for a concrete
BrowserGym example.

---

## Create dataset

\`\`\`python
Expand Down
28 changes: 28 additions & 0 deletions envs/browsergym_env/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,34 @@ for episode in range(1000):
env.close()
```

## Harness Sessions for TRL

If you want BrowserGym to participate in a tool-driven harness instead of a
hand-written `env.reset()` / `env.step()` loop, use the BrowserGym session
factory:

```python
from browsergym_env import BrowserGymEnv
from browsergym_env.harness import BrowserGymSessionFactory
from openenv.core import HarnessRunLimits, MCPHarnessAdapter, build_harness_rollout_func

session_factory = BrowserGymSessionFactory(
client_factory=lambda: BrowserGymEnv(base_url="https://openenv-browsergym-env.hf.space"),
)

rollout_func = build_harness_rollout_func(
session_factory=session_factory,
harness_adapter=MCPHarnessAdapter(),
model_step_builder=..., # trainer-owned model sampling
limits=HarnessRunLimits(max_turns=10),
)
```

BrowserGym exposes `click`, `fill`, `send_keys`, `scroll`, and `noop` as MCP-style
tools while still translating them back into the underlying `BrowserGymAction`
strings. See [tutorial/examples/browsergym_harness.py](../../tutorial/examples/browsergym_harness.py)
for a full TRL-oriented example.

### Available Tasks by Benchmark

#### MiniWoB++ Tasks (Training - 100+ tasks)
Expand Down
3 changes: 3 additions & 0 deletions envs/browsergym_env/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,11 +62,14 @@
"""

from .client import BrowserGymEnv
from .harness import BrowserGymSessionFactory, build_browsergym_action_tool_call
from .models import BrowserGymAction, BrowserGymObservation, BrowserGymState

__all__ = [
"BrowserGymEnv",
"BrowserGymAction",
"BrowserGymObservation",
"BrowserGymState",
"BrowserGymSessionFactory",
"build_browsergym_action_tool_call",
]
261 changes: 261 additions & 0 deletions envs/browsergym_env/harness.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,261 @@
"""Harness-oriented BrowserGym session adapters."""

from __future__ import annotations

import re
from typing import Any

from openenv.core import StepEnvSessionAdapter, ToolResult, VerifyResult
from openenv.core.env_server.mcp_types import Tool
from openenv.core.llm_client import ToolCall

from .models import BrowserGymAction

_BROWSERGYM_TOOLS = [
Tool(
name="click",
description="Click an element by BrowserGym bid.",
input_schema={
"type": "object",
"properties": {
"bid": {"type": "string"},
},
"required": ["bid"],
},
),
Tool(
name="fill",
description="Fill an input field by bid.",
input_schema={
"type": "object",
"properties": {
"bid": {"type": "string"},
"text": {"type": "string"},
},
"required": ["bid", "text"],
},
),
Tool(
name="send_keys",
description="Send keyboard input to the page.",
input_schema={
"type": "object",
"properties": {
"text": {"type": "string"},
},
"required": ["text"],
},
),
Tool(
name="scroll",
description="Scroll the page up or down.",
input_schema={
"type": "object",
"properties": {
"direction": {"type": "string", "enum": ["up", "down"]},
},
"required": ["direction"],
},
),
Tool(
name="noop",
description="Take no action on the current page.",
input_schema={"type": "object", "properties": {}},
),
]


def _quote(value: str) -> str:
return repr(value)


def build_browsergym_action_str(tool_name: str, arguments: dict[str, Any]) -> str:
"""Convert a BrowserGym tool call into the action string the env expects."""

if tool_name == "click":
return f"click({_quote(str(arguments['bid']))})"
if tool_name == "fill":
return (
f"fill({_quote(str(arguments['bid']))}, "
f"{_quote(str(arguments['text']))})"
)
if tool_name == "send_keys":
return f"send_keys({_quote(str(arguments['text']))})"
if tool_name == "scroll":
return f"scroll({_quote(str(arguments['direction']))})"
if tool_name == "noop":
return "noop()"

raise KeyError(f"Unsupported BrowserGym tool: {tool_name}")


def _format_browsergym_prompt(observation: Any, task: Any) -> str:
goal = getattr(observation, "goal", "") or (task or "")
page_text = getattr(observation, "axtree_txt", "") or getattr(observation, "text", "")
error = getattr(observation, "error", "")

parts = []
if goal:
parts.append(f"Goal: {goal}")
if error:
parts.append(f"Previous action error: {error}")
if page_text:
parts.append(f"Page structure:\n{page_text}")
parts.append("Choose the next browser action.")
return "\n\n".join(parts)


def _build_browsergym_tool_result(
task: Any,
):
def builder(
tool_name: str,
arguments: dict[str, Any],
result: Any,
state: Any,
) -> ToolResult:
observation = result.observation
data = {
"tool_name": tool_name,
"arguments": dict(arguments),
"goal": getattr(observation, "goal", "") or (task or ""),
"observation_text": getattr(observation, "axtree_txt", "")
or getattr(observation, "text", ""),
"url": getattr(observation, "url", ""),
"error": getattr(observation, "error", ""),
"last_action_error": getattr(observation, "last_action_error", False),
"reward": result.reward,
"done": result.done,
}
metadata = {
"reward": result.reward,
"state": state.model_dump() if hasattr(state, "model_dump") else state,
}
return ToolResult(data=data, done=bool(result.done), metadata=metadata)

return builder


def _build_browsergym_verify(
transcript: list[dict[str, Any]],
final_state: Any | None,
last_result: Any | None,
state: Any,
) -> VerifyResult:
reward = None if last_result is None else last_result.reward
done = False if last_result is None else bool(last_result.done)
metrics = {
"step_count": getattr(state, "step_count", 0),
"cum_reward": getattr(state, "cum_reward", reward or 0.0),
"benchmark": getattr(state, "benchmark", ""),
"task_name": getattr(state, "task_name", ""),
}
artifacts = {
"final_state": state.model_dump() if hasattr(state, "model_dump") else state,
"final_rollout": final_state,
"transcript_length": len(transcript),
}
return VerifyResult(
reward=reward,
done=done,
metrics=metrics,
artifacts=artifacts,
)


class BrowserGymSessionFactory:
"""Create BrowserGym-backed resource sessions from client factories."""

def __init__(self, client_factory, *, default_task: str | None = None):
self._client_factory = client_factory
self._default_task = default_task

def create(
self,
task: Any,
seed: int | None = None,
episode_id: str | None = None,
) -> StepEnvSessionAdapter:
session_task = task if task is not None else self._default_task
client = self._client_factory()

reset_kwargs = {}
if session_task is not None:
reset_kwargs["task_name"] = session_task

return StepEnvSessionAdapter(
client=client,
task=session_task,
seed=seed,
episode_id=episode_id,
tool_specs=list(_BROWSERGYM_TOOLS),
action_builder=lambda name, arguments: BrowserGymAction(
action_str=build_browsergym_action_str(name, arguments)
),
initial_messages_builder=lambda result, current_task: [
{
"role": "user",
"content": _format_browsergym_prompt(
result.observation,
current_task,
),
}
],
tool_result_builder=_build_browsergym_tool_result(session_task),
verify_builder=_build_browsergym_verify,
reset_kwargs=reset_kwargs,
)


_CLICK_RE = re.compile(r"^click\(\s*['\"](?P<bid>.+?)['\"]\s*\)$")
_FILL_RE = re.compile(
r"^fill\(\s*['\"](?P<bid>.+?)['\"]\s*,\s*['\"](?P<text>.*)['\"]\s*\)$"
)
Comment thread
burtenshaw marked this conversation as resolved.
Resolved
_SEND_KEYS_RE = re.compile(r"^send_keys\(\s*['\"](?P<text>.*)['\"]\s*\)$")
_SCROLL_RE = re.compile(r"^scroll\(\s*['\"]?(?P<direction>up|down)['\"]?\s*\)$")
_NOOP_RE = re.compile(r"^noop\(\s*\)$")


def build_browsergym_action_tool_call(action_text: str) -> ToolCall:
"""Parse a text BrowserGym action into a structured tool call."""

text = action_text.strip()

if match := _CLICK_RE.match(text):
return ToolCall(
id="browsergym-click",
name="click",
args={"bid": match.group("bid")},
)
if match := _FILL_RE.match(text):
return ToolCall(
id="browsergym-fill",
name="fill",
args={
"bid": match.group("bid"),
"text": match.group("text"),
},
)
if match := _SEND_KEYS_RE.match(text):
return ToolCall(
id="browsergym-send_keys",
name="send_keys",
args={"text": match.group("text")},
)
if match := _SCROLL_RE.match(text):
return ToolCall(
id="browsergym-scroll",
name="scroll",
args={"direction": match.group("direction")},
)
if _NOOP_RE.match(text):
return ToolCall(id="browsergym-noop", name="noop", args={})

raise ValueError(f"Unsupported BrowserGym action: {action_text}")


__all__ = [
"BrowserGymSessionFactory",
"build_browsergym_action_str",
"build_browsergym_action_tool_call",
]
23 changes: 23 additions & 0 deletions src/openenv/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,29 @@ Core components for OpenEnv - a framework for building HTTP-based agentic enviro
- **Container Providers**: Pluggable architecture for running containers (Docker, Kubernetes, etc.)
- **Type System**: Strongly-typed Action/Observation/State interfaces
- **Web Interface**: Optional web UI for interacting with environments
- **Harness Sessions**: `ResourceSessionFactory`, `StepEnvSessionAdapter`, and
`HarnessAdapter` for MCP-first training/evaluation harnesses

## Harness Sessions

OpenEnv now includes an additive harness-facing layer for cases where a rollout
driver should interact with environment resources through tools instead of
calling `reset()` / `step()` directly.

Core pieces:

- `ResourceSessionFactory.create(task, seed, episode_id)` creates an isolated
per-rollout session
- `StepEnvSessionAdapter` wraps an existing client and exposes session tools
- `SessionMCPBridge` exposes a session through MCP-style JSON-RPC
- `MCPHarnessAdapter` drives white-box rollouts where the trainer still owns
model sampling, token IDs, and logprobs
- `CLIHarnessAdapter` supports opaque evaluation harnesses over the same session
surface
- `build_harness_rollout_func(...)` builds a TRL-compatible `rollout_func`

This layer is additive. Existing environment clients and MCP environments keep
their current control-plane APIs unchanged.

## Installation

Expand Down
Loading
Loading