|
| 1 | +--- |
| 2 | +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. |
| 3 | +# SPDX-License-Identifier: Apache-2.0 |
| 4 | + |
| 5 | +title: "Evaluate a NeMo Gym Environment" |
| 6 | +description: "Run an existing NeMo Gym environment through NeMo Evaluator's Gym runner — Gym collects rollouts against its own resources-server and agent, and the SDK adapts them into trials scored on Gym's reward." |
| 7 | +--- |
| 8 | + |
| 9 | +[NeMo Gym](https://github.com/NVIDIA-NeMo/Gym) is an environment framework for agentic rollouts: a |
| 10 | +**resources-server** provides the environment, an **agent** acts in it, and each rollout carries a |
| 11 | +**reward**. If you already have a Gym environment, the **Gym runner** runs it and scores its reward |
| 12 | +through agent-eval — the same [`AgentEvaluator`](/documentation/evaluate-models/agent-eval) and the |
| 13 | +same result and bundle as the [quickstart](/documentation/evaluate-models/agent-eval/quickstart). |
| 14 | +Only the runner changes. |
| 15 | + |
| 16 | +Gym owns execution **and** scoring here. The runner shells out to the `gym` CLI and adapts the |
| 17 | +rollout bundle into trials; `GymRewardMetric` surfaces Gym's per-attempt reward. |
| 18 | + |
| 19 | +<Warning> |
| 20 | + |
| 21 | +Like the Harbor runner, this one is **not** zero-dependency — it shells out to the `gym` CLI: |
| 22 | + |
| 23 | +- **NeMo Gym**, installed into its own `uv` environment (below) |
| 24 | +- **The target environment's own dependencies** — each resources-server ships its own |
| 25 | + `requirements.txt` (the `mcqa` example needs `tiktoken`) |
| 26 | +- **Model credentials** for the collector, in an `env.yaml` (below) |
| 27 | + |
| 28 | +```bash |
| 29 | +uv venv ~/gym-env --python 3.12 |
| 30 | +uv pip install --python ~/gym-env/bin/python nemo-gym tiktoken |
| 31 | +export PATH="$HOME/gym-env/bin:$PATH" |
| 32 | +``` |
| 33 | + |
| 34 | +Install Gym into **its own environment** and put that environment's `bin` on `PATH`. Gym imports Ray |
| 35 | +at module load, and `nemo-platform` excludes Ray by constraint, so the two generally cannot share a |
| 36 | +virtualenv. The runner resolves `gym` from `PATH` only — there is deliberately no setting pointing at |
| 37 | +a checkout or another venv, because this config becomes a serialized job spec when Gym runs as a |
| 38 | +platform job, and a local path means nothing on the other side of that boundary. In a job image, the |
| 39 | +image owns `PATH` and this resolves normally. |
| 40 | + |
| 41 | +</Warning> |
| 42 | + |
| 43 | +## Credentials |
| 44 | + |
| 45 | +Gym's collector calls your model endpoint directly. It reads the credentials from an `env.yaml` in |
| 46 | +the directory you run from — **this SDK never reads or handles that file**: |
| 47 | + |
| 48 | +```yaml |
| 49 | +policy_base_url: https://<your-openai-compatible-endpoint>/v1 |
| 50 | +policy_api_key: <key> |
| 51 | +policy_model_name: <model, e.g. meta/llama-3.1-8b-instruct> |
| 52 | +``` |
| 53 | +
|
| 54 | +Keep it out of version control. Gym searches the working directory first, then its install root. |
| 55 | +
|
| 56 | +## The dataset |
| 57 | +
|
| 58 | +A Gym dataset is a **jsonl file**, one row per case. Environments ship their example data inside the |
| 59 | +`nemo-gym` wheel, so the bundled `mcqa` benchmark needs no checkout: |
| 60 | + |
| 61 | +``` |
| 62 | +<site-packages>/resources_servers/mcqa/data/example.jsonl |
| 63 | +``` |
| 64 | +
|
| 65 | +`discover_gym_tasks` turns that file into tasks — one per **distinct** row: |
| 66 | +
|
| 67 | +```python |
| 68 | +from nemo_evaluator_sdk.agent_eval.runtimes.gym import GymRewardMetric, discover_gym_tasks |
| 69 | +
|
| 70 | +tasks = discover_gym_tasks("path/to/example.jsonl", metrics=[GymRewardMetric()]) |
| 71 | +``` |
| 72 | + |
| 73 | +Task identity is the row's **content hash**, which has two consequences worth knowing before you |
| 74 | +build a dataset: |
| 75 | + |
| 76 | +- Duplicate rows collapse into a single task, and the runner warns. Duplicates usually mean a data |
| 77 | + problem. |
| 78 | +- Repeating a row is **not** how you ask for repeated attempts. Use `num_repeats` — attempts are a |
| 79 | + run-level concern, not a dataset one. |
| 80 | + |
| 81 | +## Run it |
| 82 | + |
| 83 | +```python |
| 84 | +import asyncio |
| 85 | + |
| 86 | +from nemo_evaluator_sdk.agent_eval.evaluator import AgentEvaluator |
| 87 | +from nemo_evaluator_sdk.agent_eval.runtimes.gym import ( |
| 88 | + GymAgentTaskRunner, |
| 89 | + GymRewardMetric, |
| 90 | + GymRuntimeConfig, |
| 91 | + discover_gym_tasks, |
| 92 | +) |
| 93 | + |
| 94 | +tasks = discover_gym_tasks("path/to/example.jsonl", metrics=[GymRewardMetric()]) |
| 95 | + |
| 96 | +runner = GymAgentTaskRunner( |
| 97 | + config=GymRuntimeConfig( |
| 98 | + agent="simple_agent", |
| 99 | + agent_config="responses_api_agents/simple_agent/configs/simple_agent.yaml", |
| 100 | + resources_server="mcqa", |
| 101 | + num_repeats=2, |
| 102 | + ) |
| 103 | +) |
| 104 | + |
| 105 | +result = asyncio.run(AgentEvaluator().run(tasks=tasks, target=runner)) |
| 106 | +print(result.summary) |
| 107 | +``` |
| 108 | + |
| 109 | +Run it from the directory holding `env.yaml`. |
| 110 | + |
| 111 | +The mapping is: |
| 112 | + |
| 113 | +- one Gym dataset → one run |
| 114 | +- each distinct row → one task |
| 115 | +- each attempt → one trial |
| 116 | + |
| 117 | +So `num_repeats=2` over a 5-row dataset yields 5 tasks and 10 trials. |
| 118 | + |
| 119 | +### Configuration |
| 120 | + |
| 121 | +| Field | Required | Notes | |
| 122 | +|---|---|---| |
| 123 | +| `agent` | yes | agent name to collect rollouts with, e.g. `simple_agent` | |
| 124 | +| `agent_config` | yes | agent config passed to `gym env start`, resolved relative to the Gym install, e.g. `responses_api_agents/simple_agent/configs/simple_agent.yaml` | |
| 125 | +| `resources_server` | yes | resources-server (environment) name, e.g. `mcqa` | |
| 126 | +| `model_type` | no | `inference_provider` for OpenAI-compatible **chat** endpoints; `openai_model` uses the OpenAI **Responses API** and fails against chat-only endpoints | |
| 127 | +| `bind_resources_server` | no | auto-bind the agent's `resources_server.name` via a Hydra override, for a composable agent whose config leaves it unset (`simple_agent`). Set `False` for a self-contained agent that already binds its own | |
| 128 | +| `num_repeats` | no | attempts per row; each attempt becomes one trial | |
| 129 | +| `concurrency` | no | concurrent rollouts during collection — tune to your model endpoint's limits | |
| 130 | +| `hydra_params` | no | parameters merged into Gym's Hydra config, e.g. `{"model": {"temperature": 0.7}}` | |
| 131 | +| `env_vars` | no | environment variables set on the `gym` invocation | |
| 132 | +| `reward_key` | no | key read from each rollout record (default `reward`) | |
| 133 | +| `startup_timeout_s` | no | max wait for `gym env start` readiness | |
| 134 | +| `collection_timeout_s` | no | max wait for collection; `None` is unbounded | |
| 135 | +| `shutdown_grace_s` | no | grace period for the Gym subprocess group to exit on `SIGTERM`, letting Ray shut down cleanly, before escalating to `SIGKILL` | |
| 136 | + |
| 137 | +Anything `GymRuntimeConfig` does not expose can go through `hydra_params`, which is flattened to |
| 138 | +Hydra's override grammar and applied to `gym env start`. For the full set of knobs, see the |
| 139 | +[NeMo Gym documentation](https://github.com/NVIDIA-NeMo/Gym). |
| 140 | + |
| 141 | +## Read the results |
| 142 | + |
| 143 | +Gym's reward arrives as `gym_reward.reward`, and Gym's own aggregates are imported alongside the |
| 144 | +SDK's under a `runner.gym.*` prefix — the prefix is what tells you which side computed a number: |
| 145 | + |
| 146 | +```python |
| 147 | +for score in result.summary.scores.scores: |
| 148 | + print(score.name) |
| 149 | +# gym_reward.reward |
| 150 | +# gym_reward.reward.pass@1 |
| 151 | +# runner.gym.pass@1/accuracy |
| 152 | +# runner.gym.input_tokens |
| 153 | +``` |
| 154 | + |
| 155 | +Gym reports accuracy on a **0–100** scale where the SDK uses **0–1**, so `runner.gym.pass@1/accuracy` |
| 156 | +of `50.0` corresponds to a `gym_reward.reward` mean of `0.5`. Trials, scores, and the run bundle are |
| 157 | +otherwise read exactly as in |
| 158 | +[Reading Results](/documentation/evaluate-models/agent-eval/reading-results). |
| 159 | + |
| 160 | +## Output directories |
| 161 | + |
| 162 | +Each run writes to a fresh temporary directory by default. To choose one, set `work_dir` on the run |
| 163 | +config: |
| 164 | + |
| 165 | +```python |
| 166 | +from pathlib import Path |
| 167 | + |
| 168 | +from nemo_evaluator_sdk.agent_eval.tasks import AgentEvalRunConfig |
| 169 | + |
| 170 | +result = asyncio.run( |
| 171 | + AgentEvaluator().run( |
| 172 | + tasks=tasks, target=runner, config=AgentEvalRunConfig(work_dir=Path("gym-run-1")) |
| 173 | + ) |
| 174 | +) |
| 175 | +``` |
| 176 | + |
| 177 | +Give every run its own. The runner **refuses** to reuse a directory that already holds Gym rollout |
| 178 | +output, raising `FileExistsError`: Gym appends to its failures sidecar, so reusing one would mix two |
| 179 | +runs together, and the runner raises rather than clearing a previous run's results. |
| 180 | + |
| 181 | +Gym's own artifacts land in a `gym_run/` subdirectory — `rollouts.jsonl`, |
| 182 | +`rollouts_failures.jsonl`, `rollouts_aggregate_metrics.json`, and the materialized |
| 183 | +`gym_input.jsonl` handed to collection. |
| 184 | + |
| 185 | +## How it runs Gym |
| 186 | + |
| 187 | +The runner uses Gym's **two-step** flow, which reads a dataset file directly — no split-driven data |
| 188 | +preparation and no HuggingFace downloads: |
| 189 | + |
| 190 | +1. `gym env start …` brings up the resources-server, agent, and model servers. |
| 191 | +2. `gym eval run --no-serve --input <dataset> …` collects rollouts against them. |
| 192 | + |
| 193 | +The dataset handed to step 2 is **not** your source file. The runner materializes a normalized one |
| 194 | +into the run's work directory, one row per requested task, with `_ng_task_index` stamped explicitly. |
| 195 | +Gym honors a caller-supplied `_ng_task_index` and echoes it back on every rollout record, so rollouts |
| 196 | +join back to tasks through a map the runner owns rather than a guess about Gym's row ordering. That |
| 197 | +is also what lets you run a **subset** of tasks and roll out only that subset. |
| 198 | + |
| 199 | +### Logs |
| 200 | + |
| 201 | +Gym's subprocess output is streamed to files in the run's work directory — `gym_env.log` for startup, |
| 202 | +and `gym_eval.stdout.log` / `gym_eval.stderr.log` for collection — and mirrored to the |
| 203 | +`nemo_evaluator_sdk.agent_eval.runtimes.gym` logger at `DEBUG`. Startup and collection failures name |
| 204 | +the relevant file and inline its last lines. To watch Gym's output in your own terminal: |
| 205 | + |
| 206 | +```python |
| 207 | +import logging |
| 208 | + |
| 209 | +logging.getLogger("nemo_evaluator_sdk.agent_eval.runtimes.gym").setLevel(logging.DEBUG) |
| 210 | +``` |
| 211 | + |
| 212 | +## Submit as a platform job |
| 213 | + |
| 214 | +A Gym runner can be submitted as a durable platform job **from the live runner object**, rather than |
| 215 | +described a second time as a job spec — the configuration you validated locally is the configuration |
| 216 | +that runs. |
| 217 | + |
| 218 | +`submit` takes a stored taskset, so the Gym rows have to be stored first. A Gym taskset is not an |
| 219 | +ordinary one: the job rebuilds the Gym dataset from each task, so every task must carry the row that |
| 220 | +`discover_gym_tasks` split across `inputs['gym_row']` and `metadata['gym_row_extras']`. Build the |
| 221 | +tasks with `discover_gym_tasks` and store both halves: |
| 222 | + |
| 223 | +```python |
| 224 | +from nemo_evaluator.api.fields import MetadataItem, MetricInline |
| 225 | +from nemo_evaluator.api.schemas import ( |
| 226 | + EvaluatorTaskDefinition, |
| 227 | + TaskInput, |
| 228 | + TaskInputs, |
| 229 | + TaskRef, |
| 230 | + TasksetInput, |
| 231 | + TasksetRef, |
| 232 | +) |
| 233 | +from nemo_evaluator.shared.metric_bundles.bundles import bundle_metric |
| 234 | +from nemo_evaluator.shared.metric_bundles.cloudpickle import CloudpickleMetricBundlePackager |
| 235 | +from nemo_evaluator_sdk.agent_eval.runtimes.gym import ( |
| 236 | + GymAgentTaskRunner, |
| 237 | + GymRewardMetric, |
| 238 | + GymRuntimeConfig, |
| 239 | + discover_gym_tasks, |
| 240 | +) |
| 241 | +from nemo_platform import NeMoPlatform |
| 242 | + |
| 243 | +client = NeMoPlatform(base_url="http://localhost:8080", workspace="default") |
| 244 | + |
| 245 | +runner = GymAgentTaskRunner( |
| 246 | + config=GymRuntimeConfig( |
| 247 | + agent="simple_agent", |
| 248 | + agent_config="responses_api_agents/simple_agent/configs/simple_agent.yaml", |
| 249 | + resources_server="mcqa", |
| 250 | + ) |
| 251 | +) |
| 252 | + |
| 253 | +tasks = discover_gym_tasks("path/to/example.jsonl") |
| 254 | + |
| 255 | +# GymRewardMetric is not a built-in metric type, so it needs the cloudpickle packager. |
| 256 | +reward = MetricInline.model_validate( |
| 257 | + bundle_metric(GymRewardMetric(), CloudpickleMetricBundlePackager()).model_dump(mode="json") |
| 258 | +) |
| 259 | + |
| 260 | +names = [] |
| 261 | +for index, task in enumerate(tasks): |
| 262 | + name = f"mcqa-{index}" |
| 263 | + names.append(name) |
| 264 | + client.evaluator.tasks.create( |
| 265 | + name, |
| 266 | + task=TaskInput( |
| 267 | + spec=EvaluatorTaskDefinition( |
| 268 | + kind="evaluator", |
| 269 | + intent=task.intent, |
| 270 | + inputs=TaskInputs(**task.inputs), |
| 271 | + metrics=[reward], |
| 272 | + ), |
| 273 | + metadata=[MetadataItem(key=key, value=value) for key, value in task.metadata.items()], |
| 274 | + ), |
| 275 | + ) |
| 276 | + |
| 277 | +client.evaluator.tasksets.create("mcqa-suite", taskset=TasksetInput(tasks=[TaskRef(n) for n in names])) |
| 278 | + |
| 279 | +job = client.evaluator.submit(tasks=TasksetRef("mcqa-suite"), target=runner) |
| 280 | +job.wait_until_done() |
| 281 | +``` |
| 282 | + |
| 283 | +Two things that bite here: |
| 284 | + |
| 285 | +- **Do not name the task after `task.id`.** It is a 64-character content hash beginning with a digit, |
| 286 | + and entity names cap at 63 characters and must start with a letter. Derive a name, as above; the |
| 287 | + content hash stays the task's own `id`. |
| 288 | +- **`gym_row` rides on `inputs` and `gym_row_extras` on the task's `metadata`** — the field beside |
| 289 | + `spec`, not inside it. A task missing either is rejected job-side with `task '<id>' is missing |
| 290 | + inputs['gym_row'] and/or metadata['gym_row_extras']`. |
| 291 | + |
| 292 | +A value with no JSON form — a callable in `hydra_params`, say — is refused with |
| 293 | +`UnsubmittableRunnerError` at submit time, rather than failing inside the transport with an error |
| 294 | +that names neither the runner nor the field. |
| 295 | + |
| 296 | +The returned handle is an `AgentEvaluatorJobResource`. Unlike a dataset-driven job it has no |
| 297 | +`get_result()` or `download_artifacts()`, because an agent evaluation publishes agent-eval results |
| 298 | +and a summary rather than row scores. Read the scores through |
| 299 | +`client.evaluator.agent_eval_results`. |
| 300 | + |
| 301 | +Gym jobs run on their own `nmp-gym-tasks` container image rather than the shared CPU task image, |
| 302 | +because Gym requires Ray. No configuration is needed — the target selects it. |
| 303 | + |
| 304 | +## Caveats |
| 305 | + |
| 306 | +- **Per-environment dependencies are heterogeneous.** `mcqa` needs only `tiktoken`; other Gym |
| 307 | + environments pull in `torch`, COMET, a GPU, or Docker. Providing a Gym runtime with those installed |
| 308 | + is the caller's responsibility. |
| 309 | +- **`--no-serve --input` bypasses Gym's data-prep** — prompt templating and dataset materialization. |
| 310 | + Rows that are already complete, like the bundled `example.jsonl`, are faithful; an environment |
| 311 | + whose rows need templating would need that step run first. |
| 312 | +- **Service-side execution** — Docker or Kubernetes, Ray provisioning — is out of scope for this SDK |
| 313 | + path. That is the evaluator plugin's concern. |
| 314 | + |
| 315 | +## Next steps |
| 316 | + |
| 317 | +<Cards> |
| 318 | + <Card title="Agent Evaluation (concepts)" href="/documentation/evaluate-models/agent-eval" /> |
| 319 | + <Card title="Targets and Runners" href="/documentation/evaluate-models/agent-eval/targets-and-runners" /> |
| 320 | + <Card title="Evaluate a Harbor Task Suite" href="/documentation/evaluate-models/agent-eval/harbor-runner" /> |
| 321 | +</Cards> |
0 commit comments