docs/source/openenv.md
OpenEnv is an open-source framework for defining, deploying, and interacting with environments in reinforcement learning (RL) and agentic workflows. It provides standardized APIs for environment interaction and supports running environments as backend servers (via WebSocket or containerised execution). You can find a collection of ready-to-use OpenEnv environments on the Hugging Face Hub.
This guide covers how to integrate OpenEnv with TRL. For more on OpenEnv itself, see the OpenEnv docs.
[!NOTE] You can explore ready-to-use example scripts and notebooks in the Examples Overview.
[GRPOTrainer] can be used to train agents. For agentic tasks, it supports two modes: tools, where the model can call external functions but each call is stateless and independent, and environments, which maintain state across turns, enabling genuine multi-turn interaction where the agent's actions shape future observations. Use environments when continuity matters — for example, navigating a game, browsing a web page, or any task where what the agent sees next depends on what it did before.
OpenEnv is the native path documented here. Two further integrations — OpenReward and Harbor — conform to the same environment_factory contract and are interchangeable at the TRL level. See the comparison of environment integrations in the GRPO guide to pick the one whose ecosystem fits your task.
OpenEnv environments are hosted as Hugging Face Spaces, which are also pip-installable Git repositories:
# Echo environment
pip install "openenv-echo-env @ git+https://huggingface.co/spaces/openenv/echo_env"
# Wordle (TextArena) environment
pip install "openenv-textarena @ git+https://huggingface.co/spaces/openenv/wordle"
# Catch (OpenSpiel) environment
pip install "openenv-openspiel-env @ git+https://huggingface.co/spaces/openenv/openspiel_env"
This installs the environment client (e.g., EchoEnv) that communicates with the remote environment server via WebSocket, along with the action/observation models and all required dependencies (including openenv).
[!TIP] You can find the install command for any environment on its HF Space page. Click the ⋮ (three dots) menu and select "Use this Space" to see the install instructions.
[!TIP] You can also install the core package from PyPI with
pip install "openenv[core]>=0.3.1", but note that environment-specific dependencies may need to be installed separately.
For development, you can clone the OpenEnv repo and install locally:
git clone https://github.com/huggingface/OpenEnv.git
cd OpenEnv/envs/echo_env
pip install -e .
[!NOTE] Each environment script in TRL includes inline dependency metadata (PEP 723) so you can also run them directly with uv:
bashuv run examples/scripts/openenv/echo.pyThis automatically installs the required environment package in an isolated virtual environment.
The fastest way to understand the integration is a complete example. The echo.py script trains a model with the Echo environment, which rewards completions based on their text length:
from datasets import Dataset
from echo_env import EchoEnv
from echo_env.models import EchoAction
from trl import GRPOConfig, GRPOTrainer
ENV_URL = "https://openenv-echo-env.hf.space"
class EchoToolEnv:
def __init__(self):
self.env = EchoEnv(base_url=ENV_URL)
self.reward = 0.0
def reset(self, **kwargs) -> str | None:
self.reward = 0.0
return None
def echo(self, message: str) -> str:
"""
Echo the message back from the environment.
Args:
message: The message to echo
Returns:
The echoed message.
"""
observation = self.env.step(EchoAction(message=message))
self.reward = observation.observation.reward
return observation.observation.echoed_message
def reward_func(environments, **kwargs):
return [env.reward for env in environments]
dataset = Dataset.from_dict(
{"prompt": [[{"role": "user", "content": "Try to echo 'Hello World!' in the environment."}]] * 64}
)
trainer = GRPOTrainer(
model="Qwen/Qwen3-0.6B",
train_dataset=dataset,
reward_funcs=reward_func,
args=GRPOConfig(
chat_template_kwargs={"enable_thinking": False},
log_completions=True,
),
environment_factory=EchoToolEnv,
)
trainer.train()
That's it. Here's what happens under the hood:
environment_factory=EchoToolEnv: The trainer creates one EchoToolEnv instance per generation (pass the class, not an instance).reset() is called at the start of each episode to initialize state. Returns an observation string (or None).echo()) and exposes them as function-calling tools. Each method must have a proper docstring with typed arguments, which the trainer uses to build the tool schema.echo(), appends the result, and generates again, until the model stops calling tools or max_completion_length is reached.env.reward from each environment instance after the episode (before the environment is reset).# Run the example
python examples/scripts/openenv/echo.py
# Customize model and environment URL
python examples/scripts/openenv/echo.py --model Qwen/Qwen3-0.6B --env-host https://openenv-echo-env.hf.space
Below is the reward curve from training:
<iframe src="https://trl-lib-trackio.hf.space?project=openenv&metrics=train/rewards/reward_from_env/mean&runs=qgallouedec-1761202871&sidebar=hidden&navbar=hidden" style="width:100%; max-width:800px; height:500px; border:0;"></iframe>[!NOTE] You can explore more ready-to-use example scripts and notebooks in the Examples Overview.
environment_factory worksTRL's [GRPOTrainer] supports interactive environment training through the environment_factory argument. When provided, the trainer automatically handles the multi-turn tool-calling loop: it generates completions, parses tool calls, executes them against the environment, and feeds the results back to the model. All without custom rollout code.
Your environment class must follow these rules:
__init__(self) (optional): If provided, must take no arguments. Use it to initialize state or clients. If you need external configuration (e.g., a URL), capture it from the enclosing scope or module-level variables.reset(self, **kwargs): Called at the start of each episode. Receives all dataset columns as keyword arguments. Return a string observation (or None for no initial observation)._) other than reset is automatically exposed as a tool. Each tool method must have a docstring with Args: descriptions, since the trainer uses these to generate the tool schema for the model.self.reward, self.done, etc.) and access it in your reward function via the environments parameter. Refer to the Quick Start guide for an example of this pattern.ValueError("Game over.")), the trainer catches it and feeds the error message back to the model as a tool response. This is the recommended way to signal that an action is invalid or that the episode has ended.ENV_URL = "https://my-env.hf.space"
class MyEnv:
def __init__(self):
self.client = MyClient(base_url=ENV_URL) # captured from enclosing scope
self.reward = 0.0
def reset(self, **kwargs) -> str | None:
self.reward = 0.0
return "Initial observation for the model"
def my_tool(self, arg1: str, arg2: int) -> str:
"""
Description of what this tool does.
Args:
arg1: Description of arg1
arg2: Description of arg2
Returns:
The result message.
"""
self.reward = 1.0
return "Tool result"
[!IMPORTANT] Tools must be individual methods with descriptive names and typed arguments (e.g.,
guess(word: str),move(direction: str)). We do not recommend using generic methods likestep(action), since the model needs meaningful tool names and argument descriptions to learn tool calling.
Reward functions receive the environments parameter (a list of environment instances), so you can access any state stored during the episode:
def reward_func(environments, **kwargs) -> list[float]:
return [env.reward for env in environments]
For more information on reward functions, see the GRPO - Custom Reward Functions.
A few things we've found helpful when working with OpenEnv environments and GRPO:
max_completion_length in multi-turn episodesThe max_completion_length parameter limits the total number of tokens across the entire multi-turn conversation (all model generations + tool results combined), not just a single generation. For environments with many turns (e.g., Sudoku with dozens of moves), you may need to increase it:
args = GRPOConfig(
max_completion_length=4096, # default is usually 256-1024, increase for long episodes
# ...
)
If episodes are being cut short (model stops mid-game), this is likely the cause.
Let's train a model to play Wordle using the TextArena environment. This demonstrates multi-turn interaction, cumulative feedback handling, and episode termination via exceptions.
[!NOTE] You can explore the notebook version of this example in the OpenEnv Wordle GRPO example.
TextArena is an open-source collection of competitive text-based games designed to evaluate reasoning skills in LLMs using textual games like Wordle, Snake, Tic-Tac-Toe, and more.
Wordle is a good benchmark for environment-based RL because it requires reasoning about feedback, is purely text-based, and models from 1B parameters can improve at it. Each guess is only 8 tokens, making it lightweight to experiment with.
[!NOTE] How does Wordle work? Wordle is a word guessing game where the player has to guess a 5-letter word in 6 attempts. After each guess, the environment provides letter-by-letter feedback:
G U E S S X G Y X XX = not in the word, G = correct position (green), Y = wrong position (yellow). Here, "U" is correct and in place, "E" is in the word but misplaced.
The WordleEnv class wraps the TextArena client and exposes guess() as the tool:
from textarena_env import TextArenaAction, TextArenaEnv
class WordleEnv:
def __init__(self):
self.client = TextArenaEnv(base_url="https://openenv-wordle.hf.space")
def reset(self, **kwargs) -> str | None:
result = self.client.reset()
self._last_full_feedback = result.observation.messages[0].content
self.reward = 0.0
self.done = False
return self._last_full_feedback
def guess(self, guess: str) -> str:
"""
Make a guess in the Wordle environment.
Args:
guess: The guessed word, formatted as '[abcde]'
Returns:
The feedback message from the environment.
"""
if self.done:
raise ValueError("Game over.")
result = self.client.step(TextArenaAction(message=guess))
_full_feedback = result.observation.messages[0].content
feedback = _full_feedback[len(self._last_full_feedback):]
self._last_full_feedback = _full_feedback
if "You attempted an invalid move" in feedback:
self.reward = 0.0
else:
self.reward = result.reward
self.done = result.done
return feedback
Key design choices:
reset() returns the initial game message as the first observation the model sees.guess() is the only tool. The model calls it each turn with a 5-letter word.guess() raises a ValueError. The trainer catches this and feeds "Game over." back to the model as a tool response. The model learns to stop calling tools after this signal.from datasets import Dataset
from trl import GRPOConfig, GRPOTrainer
def reward_func(environments, **kwargs) -> list[float]:
return [env.reward for env in environments]
prompt = """You are an expert Wordle solver with deep knowledge of English vocabulary...
Use the tool `guess` to make a guess."""
dataset = Dataset.from_dict({"prompt": [[{"role": "user", "content": prompt}]] * 1000})
trainer = GRPOTrainer(
model="Qwen/Qwen3-1.7B",
reward_funcs=reward_func,
train_dataset=dataset,
args=GRPOConfig(
use_vllm=True,
vllm_mode="colocate",
chat_template_kwargs={"enable_thinking": False},
max_completion_length=1024,
num_generations=4,
gradient_accumulation_steps=64,
),
environment_factory=WordleEnv,
)
trainer.train()
The environment returns 1.0 if the model wins and 0.0 otherwise.
Colocate mode (1 GPU, recommended)
python examples/scripts/openenv/wordle.py --vllm-mode colocate
This runs vLLM in the same process as training, requiring only a single GPU.
</hfoption> <hfoption id="server">Server mode (2+ GPUs, scalable)
# Terminal 1: Start vLLM inference server
CUDA_VISIBLE_DEVICES=0 trl vllm-serve --model Qwen/Qwen3-1.7B --host 0.0.0.0 --port 8000
# Terminal 2: Run GRPO training with OpenEnv
CUDA_VISIBLE_DEVICES=1 python examples/scripts/openenv/wordle.py --vllm-mode server --vllm-server-url http://localhost:8000
The model improves its performance by reducing repetitions and increasing correct guesses. However, Qwen3-1.7B with enable_thinking=False is not able to consistently win the game.
[!NOTE] With
enable_thinking=False(the default in these examples), small models like Qwen3-1.7B can learn to improve their guesses but should not be expected to consistently solve the game. For significantly better results, use larger models or enable thinking mode (enable_thinking=True), which allows the model to reason before making a guess at the cost of longer completions.
We experimented with larger models like gpt-oss-20b and found that it was able to consistently win the game, though this requires significantly more compute.
You can train a single model across multiple environments simultaneously. This is useful when you want a model to learn different skills in parallel. For example, playing Wordle (language reasoning) and Catch (spatial reasoning) in the same training run.
The key idea is to create a meta-environment class that wraps multiple environments and routes each sample to the correct one using a dataset column.
"env" column (or similar) to your dataset that identifies which environment each sample belongs to.reset(**kwargs), read kwargs["env"] to select the active environment for that episode.None for samples that don't belong to that environment. TRL handles None values with nansum/nanmean.The multi_env.py script trains on Wordle and Catch simultaneously:
class MultiEnv:
def __init__(self):
self._wordle_client = None
self._catch_client = None
self.active = None
self.reward = 0.0
self.done = False
def reset(self, **kwargs) -> str | None:
self.active = kwargs.get("env", "wordle")
self.reward = 0.0
self.done = False
if self.active == "wordle":
if self._wordle_client is not None:
try:
self._wordle_client.close()
except Exception:
pass
self._wordle_client = TextArenaEnv(base_url=WORDLE_URL)
result = self._wordle_client.reset()
self._last_full_feedback = result.observation.messages[0].content
self.reward = 0.0
return self._last_full_feedback
elif self.active == "catch":
if self._catch_client is not None:
try:
self._catch_client.close()
except Exception:
pass
self._catch_client = OpenSpielEnv(base_url=CATCH_URL)
result = self._catch_client.reset()
self.done = result.observation.done
return _format_catch_obs(result.observation.info_state)
# Wordle tool
def guess(self, guess: str) -> str:
"""Make a guess in the Wordle environment. ..."""
...
# Catch tools
def move(self, direction: str) -> str:
"""Move the paddle left or right. ..."""
...
def stay(self) -> str:
"""Do nothing and let the ball fall one step. ..."""
...
Key patterns:
reset(), not __init__(), to avoid unnecessary WebSocket connections.kwargs routing: The "env" column from the dataset is passed to reset() as a keyword argument.guess, move, and stay as available tools regardless of the active environment. If it calls the wrong tool (e.g., move during Wordle), the method raises a ValueError that the trainer catches gracefully. In practice, models learn to use the correct tools based on the system prompt.Each reward function returns None for samples from other environments:
def wordle_reward(environments, **kwargs) -> list[float | None]:
return [env.reward if env.active == "wordle" else None for env in environments]
def catch_reward(environments, **kwargs) -> list[float | None]:
rewards = []
for env in environments:
if env.active != "catch":
rewards.append(None)
elif env.done:
rewards.append(max(env.reward, 0.0))
else:
rewards.append(0.0)
return rewards
TRL converts None to nan internally and uses nansum/nanmean for aggregation, so each sample is only scored by its relevant reward function.
n = 500
dataset = Dataset.from_dict({
"prompt": (
[[{"role": "user", "content": wordle_prompt}]] * n
+ [[{"role": "user", "content": catch_prompt}]] * n
),
"env": ["wordle"] * n + ["catch"] * n,
})
python examples/scripts/openenv/multi_env.py \
--wordle-url https://openenv-wordle.hf.space \
--catch-url https://openenv-openspiel-env.hf.space \
--vllm-mode colocate \
--gradient-accumulation-steps 4 \
--num-generations 8
[!TIP] When training across multiple environments, monitor the per-reward-function metrics (
train/reward_func_0,train/reward_func_1, etc.) rather than the combinedtrain/reward. The combined metric alternates between environments and can appear noisy.
When using environment_factory, the trainer connects to the environment server automatically. You just need the server to be running. There are three ways to run an OpenEnv environment server:
Connect to a remote Hugging Face Space (simplest)
Most example scripts default to a hosted Space (no setup needed):
env = EchoEnv(base_url="https://openenv-echo-env.hf.space")
</hfoption> <hfoption id="docker">[!WARNING] For training, duplicate the Space to your own account to avoid concurrency issues. The trainer opens N simultaneous WebSocket connections (one per generation), and shared Spaces may not support this. See Server concurrency for details.
Docker container (recommended for production)
docker run -d -p 8001:8000 --platform linux/amd64 registry.hf.space/openenv-echo-env:latest
Then connect:
env = EchoEnv(base_url="http://0.0.0.0:8001")
We map port 8001 to 8000 to leave port 8000 available for a vLLM server.
You can also start the container programmatically:
env = EchoEnv.from_docker_image("registry.hf.space/openenv-echo-env:latest")
</hfoption> <hfoption id="local">[!NOTE] You can find the Docker image for any Space on the Hub: open the Space page → ⋮ (three dots) → "Run locally."
Local Python process (for development)
hf download openenv/echo_env --repo-type=space --local-dir=echo_env
python -m uvicorn echo_env.src.envs.echo_env.server.app:app --host 0.0.0.0 --port 8001
Then connect:
env = EchoEnv(base_url="http://0.0.0.0:8001")
For more details, see the OpenEnv catalog.
</hfoption> </hfoptions>The best way to explore the current catalog of maintained environments is by visiting the official OpenEnv catalog.
To create your own environment, check out the guide on Building Your Own Environment with OpenEnv. Environments are tightly integrated with the Hub, so you can push new environments for the community to reuse.
When using environment_factory, the trainer creates N environment instances (one per generation), each opening a WebSocket connection to the server. By default, OpenEnv servers allow only 1 concurrent session, which will cause failures during training.
To support parallel training, configure the server for concurrency:
SUPPORTS_CONCURRENT_SESSIONS: bool = True
app = create_app(
create_my_environment,
MyAction,
MyObservation,
max_concurrent_envs=64, # match or exceed generation_batch_size
)
[!TIP]
max_concurrent_envsshould be ≥generation_batch_size(which defaults toper_device_train_batch_size × gradient_accumulation_steps). For example, withgradient_accumulation_steps=64and batch size 1, you need at least 64 concurrent sessions.
environment_factory vs rollout_func[GRPOTrainer] supports two approaches for environment-based training:
environment_factory (recommended): You define an environment class with tool methods, and the trainer handles generation, tool-call parsing, and the multi-turn loop automatically. This is the approach used throughout this guide.rollout_func: You write the entire generation and environment interaction loop yourself. This gives full control over how completions are produced, how tools are executed, and how rewards are computed.Use rollout_func when environment_factory doesn't fit your use case. For example, external agent servers where an external server owns the generation loop and manages its own agent-environment interaction protocol.
The integrations above are white-box: TRL drives the multi-turn loop itself. It samples each turn, parses the tool calls, runs them, and feeds the results back.
Some agents cannot be driven this way because they own their own loop. A production coding agent harness like opencode has its own planner, tool set, context management, and stop condition. You want to train that exact agent, not a reimplementation of it.
For this, TRL provides an experimental black box (loop-owning) path built on [experimental.async_grpo.AsyncGRPOTrainer] and a HarnessRolloutWorker specific for OpenEnv that drives an OpenEnv ResourceSessionFactory. See examples/scripts/openenv/opencode.py for a complete, self-contained example. To scale rollouts beyond a single node, examples/scripts/openenv/opencode_hf_sandbox.py runs each rollout in its own remote Hugging Face sandbox instead of a local subprocess.
TRL does not sample each turn here. The agent runs to completion on its own, and TRL reads back what it did:
transparent_proxy mode. A small proxy inside the sandbox forwards the agent's /v1/chat/completions calls to your vLLM server and records each turn's token ids and logprobs.verify() method (a held-out verifier).Each rollout runs in its own isolated session. In the example that means one sandbox directory, one proxy on its own port, and one agent process per rollout. The isolation matters for two reasons: the proxy has to capture exactly that rollout's tokens, and one rollout must not interfere with another. The max_inflight_tasks setting controls how many rollouts run at the same time.
You pass a HarnessRolloutWorker to [experimental.async_grpo.AsyncGRPOTrainer] with harness_adapter=None to select loop-owning mode. Besides the usual training arguments, you provide three functions (rollout_reward_fn, train_turn_fn, and agent_turn_fn) that tell TRL how to score, filter, and read the agent's rollouts. They are described in What you need to define.
from trl.experimental.async_grpo import AsyncGRPOConfig, AsyncGRPOTrainer
from trl.experimental.async_grpo.openenv_harness import HarnessRolloutWorker, has_tool_call
worker = HarnessRolloutWorker(
harness_session_factory=build_factory(...), # your OpenEnv ResourceSessionFactory
harness_adapter=None, # loop-owning: the agent runs its own loop
rollout_reward_fn=my_reward, # outcome -> float | None
train_turn_fn=has_tool_call, # reinforce only action turns
agent_turn_fn=my_agent_turns, # drop auxiliary (non-agent) calls
model_name=model,
dataset=dataset,
reward_funcs=[],
processing_class=tokenizer,
num_generations=8,
max_inflight_tasks=8,
vllm_server_url=vllm_url,
)
trainer = AsyncGRPOTrainer(model=model, args=config, train_dataset=dataset, rollout_worker=worker)
trainer.train()
In loop-owning mode the agent runs the loop and TRL only sees a raw trace of the LLM calls it made. TRL takes care of everything mechanical: it drives the agent, captures the trace, rebuilds the training rows from the recorded token ids, keeps the policy in sync with vLLM, and runs the GRPO update. But three decisions depend on your task and your agent, and TRL cannot make them for you. You supply each as a small function. Every function receives a typed input and has a default, so you only write the ones your task needs.
rollout_reward_fn: how to score a rolloutCallable[[HarnessRolloutOutcome], float | None]
TRL does not know what success means for your task, so you turn each finished rollout into a scalar reward. The function receives a HarnessRolloutOutcome that describes what the agent did:
env_reward (float | None): the reward from the session's verify() (for opencode, the fraction of held-out tests that passed), or None when the rollout could not be scored.completion (list[dict]): the final message transcript.trace (list[TraceEntry]): the raw proxy trace.tool_call_count and tool_failure_count (int): how many tool calls the agent made, and how many looked like failures.tool_calls_by_name (dict[str, int]): calls per tool, for example {"bash": 3, "edit": 2}.timed_out (bool): whether the agent ran out of its time budget.Return a float, or return None to mark the rollout unscorable so it is dropped from the group baseline instead of being counted as a zero. If you do not pass this function, the raw env_reward is used as is. The opencode example turns the dense pass fraction into a binary pass or fail and subtracts small penalties for degenerate behavior, such as never running the code or looping for far too many steps.
[!NOTE] Why not just use
verify()?verify()is the environment's job and answers one question, "how correct was the outcome," which keeps it clean and reusable for evaluation. The reward you train on is a separate, training-time decision (binarize the score, penalize degenerate behavior, drop unscorable rollouts). It also needs signalsverify()never sees, sinceverify()only inspects the final workspace, whilerollout_reward_fnalso gets the trajectory (tool counts,timed_out, the trace). For example, a rollout can pass some tests yet never runbash; onlyrollout_reward_fncan see that and penalize it.
train_turn_fn: which turns to reinforceCallable[[HarnessTurn], bool]
A rollout is made of many turns, and not every turn should receive a gradient. This function is called once per turn and returns True to train on that turn. It receives a HarnessTurn:
messages (list[dict]): the conversation the model saw on this turn.tools (list[dict] | None): the tools available on this turn.content (str): the assistant's text for this turn.tool_calls (list[dict]): the tool calls the assistant emitted (empty for a text-only turn).By default every agent turn is trained. For a tool-heavy agent you usually want to reinforce only the turns that took an action. TRL ships has_tool_call for this, which is what the opencode example uses:
def has_tool_call(turn: HarnessTurn) -> bool:
# Train only turns where the agent actually called a tool (e.g. wrote a file, ran bash),
# and skip pure-text turns like a final "done" message or thinking-out-loud.
return bool(turn.tool_calls)
An agent whose answer is plain text, such as a question-answering agent, would keep the default (train every turn) instead.
agent_turn_fn: which trace entries are real agent turnsCallable[[list[TraceEntry]], list[TraceEntry]]
Agent frameworks make extra LLM calls that are not part of the task. opencode, for example, fires a separate call to generate a thread title and another to summarize context. These land in the same proxy trace as the real agent turns, but they must never be trained, scored, or shown as the transcript, because they answer a different prompt. This function receives the full trace and returns only the entries that are real agent turns. Each entry is a TraceEntry, the raw record of one LLM call (request, response, completion_token_ids, per_token_logps). By default TRL keeps every entry that has both a request and a response; the opencode example overrides this to keep only the calls that carry the agent's own system prompt and tools, which drops the title and summary calls.
The vLLM server must expose tool-calling and real token ids, and enable NCCL weight sync so the agent always hits the current policy:
vllm serve <model> \
--enable-auto-tool-choice --tool-call-parser hermes \
--logprobs-mode processed_logprobs \
--return-tokens-as-token-ids \
--weight-transfer-config '{"backend":"nccl"}'
[!NOTE] Loop-owning training lives under
trl.experimentaland its API may change. The example installs theopencodeCLI into a sandbox template on first run (needs internet once) and usesagentica-org/DeepCoder-Preview-Datasetwith a held-out stdin/stdout verifier.