Skip to content

Commit b5769cb

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 b5769cb

6 files changed

Lines changed: 381 additions & 13 deletions

File tree

Lines changed: 320 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,320 @@
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 |
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+
136+
Anything `GymRuntimeConfig` does not expose can go through `hydra_params`, which is flattened to
137+
Hydra's override grammar and applied to `gym env start`. For the full set of knobs, see the
138+
[NeMo Gym documentation](https://github.com/NVIDIA-NeMo/Gym).
139+
140+
## Read the results
141+
142+
Gym's reward arrives as `gym_reward.reward`, and Gym's own aggregates are imported alongside the
143+
SDK's under a `runner.gym.*` prefix — the prefix is what tells you which side computed a number:
144+
145+
```python
146+
for score in result.summary.scores.scores:
147+
print(score.name)
148+
# gym_reward.reward
149+
# gym_reward.reward.pass@1
150+
# runner.gym.pass@1/accuracy
151+
# runner.gym.input_tokens
152+
```
153+
154+
Gym reports accuracy on a **0–100** scale where the SDK uses **0–1**, so `runner.gym.pass@1/accuracy`
155+
of `50.0` corresponds to a `gym_reward.reward` mean of `0.5`. Trials, scores, and the run bundle are
156+
otherwise read exactly as in
157+
[Reading Results](/documentation/evaluate-models/agent-eval/reading-results).
158+
159+
## Output directories
160+
161+
Each run writes to a fresh temporary directory by default. To choose one, set `work_dir` on the run
162+
config:
163+
164+
```python
165+
from pathlib import Path
166+
167+
from nemo_evaluator_sdk.agent_eval.tasks import AgentEvalRunConfig
168+
169+
result = asyncio.run(
170+
AgentEvaluator().run(
171+
tasks=tasks, target=runner, config=AgentEvalRunConfig(work_dir=Path("gym-run-1"))
172+
)
173+
)
174+
```
175+
176+
Give every run its own. The runner **refuses** to reuse a directory that already holds Gym rollout
177+
output, raising `FileExistsError`: Gym appends to its failures sidecar, so reusing one would mix two
178+
runs together, and the runner raises rather than clearing a previous run's results.
179+
180+
Gym's own artifacts land in a `gym_run/` subdirectory — `rollouts.jsonl`,
181+
`rollouts_failures.jsonl`, `rollouts_aggregate_metrics.json`, and the materialized
182+
`gym_input.jsonl` handed to collection.
183+
184+
## How it runs Gym
185+
186+
The runner uses Gym's **two-step** flow, which reads a dataset file directly — no split-driven data
187+
preparation and no HuggingFace downloads:
188+
189+
1. `gym env start …` brings up the resources-server, agent, and model servers.
190+
2. `gym eval run --no-serve --input <dataset> …` collects rollouts against them.
191+
192+
The dataset handed to step 2 is **not** your source file. The runner materializes a normalized one
193+
into the run's work directory, one row per requested task, with `_ng_task_index` stamped explicitly.
194+
Gym honors a caller-supplied `_ng_task_index` and echoes it back on every rollout record, so rollouts
195+
join back to tasks through a map the runner owns rather than a guess about Gym's row ordering. That
196+
is also what lets you run a **subset** of tasks and roll out only that subset.
197+
198+
### Logs
199+
200+
Gym's subprocess output is streamed to files in the run's work directory — `gym_env.log` for startup,
201+
and `gym_eval.stdout.log` / `gym_eval.stderr.log` for collection — and mirrored to the
202+
`nemo_evaluator_sdk.agent_eval.runtimes.gym` logger at `DEBUG`. Startup and collection failures name
203+
the relevant file and inline its last lines. To watch Gym's output in your own terminal:
204+
205+
```python
206+
import logging
207+
208+
logging.getLogger("nemo_evaluator_sdk.agent_eval.runtimes.gym").setLevel(logging.DEBUG)
209+
```
210+
211+
## Submit as a platform job
212+
213+
A Gym runner can be submitted as a durable platform job **from the live runner object**, rather than
214+
described a second time as a job spec — the configuration you validated locally is the configuration
215+
that runs.
216+
217+
`submit` takes a stored taskset, so the Gym rows have to be stored first. A Gym taskset is not an
218+
ordinary one: the job rebuilds the Gym dataset from each task, so every task must carry the row that
219+
`discover_gym_tasks` split across `inputs['gym_row']` and `metadata['gym_row_extras']`. Build the
220+
tasks with `discover_gym_tasks` and store both halves:
221+
222+
```python
223+
from nemo_evaluator.api.fields import MetadataItem, MetricInline
224+
from nemo_evaluator.api.schemas import (
225+
EvaluatorTaskDefinition,
226+
TaskInput,
227+
TaskInputs,
228+
TaskRef,
229+
TasksetInput,
230+
TasksetRef,
231+
)
232+
from nemo_evaluator.shared.metric_bundles.bundles import bundle_metric
233+
from nemo_evaluator.shared.metric_bundles.cloudpickle import CloudpickleMetricBundlePackager
234+
from nemo_evaluator_sdk.agent_eval.runtimes.gym import (
235+
GymAgentTaskRunner,
236+
GymRewardMetric,
237+
GymRuntimeConfig,
238+
discover_gym_tasks,
239+
)
240+
from nemo_platform import NeMoPlatform
241+
242+
client = NeMoPlatform(base_url="http://localhost:8080", workspace="default")
243+
244+
runner = GymAgentTaskRunner(
245+
config=GymRuntimeConfig(
246+
agent="simple_agent",
247+
agent_config="responses_api_agents/simple_agent/configs/simple_agent.yaml",
248+
resources_server="mcqa",
249+
)
250+
)
251+
252+
tasks = discover_gym_tasks("path/to/example.jsonl")
253+
254+
# GymRewardMetric is not a built-in metric type, so it needs the cloudpickle packager.
255+
reward = MetricInline.model_validate(
256+
bundle_metric(GymRewardMetric(), CloudpickleMetricBundlePackager()).model_dump(mode="json")
257+
)
258+
259+
names = []
260+
for index, task in enumerate(tasks):
261+
name = f"mcqa-{index}"
262+
names.append(name)
263+
client.evaluator.tasks.create(
264+
name,
265+
task=TaskInput(
266+
spec=EvaluatorTaskDefinition(
267+
kind="evaluator",
268+
intent=task.intent,
269+
inputs=TaskInputs(**task.inputs),
270+
metrics=[reward],
271+
),
272+
metadata=[MetadataItem(key=key, value=value) for key, value in task.metadata.items()],
273+
),
274+
)
275+
276+
client.evaluator.tasksets.create("mcqa-suite", taskset=TasksetInput(tasks=[TaskRef(n) for n in names]))
277+
278+
job = client.evaluator.submit(tasks=TasksetRef("mcqa-suite"), target=runner)
279+
job.wait_until_done()
280+
```
281+
282+
Two things that bite here:
283+
284+
- **Do not name the task after `task.id`.** It is a 64-character content hash beginning with a digit,
285+
and entity names cap at 63 characters and must start with a letter. Derive a name, as above; the
286+
content hash stays the task's own `id`.
287+
- **`gym_row` rides on `inputs` and `gym_row_extras` on the task's `metadata`** — the field beside
288+
`spec`, not inside it. A task missing either is rejected job-side with `task '<id>' is missing
289+
inputs['gym_row'] and/or metadata['gym_row_extras']`.
290+
291+
A value with no JSON form — a callable in `hydra_params`, say — is refused with
292+
`UnsubmittableRunnerError` at submit time, rather than failing inside the transport with an error
293+
that names neither the runner nor the field.
294+
295+
The returned handle is an `AgentEvaluatorJobResource`. Unlike a dataset-driven job it has no
296+
`get_result()` or `download_artifacts()`, because an agent evaluation publishes agent-eval results
297+
and a summary rather than row scores. Read the scores through
298+
`client.evaluator.agent_eval_results`.
299+
300+
Gym jobs run on their own `nmp-gym-tasks` container image rather than the shared CPU task image,
301+
because Gym requires Ray. No configuration is needed — the target selects it.
302+
303+
## Caveats
304+
305+
- **Per-environment dependencies are heterogeneous.** `mcqa` needs only `tiktoken`; other Gym
306+
environments pull in `torch`, COMET, a GPU, or Docker. Providing a Gym runtime with those installed
307+
is the caller's responsibility.
308+
- **`--no-serve --input` bypasses Gym's data-prep** — prompt templating and dataset materialization.
309+
Rows that are already complete, like the bundled `example.jsonl`, are faithful; an environment
310+
whose rows need templating would need that step run first.
311+
- **Service-side execution** — Docker or Kubernetes, Ray provisioning — is out of scope for this SDK
312+
path. That is the evaluator plugin's concern.
313+
314+
## Next steps
315+
316+
<Cards>
317+
<Card title="Agent Evaluation (concepts)" href="/documentation/evaluate-models/agent-eval" />
318+
<Card title="Targets and Runners" href="/documentation/evaluate-models/agent-eval/targets-and-runners" />
319+
<Card title="Evaluate a Harbor Task Suite" href="/documentation/evaluate-models/agent-eval/harbor-runner" />
320+
</Cards>

0 commit comments

Comments
 (0)