GRPO Environment Packages

View as Markdown

Package a GRPO reward environment as a FileSet. Job JSON, LoRA, metrics, and cluster flags are in GRPO and Reward Environments.

A GRPO job takes two FileSets:

FileSetpurposeContents
EnvironmentenvironmentCode, Gym YAML, and nemo-environment.yaml. No prompt JSONL.
Datasetdatasettraining.jsonl (required), validation.jsonl (optional).

Do not put .jsonl in the environment package. If a Gym config lists datasets[].jsonl_fpath, omit that block and the data/ directory; prompts belong in the dataset FileSet.

Prerequisites

Before packaging an environment, ensure you have:

  1. A NeMo Platform configured with platform.runtime: kubernetes — GRPO provisions a Ray cluster and has no local Docker fallback
  2. Sandboxed Gym enabled on the cluster by your platform operator (sandbox_cluster_capable and a job-storage PVC claim). Refer to Cluster prerequisites; installing OpenSandbox is covered in OpenSandbox
  3. The nemo CLI on your PATH, or a source checkout where uv run --package nmp-rl resolves
  4. The training image tag your cluster runs — vendoring a wheel closure requires the nemo-gym, ray, and openai versions it reports
  5. A machine with internet access for the packaging step. Training clusters consume uploaded FileSets only. For details on which environment type require internet access too, see details below.

Pick a format

You haveFormatconfig_paths live under
A Gym server tree, and the cluster can reach a package index at job startnative-v1responses_api_agents/, resources_servers/, or responses_api_models/
A Prime Intellect hub / verifiers environmentadapter-wheels-v1configs/
A Gym tree or custom servers with dependencies vendored in the packagewheels-v1Anywhere in the package

All three run on the same Gym runtime: the training image starts a package’s servers exactly as it starts Gym’s own. The format decides only where dependencies come from and where config_paths may live. It does not change what is supported.

Use pi-to-gym-conversion to produce adapter-wheels-v1. Set adapter.agent to verifiers_agent.

adapter-wheels-v1 requires network access when the job starts. The package wheels/ directory covers the hub environment; verifiers_agent also installs verifiers from GitHub. Enable NMP_RL_SANDBOX_ALLOW_INTERNET (or equivalent egress) for these jobs.

How Gym YAML names servers

Gym loads every file in config_paths and starts instances:

1<instance_name>: # unique at runtime; dataset agent_ref.name and YAML refs use this
2 <server_type>: # resources_servers | responses_api_agents | responses_api_models
3 <implementation>: # directory: {server_type}/{implementation}/
4 entrypoint: app.py

Refs are {type, name} where name is the instance, not the implementation folder. Dataset rows follow the same rule: agent_ref.name is an instance.

Instance and implementation are often spelled the same in Gym’s own configs (math_with_judge / math_with_judge), which is why the distinction is easy to miss. example_single_tool_call.yaml is the counter-example: instance example_single_tool_call_simple_agent, implementation simple_agent.

The job looks up {server_type}/{implementation}/ in the environment FileSet first, then in the Gym tree on the training image. A server directory counts only if it includes requirements.txt or pyproject.toml — never both. The process runs python app.py from that directory.

Server typeRole
resources_serversTools, state, and verify() reward. domain is required.
responses_api_agentsRollout loop (POST /v1/responses).
responses_api_modelsProxy to the job’s vLLM. Name the instance policy_model.

domain is validated against a closed set: math, coding, agent, knowledge, instruction_following, long_context, safety, games, translation, e2e, rlhf, other. An empty string, a missing value, or a typo makes the block an almost-server — Gym prints a warning banner and then raises AlmostServerError, aborting the run. It is not silently skipped. Agent and model blocks must not carry domain; Gym strips it.

You can reference simple_agent, vllm_model, and verifiers_agent without uploading their app.py — shipping only responses_api_agents/simple_agent/configs/*.yaml is correct and intended. Any other implementation must appear as {type}/{impl}/ in the FileSet (native-v1 and wheels-v1). Put YAML alone under configs/ only when every implementation is one of those image servers (adapter-wheels-v1).

Two layout mistakes fail in ways that are hard to read:

  • A custom {type}/{impl}/ directory without requirements.txt or pyproject.toml is not recognised as a server. If a Gym built-in shares the name, the job runs the built-in instead — training against the wrong environment with no error.
  • A pyproject.toml at the package root makes Gym treat the package as a Gym checkout and use its editable-install branch, which drops the automatic nemo-gym==<image version> pin. If the server’s requirements still carry Gym’s relative -e nemo-gym[dev] @ ../../ entry, pip then installs your package root as nemo-gym; without that entry the virtualenv simply has no nemo-gym. Keep the server’s original directory structure from the Gym checkout; do not repackage the environment as a setuptools distribution.

Include a policy_model definition in config_paths. Without it, Gym reports ServerRefNotFoundError (Available responses_api_models: (none)).

1policy_model:
2 responses_api_models:
3 vllm_model:
4 entrypoint: app.py
5 base_url: ${policy_base_url}
6 api_key: ${policy_api_key}
7 model: ${policy_model_name}
8 return_token_id_information: true
9 uses_reasoning_parser: true

${policy_base_url}, ${policy_api_key}, and ${policy_model_name} resolve to the job’s vLLM endpoint. List this file first in config_paths.

FormatPath
adapter-wheels-v1, wheels-v1configs/policy_model.yaml
native-v1responses_api_models/vllm_model/configs/policy_model.yaml

The native-v1 path only has to start with one of the three server-type prefixes; the folder underneath it is free. responses_api_models/policy_model/configs/policy_model.yaml — matching the instance — passes the same check. Either is fine, because Gym runs responses_api_models/vllm_model/ from the YAML body, not from the config file’s directory. Pick one convention and keep it: naming the folder after the implementation makes the on-disk tree mirror what actually runs.

Dependencies

Each Gym server installs into its own virtualenv when the job starts. There are two installs, and they behave differently — this is the most common source of confusion about offline runs.

  1. Gym builds the virtualenv from that server’s requirements.txt or pyproject.toml, plus nemo-gym at the training-image version and Gym’s pinned ray[default] and openai. The package’s wheels/ directory is offered to this step as a candidate pool (UV_FIND_LINKS) — a package index is still enabled, so anything the wheelhouse misses is fetched from it.
  2. The job then installs the package’s vendored closure into each agent and resources-server virtualenv with --no-index --find-links=wheels/. Fully offline, but only for distributions already in wheels/, and only after step 1 has succeeded.
FormatNetwork at job start
native-v1Required (package index)
wheels-v1Not required if wheels/ also satisfies step 1
adapter-wheels-v1Required (verifiers from GitHub)

For a wheels-v1 job to start with no egress, wheels/ must contain:

  • everything the server’s requirements.txt names, and their transitive dependencies;
  • nemo-gym at exactly the version the training image reports;
  • ray[default] and openai at the versions the image pins — the virtualenv is created with uv venv --seed, so nothing is inherited from the image.

Completeness of wheels/ is what makes a sandboxed run work; the format name alone does not.

Step 2 installs unpinned distribution names on purpose, so an already-installed package is a no-op. That means step 2 cannot correct a version that step 1 resolved from an index — another reason to make the wheelhouse complete rather than partial.

requirements.txt in a custom server directory

The file’s presence is what makes Gym treat the directory as a server. Its contents then build the virtualenv:

Where the server sitsWhat Gym runs
Inside a Gym checkoutuv pip install -r requirements.txt <pinned deps>
A staged FileSet (the normal case)nemo-gym==<image version> is prepended, the -e nemo-gym[dev] @ ../../ line is dropped, and the result is piped to uv pip install

That rewrite is why a Gym server directory copies cleanly out of the source tree. It is also why vendoring the image’s exact nemo-gym version matters: a mismatch means your wheel is ignored and the version is resolved from PyPI.

Do not ship an empty requirements.txt. The file must exist, but an empty one produces a virtualenv containing only nemo-gym, ray and openai — so any other import in app.py fails at the first rollout, long after the job started. List the server’s real dependencies; if it genuinely has none beyond nemo-gym, write a comment line rather than leaving the file blank.

adapter-wheels-v1

Convert on a machine with internet. Upload the result; training nodes do not fetch from the hub.

From a platform install, run pi-to-gym-conversion. From this repository:

$uv run --package nmp-rl pi-to-gym-conversion \
> --hub-id primeintellect/ascii-tree \
> --hub-version 0.1.5 \
> --out-dir ./ascii-tree-pkg \
> --dataset-dir ./ascii-tree-data \
> --validation-fraction 0.1

Pin --hub-version so the vendored release is reproducible.

FlagPurpose
--hub-idRequired for conversion (not for --validate-only)
--hub-versionHub package version
--out-dir / --dataset-dirEnvironment package and Gym JSONL
--vf-env-id / --vf-env-argsVerifiers load id (default: last segment of the hub slug) and JSON object of loader kwargs
--dataset-size / --validation-fractionRow cap (-1 = all); split in [0, 1) (0 = train only)
--wheels-dirExisting *.whl files instead of download (directory must be non-empty)
--validate-only <dir>Check package layout
--uploadCreate both FileSets (NMP_BASE_URL required). Default names: <slug>-env and <slug>-env-dataset

pi-to-gym-conversion vendors wheels for x86_64 today. On an arm64 cluster, build the closure yourself with the pip download command above and pass it via --wheels-dir.

ascii-tree-pkg/
nemo-environment.yaml
configs/policy_model.yaml
configs/verifiers_agent.yaml
wheels/*.whl
ascii-tree-data/
training.jsonl
validation.jsonl # when --validation-fraction > 0
1# nemo-environment.yaml
2format: adapter-wheels-v1
3config_paths:
4 - configs/policy_model.yaml
5 - configs/verifiers_agent.yaml
6adapter:
7 agent: verifiers_agent
8 agent_type: responses_api_agents
9metadata:
10 name: ascii-tree
11 hub_id: primeintellect/ascii-tree
12 vf_env_id: ascii-tree
13 adapter_agent: verifiers_agent

Set adapter.agent to verifiers_agent. Dataset rows use agent_ref.name: verifiers_agent and vf_env_id equal to metadata.vf_env_id.

configs/verifiers_agent.yaml sets max_tokens (default 8192). That value caps generation for this agent.

$uv run --package nmp-rl pi-to-gym-conversion --validate-only ./ascii-tree-pkg

This checks layout (manifest, config_paths, wheels/). It does not check wheel platform tags, whether the closure is complete, whether config_paths define the servers your rows route to, or dataset rows. Those surface at job start.

native-v1

Keep Gym directory names. Do not flatten the tree.

weather-env/
├── nemo-environment.yaml
├── resources_servers/
│ └── weather_verifier/
│ ├── app.py
│ ├── requirements.txt
│ └── configs/
│ └── weather_verifier.yaml
├── responses_api_agents/
│ └── simple_agent/
│ └── configs/
│ └── weather_agent.yaml
└── responses_api_models/
└── vllm_model/
└── configs/
└── policy_model.yaml

responses_api_agents/simple_agent/configs/ configures the image simple_agent. resources_servers/weather_verifier/ is your server, so it includes app.py and requirements.txt.

1format: native-v1
2config_paths:
3 - responses_api_models/vllm_model/configs/policy_model.yaml
4 - resources_servers/weather_verifier/configs/weather_verifier.yaml
5 - responses_api_agents/simple_agent/configs/weather_agent.yaml
6metadata:
7 name: weather-grpo
1# resources_servers/weather_verifier/configs/weather_verifier.yaml
2weather_verifier:
3 resources_servers:
4 weather_verifier:
5 entrypoint: app.py
6 domain: agent
7 description: Weather tool + verify
1# responses_api_agents/simple_agent/configs/weather_agent.yaml
2weather_simple_agent:
3 responses_api_agents:
4 simple_agent:
5 entrypoint: app.py
6 resources_server:
7 type: resources_servers
8 name: weather_verifier
9 model_server:
10 type: responses_api_models
11 name: policy_model

resources_servers/weather_verifier/app.py is a SimpleResourcesServer: one route per tool, plus the verify() that returns the reward.

1from fastapi import FastAPI
2from pydantic import BaseModel
3
4from nemo_gym.base_resources_server import (
5 BaseResourcesServerConfig,
6 BaseVerifyRequest,
7 BaseVerifyResponse,
8 SimpleResourcesServer,
9)
10
11
12class WeatherResourcesServerConfig(BaseResourcesServerConfig):
13 pass
14
15
16class GetWeatherRequest(BaseModel):
17 city: str
18
19
20class GetWeatherResponse(BaseModel):
21 city: str
22 weather_description: str
23
24
25class WeatherResourcesServer(SimpleResourcesServer):
26 config: WeatherResourcesServerConfig
27
28 def setup_webserver(self) -> FastAPI:
29 app = super().setup_webserver()
30 app.post("/get_weather")(self.get_weather)
31 return app
32
33 async def get_weather(self, body: GetWeatherRequest) -> GetWeatherResponse:
34 return GetWeatherResponse(city=body.city, weather_description=f"The weather in {body.city} is cold.")
35
36 async def verify(self, body: BaseVerifyRequest) -> BaseVerifyResponse:
37 # Score the rollout in `body` and return the reward. 1.0 is a placeholder.
38 return BaseVerifyResponse(**body.model_dump(), reward=1.0)
39
40
41if __name__ == "__main__":
42 WeatherResourcesServer.run_webserver()

resources_servers/weather_verifier/requirements.txt lists anything else app.py imports. nemo-gym is prepended for you, so a server with no other dependencies needs only a comment line — but not an empty file.

Set dataset agent_ref.name to weather_simple_agent, the agent instance.

wheels-v1

Use the same server directories as native-v1, and add wheels/ so job start does not need a package index. config_paths may list files under configs/ or under the server trees. Custom implementations need {type}/{impl}/ with app.py and requirements.txt.

weather-env/
├── nemo-environment.yaml
├── configs/
│ ├── policy_model.yaml
│ ├── weather_verifier.yaml
│ └── weather_agent.yaml
├── resources_servers/
│ └── weather_verifier/
│ ├── app.py
│ └── requirements.txt
└── wheels/
├── nemo_gym-<image-version>-*.whl
└── … # match the training nodes' arch; CPython 3.13; one version per project

Wheels must match the architecture of the nodes that run GRPO training, not the machine that builds the package. The training images are published for both linux/amd64 and linux/arm64, so there is no single correct answer — check what your cluster runs and target that:

$kubectl get nodes -o jsonpath='{.items[*].status.nodeInfo.architecture}' # amd64 or arm64
$ARCH=x86_64 # amd64 nodes
$# ARCH=aarch64 # arm64 nodes

The interpreter is Python 3.13 on every architecture. A closure resolved for the wrong architecture passes --validate-only, which checks layout rather than wheel tags, and then fails on the cluster with has no wheels with a matching platform tag. Three versions must match what the Gym host process reports: nemo-gym, which Gym pins each virtualenv to, and ray[default] / openai, which it appends to every install. Gym runs from its own actor virtualenv under /opt/ray_venvs, so read them from there:

$IMAGE=<training-image>
$read GYM_VERSION RAY_VERSION OPENAI_VERSION < <(docker run --rm "$IMAGE" sh -c '
> PY=$(ls -d /opt/ray_venvs/*NemoGym*/bin/python 2>/dev/null | head -1)
> "${PY:-python}" -c "import importlib.metadata as m; print(m.version(\"nemo-gym\"), m.version(\"ray\"), m.version(\"openai\"))"')
$
$mkdir -p weather-env/wheels
$pip download --dest weather-env/wheels \
> --only-binary=:all: \
> --python-version 3.13 \
> --platform "manylinux_2_39_$ARCH" \
> --platform "manylinux_2_28_$ARCH" \
> --platform "manylinux_2_17_$ARCH" \
> --platform "manylinux2014_$ARCH" \
> "nemo-gym==$GYM_VERSION" "ray[default]==$RAY_VERSION" "openai==$OPENAI_VERSION" \
> -r resources_servers/weather_verifier/requirements.txt
$uv run --package nmp-rl pi-to-gym-conversion --validate-only ./weather-env

--platform is repeated because pip matches these tags literally rather than expanding a compatibility range.

--validate-only does not check wheel tags. Empty wheels/ and rebuild it so you do not ship two versions of the same project.

If the environment must run a NeMo Gym fork rather than the published package, pip download nemo-gym==X fetches the upstream release at that version string. Build the wheel from the fork checkout instead (uv build --wheel <gym-root>) and confirm the built version matches what the image reports — Gym pins each virtualenv to that version, so a mismatch silently falls back to PyPI.

Dataset rows

1{
2 "responses_create_params": {
3 "input": [{"role": "user", "content": "what's it like in sf?"}]
4 },
5 "agent_ref": {
6 "type": "responses_api_agents",
7 "name": "weather_simple_agent"
8 }
9}

Hub conversions also set vf_env_id, task_idx, and agent_ref.name: verifiers_agent. Extra fields are passed to the resources server.

When the agent is simple_agent and the environment exposes function-calling tools, each row must also declare them under responses_create_params.toolssimple_agent reads the tool list from the row, not from the resources server:

1{
2 "responses_create_params": {
3 "input": [{"role": "user", "content": "what's it like in sf?"}],
4 "tools": [{"type": "function", "name": "get_weather", "description": "",
5 "parameters": {"type": "object", "properties": {"city": {"type": "string"}},
6 "required": ["city"], "additionalProperties": false},
7 "strict": true}]
8 },
9 "agent_ref": {"type": "responses_api_agents", "name": "weather_simple_agent"}
10}

NeMo Gym’s own in-tree JSONL omits agent_ref, because Gym infers the agent from the config that owns the dataset. The platform requires it on every row. Add it when reusing Gym data.

Upload and submit

Keep relative paths. A trailing slash on nemo files upload ./pkg/ uploads the directory contents; without it, everything nests one level deeper under the directory’s basename and the manifest is no longer at the FileSet root. Run nemo files list to confirm before submitting.

--purpose environment is enforced at submit, not cosmetic: an environment FileSet whose purpose is anything but environment or generic is rejected.

$nemo files filesets create weather-env --workspace default --purpose environment --exist-ok
$nemo files upload ./weather-env/ weather-env --workspace default
$nemo files list weather-env --workspace default

Job JSON: GRPO and Reward Environments. Sandboxed Gym is configured on the platform (NMP_RL_SANDBOXED_GYM_DEFAULT), not in the job payload. Cluster setup: OpenSandbox.

Next Steps