Skip to content

Commit 9f37336

Browse files
SandyChapmanclaude
andcommitted
docs(evaluator): document the Gym runner and fix the runner protocol
The Gym runner shipped with no documentation. "Gym" appeared nowhere in `docs/`, even though `GymAgentTaskRunner` sits alongside the Callable and Harbor runners in the SDK and `GymRunnerTarget` alongside Codex, Fabric, and Harbor in the job spec. Targets and Runners was also wrong about the one thing readers copy from it. `AgentTaskRunner` is a two-member protocol -- `run_tasks` *and* `runner_info` -- but the page called it "the one-method protocol", showed only `run_tasks`, and its `EchoRunner` example omitted `runner_info`. That example does not work: being `@runtime_checkable`, the protocol rejects the class, and the run dies with `NotImplementedError: unsupported agent-eval target type: EchoRunner`, naming neither the protocol nor the missing method. Fixed, and the failure mode is now stated so the error is searchable. Adds "Evaluate a NeMo Gym Environment" beside the Harbor page, which had the same shape already: an example README in `examples/gym/` and no doc. It covers install and the `PATH` constraint, credentials, task discovery, the config reference, results, output directories, the two-step Gym invocation, and submission as a platform job. Everything here was executed rather than read. A throwaway venv with `nemo-gym` installed ran three live evaluations against mcqa, which caught four errors in my own draft: `agent_config` was `configs/simple_agent.yaml` where the real value is `responses_api_agents/simple_agent/configs/simple_agent.yaml`; `result.summary.scores` does not iterate scores (`.scores.scores` does); `work_dir` needs a `Path`; and the page never showed how to set an output directory at all. The score names, the 0-100 vs 0-1 scale note, and the `gym_run/` artifact list are copied from real output. The taskset-submission block is executed verbatim in review too -- it stores five tasks from the bundled mcqa dataset and the job side rebuilds all five rows from them. The submission snippet was executed verbatim against #1315's branch, and two more traps came out of it: a task cannot be named after `task.id` (a 64-char hash starting with a digit, against a 63-char cap requiring a leading letter), and `GymRewardMetric` is not a built-in type so the inline packager rejects it. Two fixes outside the docs tree, both found while sourcing from the example: `examples/gym/README.md` said to install Gym "in the same environment as the SDK". The source says the opposite, and it is right -- Gym imports Ray at module load and nemo-platform excludes Ray by constraint. Its "Next steps" also linked `runtimes/gym_runtime.py`, which became the `runtimes/gym/` package; split into live links to `config.py` and `runtime.py`. The reuse guard's `FileExistsError` told the caller to "give each run a fresh output_dir". There is no such parameter -- it is `AgentEvalRunConfig.work_dir` -- so the message sent readers looking for an argument that does not exist. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Sandy Chapman <schapman@nvidia.com>
1 parent 42b4009 commit 9f37336

6 files changed

Lines changed: 416 additions & 13 deletions

File tree

Lines changed: 321 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,321 @@
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

Comments
 (0)