Environments (OpenEnv)¶
Every LLM-training environment in AgileRL is reached the same way: text in, text
out, through the OpenEnv API
(installed with the [llm] extra). A RolloutHarness drives the env in the
training process, or over a URL.
Two types¶
env_type is required. It is not inferred from the algorithm.
|
What it means |
|---|---|
|
The model generates and the environment scores it. A single-turn scored
task is |
|
Supervised, no generation. Requires |
The text interface¶
An environment is any object with two methods:
def reset(self, seed: int | None = None) -> tuple[str, dict]:
# returns (prompt_text, info)
...
def step(self, action: str) -> tuple[str, float, bool, bool, dict]:
# returns (observation_text, reward, terminated, truncated, info)
...
reset returns the first prompt; step receives the model’s decoded text and
returns the next observation, a reward, and whether the episode has ended. Optional
extras: a tools attribute, and a dataset_size property with extra reset
kwargs (covered below).
Each concurrent rollout gets its own env instance, created once and reused for the whole run. Over a URL, one server fronts the whole batch: each rollout opens its own WebSocket session and the server builds a fresh env for that session.
Writing one in Python¶
Pass the env to RolloutHarness.local.
It runs in the training process, with no HTTP:
from agilerl.llm_envs import RolloutHarness
class GuessEnv:
"""Guess-the-number: the model proposes a number; the env scores it."""
def __init__(self, target: int = 7, max_turns: int = 3) -> None:
self.target, self.max_turns, self._turn = target, max_turns, 0
def reset(self, seed: int | None = None) -> tuple[str, dict]:
self._turn = 0
return "Guess a number between 1 and 10.", {}
def step(self, action: str) -> tuple[str, float, bool, bool, dict]:
self._turn += 1
guess = int("".join(c for c in action if c.isdigit()) or 0)
correct = guess == self.target
done = correct or self._turn >= self.max_turns
feedback = "Correct!" if correct else ("Too high." if guess > self.target else "Too low.")
return feedback, float(correct), done, False, {}
env = RolloutHarness.local(GuessEnv(), tokenizer, max_turns=3)
max_turns bounds the episode; the env can still finish earlier by returning
terminated=True. For a batch, wrap the same call so each slot gets its own
instance:
env_factory = lambda: RolloutHarness.local(GuessEnv(), tokenizer, max_turns=3)
Naming one from a manifest¶
A rollout names exactly one source. That is what decides where it runs.
The manifest names |
The environment runs |
Started by |
|---|---|---|
|
in the training process |
the job |
|
in the training process |
the job |
|
wherever it already is |
someone else, beforehand |
Dataset rows plus a reward file¶
The model answers each labelled row once; a Python file scores it. This is still
env_type: rollout (the model generates). Full example:
configs/training/llm_finetuning/grpo.yaml.
environment:
env_type: rollout
dataset: Jiayi-Pan/Countdown-Tasks-3to4
columns:
nums: question
target: answer
rubric_file_path: reward.py
rubric_name: RUBRIC
prompt_template:
system_0: You are a helpful assistant.
user_1: Using each number in {question}, make an equation that equals {answer}.
max_reward: 2.0
train_test_split: 0.8
dataset is a HuggingFace id or a Parquet path. rubric_file_path also
accepts the alias reward_file_path. The reward file sits next to the
manifest (see docs/_static/examples/gsm8k-grpo/reward.py).
A Python entrypoint¶
entrypoint is module:attr or path/to/file.py:attr, and env_config
is its keyword arguments — so a library factory works unchanged:
environment:
env_type: rollout
entrypoint: gem:make
env_config:
env_id: game:GuessTheNumber-v0-easy
max_turns: 50
max_reward: 1.0
The same GuessEnv from above, as a file next to the run:
environment:
env_type: rollout
entrypoint: guess_env.py:GuessEnv
env_config:
target: 7
max_turns: 3
That env runs in the training process. Leaving max_turns out is allowed
here: it is probed off one throwaway env and cached. A remote env cannot be
probed, which is why env_url requires it.
Put a system prompt in env_config; RolloutHarness renders it as a leading
system message:
env_config:
env_id: game:GuessTheNumber-v0-easy
system_prompt: You are playing a guessing game. Reply with one number.
An already-running server¶
If the environment is already an OpenEnv server, point at its URL. Nothing is
started locally. max_turns is required. To stand the server up from an
entrypoint, use resolve_env() (same
entrypoint / env_config as a local run):
from agilerl.llm_envs.openenv_server import resolve_env
url, server = resolve_env(
"gem:make",
env_config={"env_id": "code:Taco8k", "sandbox_type": "bwrap"},
port=8000,
max_concurrent_envs=16,
)
environment:
env_type: rollout
env_url: http://127.0.0.1:8000
max_turns: 1
request_timeout_s: 600
Each rollout opens its own /ws session. The server’s max_concurrent_envs
must cover batch_size * group_size sessions plus one for the evaluation
env, which is built lazily at the first evaluation. request_timeout_s bounds
each message at 300 seconds by default (0 disables the bound).
Walkthrough: Serving Environments Remotely (WebSocket Sessions) — host a coding env on one machine and train on a GPU box.
Supervised (SFT / DPO)¶
No generation. objective picks the columns and the loss:
environment:
env_type: dataset
objective: sft
dataset: HumanLLMs/Human-Like-DPO-Dataset
response_column: chosen
train_test_split: 0.9
For DPO, objective: preference.
Declaring what the environment needs¶
If the entrypoint’s package is not installed, add env_packages:
environment:
env_type: rollout
entrypoint: gem:make
env_config:
env_id: game:GuessTheNumber-v0-easy
env_packages:
uv: [gem-llm==1.0.0]
max_turns: 50
Warning
env_packages installs into the interpreter training is running in.
The install runs only when the entrypoint cannot already be imported. There
is no prompt and no dry-run. Install the package yourself and leave the
field out if you would rather manage it.
Both uv: and pip: are installed with uv, so uv has to be on
PATH. Package names only; anything starting with - is rejected.
If the packages cannot resolve alongside the trainer’s own dependencies, the
install fails rather than half-upgrading the environment. Host it with
resolve_env() and point env_url
at the URL it returns.
Manifest fields¶
These are the names this package reads:
Field |
Default |
Meaning |
|---|---|---|
|
required |
|
|
|
HuggingFace id or Parquet path. Alias: |
|
|
Rename source columns (e.g. |
|
|
Reward / rubric file for a dataset-backed rollout. Alias:
|
|
|
Symbol in that file. Alias: |
|
|
Chat-template pieces rendered into the prompt on reset. |
|
|
Extra kwargs for |
|
|
Fraction of the dataset used for training. |
|
|
Maximum achievable reward, used for accuracy reporting. |
|
|
|
|
|
Keyword arguments for the entrypoint. |
|
|
|
|
probed |
Turn budget ( |
|
|
URL of an already-running OpenEnv server. |
|
|
MCP tool name; only applies with |
|
|
Field the env puts the model’s text in ( |
|
|
Field the observation’s text lives in. |
|
|
|
|
|
Per-message bound on a |
|
|
|
|
|
SFT completion column. |
Evaluation and grouped rollouts¶
A dataset-backed env exposes dataset_size and accepts two extra kwargs on
reset:
def reset(self, seed=None, *, row_index: int | None = None,
evaluation: bool | None = None) -> tuple[str, dict]:
# row_index selects a row; evaluation=True serves the held-out split
...
The collector draws one row per batch item and reuses it across that item’s
GRPO group, so grouped-advantage compares like with like. At evaluation it
passes evaluation=True and opens one extra session (the + 1 in
max_concurrent_envs). The env must be reproducible from
(seed, row_index).
Advantage granularity¶
On GRPO (and CISPO / GSPO), algorithm.advantage_granularity is
auto | trajectory | turn, default auto. trajectory is one
group-relative scalar per completion; turn normalises each turn’s reward
within the group; auto picks turn when the batch has per-turn rewards
and more than one turn. token is not a GRPO value. action_granularity
still aliases it.
LLMPPO and LLMREINFORCE still take {turn, token, auto}.
Observations that are not plain text¶
Third-party OpenEnv environments do not agree on where their observation’s text
lives — a coding env returns {"stdout": "...", "stderr": "", "exit_code": 0},
BrowserGym {"text": "..."}, OpenSpiel a numeric {"info_state": [...]}.
The harness renders the payload via an observation processor:
observation_fieldnames the field, when a lookup is enough.observation_processoris amodule:fn/path/to/file.py:fnthat turns the payload into prompt text.
environment:
env_type: rollout
env_url: https://my-openspiel-env.example.com
max_turns: 12
observation_processor: render_board.py:render
# render_board.py
def render(payload: dict) -> str:
return "Board state: " + ", ".join(str(v) for v in payload["info_state"])
Keep the processor a pure function of the payload; it runs on the collector’s I/O threads.
Note
observation_field and observation_processor are read by local
agilerl training. On Arena, have the
environment server render text itself.
Tools¶
An environment advertises tool schemas with a tools attribute (a list of
JSON schemas). AgileRL renders them into the chat template. There is no
separate tools config.
For an MCP tool instead of the plain text interface, pass
mcp_tool="<tool_name>" on RolloutHarness or in the manifest (with
env_url).
Lifecycle¶
The environment is built once per rollout and reused for the whole run.
close()(if the env has one) is called exactly once, on teardown — not per step.Closing a
RolloutHarnessends its WebSocket session or closes the in-process env.
Lower-level pieces¶
InProcessEnvClient— drives a local env’sreset/stepdirectly. WhatRolloutHarness.local()uses.RemoteEnvClient— one/wssession against a server, which backs each session with its own env.resolve_env— start a server from the sameentrypoint/env_configa local manifest would use. Returns(url, server).max_concurrent_envsgives each session its own env instance.
from agilerl.llm_envs.openenv import RemoteEnvClient
from agilerl.llm_envs.openenv_server import resolve_env
url, server = resolve_env(
"guess_env.py:GuessEnv",
env_config={"target": 7},
max_concurrent_envs=8,
)
client = RemoteEnvClient(url)
payload, _ = client.reset()
payload, reward, terminated, truncated, info = client.step("5")
client.close()
server.stop()
RolloutHarness.from_spec
picks the trainer-side transport: a URL → WebSocket client; a
package.module:EnvClass entrypoint → in-process. Use resolve_env when
you want that entrypoint hosted as a URL instead.
Training on Arena¶
This package’s manifest names a dataset, an entrypoint, or an env_url.
On Arena, a container or an external URL is an
environment version you pick in the UI, not a field this spec validates.
Unknown extra keys on environment are ignored.
Migrating from the pre-OpenEnv API¶
Retired keys are rejected rather than coerced, so a stale manifest fails at parse instead of quietly training something else.
Manifest keys:
Was |
Is now |
|---|---|
|
|
|
|
|
|
|
|
GRPO |
|
Python API:
Was |
Is now |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
There is no compatibility shim; the old names are gone.
Rollout data: the trajectory tensor on the wire is token_ids, not
completion_ids. A custom environment no longer subclasses anything —
reset and step, in text, are the whole interface.
See the API reference for the full signatures.