diff --git a/packages/nemo_evaluator_sdk/examples/gym/README.md b/packages/nemo_evaluator_sdk/examples/gym/README.md index 5a5935a300..d1d5c6275f 100644 --- a/packages/nemo_evaluator_sdk/examples/gym/README.md +++ b/packages/nemo_evaluator_sdk/examples/gym/README.md @@ -95,10 +95,10 @@ The dataset handed to step 2 is not your source file. The runner **materializes* ### Logs -Gym's subprocess output is streamed to files in the run's work directory — `gym_env.log` for `gym env start`, and `gym_eval.stdout.log` / `gym_eval.stderr.log` for the collection — and mirrored to the `nemo_evaluator_sdk.agent_eval.runtimes.gym_runtime` logger at `DEBUG`. Startup and collection failures name the relevant files and inline the last lines. To watch Gym's output in your own terminal, turn that logger up: +Gym's subprocess output is streamed to files in the run's work directory — `gym_env.log` for `gym env start`, and `gym_eval.stdout.log` / `gym_eval.stderr.log` for the collection — and mirrored to the `nemo_evaluator_sdk.agent_eval.runtimes.gym` logger at `DEBUG`. Startup and collection failures name the relevant files and inline the last lines. To watch Gym's output in your own terminal, turn that logger up: ```python -logging.getLogger("nemo_evaluator_sdk.agent_eval.runtimes.gym_runtime").setLevel(logging.DEBUG) +logging.getLogger("nemo_evaluator_sdk.agent_eval.runtimes.gym").setLevel(logging.DEBUG) ``` ## Notes & caveats diff --git a/packages/nemo_evaluator_sdk/examples/gym/run_gym_eval.py b/packages/nemo_evaluator_sdk/examples/gym/run_gym_eval.py index 3fa81ea4ca..18ee968f0b 100644 --- a/packages/nemo_evaluator_sdk/examples/gym/run_gym_eval.py +++ b/packages/nemo_evaluator_sdk/examples/gym/run_gym_eval.py @@ -38,7 +38,7 @@ from pathlib import Path from nemo_evaluator_sdk.agent_eval.evaluator import AgentEvaluator -from nemo_evaluator_sdk.agent_eval.runtimes.gym_runtime import ( +from nemo_evaluator_sdk.agent_eval.runtimes.gym import ( GymAgentTaskRunner, GymRuntimeConfig, discover_gym_tasks, diff --git a/packages/nemo_evaluator_sdk/src/nemo_evaluator_sdk/agent_eval/runtimes/gym/__init__.py b/packages/nemo_evaluator_sdk/src/nemo_evaluator_sdk/agent_eval/runtimes/gym/__init__.py new file mode 100644 index 0000000000..6e63a9fb06 --- /dev/null +++ b/packages/nemo_evaluator_sdk/src/nemo_evaluator_sdk/agent_eval/runtimes/gym/__init__.py @@ -0,0 +1,84 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""NeMo Gym-backed :class:`AgentTaskRunner` for the agent-eval pipeline. + +Runs an *existing* NeMo Gym environment through its ``gym`` CLI and adapts the +rollout bundle into SDK :class:`AgentEvalTrial` objects, so an +:class:`AgentEvaluator` can score and report Gym runs through the same seam as +Harbor/Fabric/Model. Gym owns execution *and* scoring; this runtime imports an +existing Gym environment as-is — Gym is treated as a self-scoring engine and the +runtime only adapts its rollout bundle, it does not re-derive rewards. + +**Mapping** (Gym → Evaluator): one Gym dataset → one run; each distinct row → +one :class:`AgentEvalTask` (id = content hash of the row); each attempt +(``_ng_rollout_index``) → one :class:`AgentEvalTrial`; the per-attempt verifier +``reward`` → a :class:`GymRewardMetric` score. ``num_repeats=R`` therefore yields +up to R trials per task. Row duplication is *not* a way to ask for repeated +attempts — ``num_repeats`` is (see :func:`discover_gym_tasks`). + +**Attribution** is by ``_ng_task_index``, which this runtime *assigns* rather +than infers. Gym only auto-assigns an index when a row doesn't already carry one +(``rollout_collection._preprocess_rows_from_config``), and its own fallback +dedup keys off the **raw jsonl line text** — a rule we cannot reproduce from +parsed rows. So instead of guessing, :meth:`GymAgentTaskRunner.run_tasks` +materializes a normalized dataset (one line per requested task, ``_ng_task_index`` +stamped explicitly) and feeds *that* to Gym. Gym echoes the index back on every +rollout record, giving a total, order-independent ``index → task`` map. This also +means a caller can run a **subset** of tasks without Gym rolling out the rest. + +**Execution** is the two-step Gym flow (the one that reads a dataset directly +without triggering Gym's split-driven data-prep), preceded by a pre-flight: +``gym env validate`` merges the composed config and reports unset ``???`` values, +bad paths, and dangling cross-references without starting anything; then ``gym env +start`` brings up the resources-server + agent + model servers, and ``gym eval run +--no-serve --input `` collects rollouts against them. Both +commands receive the identical selection arguments, so what is validated is what +runs. The runtime shells out to the ``gym`` CLI on PATH, so this SDK never imports +``nemo_gym``. Subprocess +output is streamed to log files under the run's work dir *and* mirrored to this +module's logger at ``DEBUG``, so callers choose terminal visibility through +ordinary ``logging`` configuration. + +**Where Gym finds things.** NeMo Gym must be installed and its ``gym`` on PATH, +along with the target environment's own dependencies. Generally that means a +*separate* environment: Gym imports Ray at module load, and nemo-platform +excludes Ray by constraint over an unfixed CVE, so the two cannot share one. In a +job image the image owns PATH and this is unremarkable. There is deliberately no +config field naming a checkout, a venv, or a search root — these runner configs +become serialized job specs, and a local filesystem path means nothing on the +other side of that boundary. Environments themselves ship in the ``nemo-gym`` +wheel (``resources_servers`` and friends install beside ``nemo_gym``, configs and +example data included), so no checkout is needed to reach them. + +The subprocesses inherit this process's working directory, which is where Gym +looks for the gitignored ``env.yaml`` holding the collector's credentials before +falling back to its install root — so credentials never pass through this SDK. +Run from the directory holding that file; a Gym checkout there also has its +components take precedence, which is how you reach an environment the wheel does +not carry. + +**Boundaries**: the caller is responsible for a +Gym runtime whose deps are installed (each Gym env ships its own +``requirements.txt``), and for handing a *ready-to-run* dataset file (``--no-serve +--input`` bypasses Gym's prompt-templating/materialization). Service-side +provisioning (docker/k8s, Ray) is out of scope here — that is the plugin's job. + +A consequence of that bypass: an environment whose rows carry no rendered prompt +(``responses_create_params.input == []``, the prompt supplied by data-prep or by the +environment's own agent) is still supported — the row travels through this runtime intact +and the task simply has no ``inputs['instruction']``. See :func:`discover_gym_tasks`. +""" + +from nemo_evaluator_sdk.agent_eval.runtimes.gym.config import DEFAULT_REWARD_KEY, GymRuntimeConfig +from nemo_evaluator_sdk.agent_eval.runtimes.gym.dataset import discover_gym_tasks +from nemo_evaluator_sdk.agent_eval.runtimes.gym.results import GymRewardMetric +from nemo_evaluator_sdk.agent_eval.runtimes.gym.runtime import GymAgentTaskRunner + +__all__ = [ + "DEFAULT_REWARD_KEY", + "GymAgentTaskRunner", + "GymRewardMetric", + "GymRuntimeConfig", + "discover_gym_tasks", +] diff --git a/packages/nemo_evaluator_sdk/src/nemo_evaluator_sdk/agent_eval/runtimes/gym/config.py b/packages/nemo_evaluator_sdk/src/nemo_evaluator_sdk/agent_eval/runtimes/gym/config.py new file mode 100644 index 0000000000..21a3dabecd --- /dev/null +++ b/packages/nemo_evaluator_sdk/src/nemo_evaluator_sdk/agent_eval/runtimes/gym/config.py @@ -0,0 +1,260 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Run configuration, and the Hydra grammar it is serialized into. + +Gym is a Hydra application, so every setting reaches it as an ``++dotted.path=value`` argument. +That grammar is typed and unforgiving — quoting rules live here alongside the config model they +serialize, so the two cannot drift. Redaction lives here too: these values are recorded as run +provenance, and the override map is a free-form escape hatch a caller can put a credential into. +""" + +from __future__ import annotations + +import logging +import re +from collections.abc import Mapping +from pathlib import Path +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field + +logger = logging.getLogger(__name__) + + +DEFAULT_REWARD_KEY = "reward" +#: Gym's CLI, expected on PATH. Not configurable: these runner configs become serialized job specs, +#: and a path into somebody's venv is meaningless on the other side of that boundary. Note this name +#: is only ever *resolved*, never executed: :func:`_gym_executable` turns it into an absolute path +#: once, and that path is what the subprocesses run — so a child whose PATH differs from ours cannot +#: end up executing a different Gym. +_HYDRA_SUBDIR = "gym_hydra" +#: `gym env start`'s combined output, under the run's work dir. Named here because a *collection* +#: failure often has to point at it: the eval logs show the symptom, this shows the cause. +_SECRET_KEY_MARKERS = ("api_key", "apikey", "token", "secret", "password", "passwd", "credential") +#: Stand-in written in place of a redacted override value. +_REDACTED = "" + + +#: Dict keys Hydra reads back unchanged. Its ``dictKey`` rule accepts no quoting, so a key is +#: whatever the lexer makes of the bare text — this is deliberately narrower than what parses. +_HYDRA_DICT_KEY = re.compile(r"[A-Za-z_][A-Za-z0-9_.-]*\Z") +#: Bare words the lexer types rather than reading as text, so they cannot serve as string keys. +_HYDRA_KEY_LITERALS = frozenset({"true", "false", "null", "inf", "nan"}) + + +def _hydra_dict(value: Mapping[str, Any]) -> str: + """Render a mapping as a Hydra dict container, ``{key:value,...}``. + + Reached for a mapping nested inside a container — ``[{"b": 1}]`` — where there is no dotted path + to flatten onto, so the dict has to be spelled inline. Values recurse, so the typed spellings + below hold at any depth. + + Keys are emitted bare, because Hydra's ``dictKey`` rule has no quoted form: ``{'b':1}`` does not + parse at all. That leaves the key at the mercy of the lexer, which types it — ``{true:1}`` keys + on the boolean ``True``, ``{1.5:1}`` on a float — and rejects ``:``, ``,``, brackets, and quotes + outright. Anything outside the conservative shape above therefore raises here rather than + silently keying the config on something the caller did not write. + """ + rendered = [] + for key, item in value.items(): + if not isinstance(key, str) or not _HYDRA_DICT_KEY.match(key) or key.casefold() in _HYDRA_KEY_LITERALS: + raise ValueError( + f"Gym config override has dict key {key!r}, which Hydra's override grammar cannot " + "express as a string: keys are unquoted, so only a leading letter or underscore " + "followed by letters, digits, '_', '.', or '-' survives the round trip. Set this " + "key through the override path instead of nesting it inside a list." + ) + rendered.append(f"{key}:{_hydra_scalar(item)}") + return "{" + ",".join(rendered) + "}" + + +def _hydra_scalar(value: Any) -> str: + """Render a leaf value the way Hydra's override grammar reads it back. + + Hydra's grammar is typed, so an unquoted string is not necessarily a string: ``true`` parses as a + boolean, ``null`` as ``None``, ``1.5`` as a float, ``a,b`` as a *sweep*, and ``A[B`` fails to + parse outright. Strings are therefore always single-quoted, which round-trips every one of those + (verified against ``hydra.core.override_parser``). Interpolations survive quoting — the override + sets the literal text and OmegaConf resolves it on read — so ``${policy_base_url}`` still works. + + Only ``'`` is escaped. Hydra does **not** decode ``\\\\`` inside a quoted value: escaping + backslashes doubles them, so they are passed through raw. + + ``None`` and booleans get their own spellings, since ``str()`` would emit ``"None"``/``"True"`` + and Hydra reads those back as text. Containers recurse for the same reason: ``str()`` on a dict + emits Python's repr, whose quoted keys Hydra rejects outright. + """ + if value is None: + return "null" + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, Mapping): + return _hydra_dict(value) + if isinstance(value, (list, tuple)): + return "[" + ",".join(_hydra_scalar(item) for item in value) + "]" + if isinstance(value, str): + # A trailing backslash would escape the closing quote and leave the value unterminated, and + # there is no spelling that avoids it — better to say so than to emit something unparseable. + if value.endswith("\\"): + raise ValueError( + f"Gym config override value {value!r} ends with a backslash, which Hydra's override " + "grammar cannot express: it escapes the closing quote." + ) + return "'" + value.replace("'", "\\'") + "'" + return str(value) + + +def _flatten_overrides(overrides: Mapping[str, Any], _prefix: str = "") -> list[str]: + """Flatten a nested override mapping into Hydra ``++dotted.path=value`` arguments. + + Callers describe overrides as structured data — ``{"a": {"b": 1}}`` — rather than as + pre-serialized Hydra strings, so the config survives being sent somewhere as JSON. Hydra itself + only speaks the flat form, so the translation happens here, at the point of invocation. + + ``++`` rather than ``+``: it sets a key whether or not it already exists, which is what an + override means. A bare ``+`` fails on a key the merged config already defines. + """ + arguments: list[str] = [] + for key, value in overrides.items(): + path = f"{_prefix}{key}" + # An empty mapping has no leaves to descend to, so recursing would drop the override + # entirely. It is still a value the caller asked to set: emit it as ``++path={}``, which + # clears the subtree. + if isinstance(value, Mapping) and value: + arguments.extend(_flatten_overrides(value, f"{path}.")) + else: + arguments.append(f"++{path}={_hydra_scalar(value)}") + return arguments + + +def _redact_env_overrides(overrides: Mapping[str, Any], _prefix: str = "") -> dict[str, Any]: + """Redact credential-looking values from overrides before they are recorded as provenance. + + ``env_overrides`` is a free-form escape hatch forwarded to Gym, so nothing stops a caller passing + ``{"model": {"api_key": "sk-..."}}``. ``RunnerInfo.config`` is persisted into the run bundle, so a + value that looks like a credential must not be written there. + + The *key* is always kept — knowing that a run overrode ``model.api_key`` is useful provenance; + knowing the value is a leak. Matching is on the full dotted path, so a marker anywhere in it + redacts, and nesting cannot hide a credential behind an innocuous leaf name. + + Lists are walked too, since a mapping inside one — ``{"models": [{"api_key": "sk-..."}]}`` — + reaches Gym just as a nested mapping does. The index contributes no path segment: what marks a + value as a credential is the key it sits under, not where in a list it happens to fall. + """ + redacted: dict[str, Any] = {} + for key, value in overrides.items(): + path = f"{_prefix}{key}" + if isinstance(value, Mapping): + redacted[key] = _redact_env_overrides(value, f"{path}.") + elif any(marker in path.casefold() for marker in _SECRET_KEY_MARKERS): + redacted[key] = _REDACTED + elif isinstance(value, (list, tuple)): + redacted[key] = [_redact_list_item(item, path) for item in value] + else: + redacted[key] = value + return redacted + + +def _redact_list_item(item: Any, path: str) -> Any: + """Redact inside one element of a list-valued override. See :func:`_redact_env_overrides`.""" + if isinstance(item, Mapping): + return _redact_env_overrides(item, f"{path}.") + if isinstance(item, (list, tuple)): + return [_redact_list_item(nested, path) for nested in item] + return item + + +def _selection_args(config: GymRuntimeConfig, work_dir: Path) -> list[str]: + """The environment/agent/model selection passed to Gym. + + Built once and handed verbatim to both ``gym env validate`` and ``gym env start``, so what is + validated is exactly what runs — a pre-flight against a different config would be worse than + none. + """ + selection = [ + "--config", + config.agent_config, + "--model-type", + config.model_type, + "--resources-server", + config.resources_server, + ] + if config.bind_resources_server: + # Composable (Pattern-A) agents leave resources_server.name unbound ('???'); bind it to the + # env we're running. Assumes the agent config's top-level key equals the agent name (the + # simple_agent convention) *and* that the resources-server is registered under the + # environment's own name — not universally true, so self-contained or differently-named + # servers set bind_resources_server=False and bind themselves via env_overrides. + selection.append( + f"+{config.agent}.responses_api_agents.{config.agent}.resources_server.name={config.resources_server}" + ) + selection.extend(_flatten_overrides(config.env_overrides)) + # Gym is a Hydra app, so each invocation writes a timestamped run directory — by default + # `outputs//