diff --git a/environments/README.md b/environments/README.md
index 61261d7bc6..fd5eb3ff02 100644
--- a/environments/README.md
+++ b/environments/README.md
@@ -30,6 +30,7 @@ This folder contains installable example environments that showcase common usage
- **ToolEnv (native function-calling)**
- **tool_test**: Validates parallel tool calls and checks exact tool usage via `ToolRubric` + custom reward.
- **wiki_search**: Multi-tool retrieval (search/view/read) with `ToolEnv`; final judgment combined via `RubricGroup` with a `JudgeRubric`.
+ - **nemo_workplace_assistant**: NeMo Gym resource-server adapter example with dynamic per-row tools, session seeding, and `/verify`-based rewards.
### Sandboxes
- **PythonEnv (ipython-style REPL)**
diff --git a/environments/nemo_gym/nemo_arc_agi/nemo_arc_agi.py b/environments/nemo_gym/nemo_arc_agi/nemo_arc_agi.py
new file mode 100644
index 0000000000..a4ace0b355
--- /dev/null
+++ b/environments/nemo_gym/nemo_arc_agi/nemo_arc_agi.py
@@ -0,0 +1,23 @@
+from typing import Any
+
+import verifiers as vf
+from verifiers.envs.integrations.nemo_gym import (
+ NemoGymEnv,
+ _build_dataset,
+ _reward_from_verify,
+)
+
+
+def load_environment(
+ dataset_split: str = "example",
+ **kwargs: Any,
+) -> vf.Environment:
+ dataset, _ = _build_dataset(resource_server="arc_agi", dataset_split=dataset_split)
+ rubric = vf.Rubric(funcs=[_reward_from_verify], weights=[1.0])
+ return NemoGymEnv(
+ resource_server="arc_agi",
+ dataset=dataset,
+ rubric=rubric,
+ max_turns=1,
+ **kwargs,
+ )
diff --git a/environments/nemo_gym/nemo_arc_agi/pyproject.toml b/environments/nemo_gym/nemo_arc_agi/pyproject.toml
new file mode 100644
index 0000000000..15d30ae21e
--- /dev/null
+++ b/environments/nemo_gym/nemo_arc_agi/pyproject.toml
@@ -0,0 +1,24 @@
+[project]
+name = "nemo-arc-agi"
+description = "NeMo Gym arc_agi resource server (ARC-AGI visual grid pattern matching)"
+tags = ["nemo-gym", "knowledge", "single-turn", "sandbox"]
+version = "0.1.0"
+requires-python = ">=3.10"
+dependencies = [
+ "verifiers>=0.1.11.dev1",
+ "nemo-gym @ https://test-files.pythonhosted.org/packages/c0/58/451a826009a0b206c932e1ebde3dcff2a8b31152c77133fdde7e5f7ccd90/nemo_gym-0.2.9892rc0-py3-none-any.whl",
+]
+
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+[tool.hatch.build]
+include = ["nemo_arc_agi.py", "pyproject.toml"]
+
+[tool.hatch.metadata]
+allow-direct-references = true
+
+[tool.verifiers.eval]
+num_examples = 5
+rollouts_per_example = 1
diff --git a/environments/nemo_gym/nemo_code_gen/nemo_code_gen.py b/environments/nemo_gym/nemo_code_gen/nemo_code_gen.py
new file mode 100644
index 0000000000..c3cbe33994
--- /dev/null
+++ b/environments/nemo_gym/nemo_code_gen/nemo_code_gen.py
@@ -0,0 +1,30 @@
+from typing import Any
+
+import verifiers as vf
+from verifiers.envs.integrations.nemo_gym import (
+ NemoGymEnv,
+ _build_dataset,
+ _reward_from_verify,
+)
+
+
+def load_environment(
+ dataset_split: str = "example",
+ **kwargs: Any,
+) -> vf.Environment:
+ dataset, _ = _build_dataset(resource_server="code_gen", dataset_split=dataset_split)
+ rubric = vf.Rubric(funcs=[_reward_from_verify], weights=[1.0])
+ return NemoGymEnv(
+ resource_server="code_gen",
+ dataset=dataset,
+ rubric=rubric,
+ max_turns=1,
+ config_overrides={
+ "domain": "coding",
+ "num_processes": 8,
+ "unit_test_timeout_secs": 10,
+ "debug": False,
+ },
+ extra_pip_packages=["numpy==2.2.6"],
+ **kwargs,
+ )
diff --git a/environments/nemo_gym/nemo_code_gen/pyproject.toml b/environments/nemo_gym/nemo_code_gen/pyproject.toml
new file mode 100644
index 0000000000..d0a2bf4d26
--- /dev/null
+++ b/environments/nemo_gym/nemo_code_gen/pyproject.toml
@@ -0,0 +1,24 @@
+[project]
+name = "nemo-code-gen"
+description = "NeMo Gym code_gen resource server (competitive coding with Ray execution)"
+tags = ["nemo-gym", "coding", "single-turn", "sandbox"]
+version = "0.1.0"
+requires-python = ">=3.10"
+dependencies = [
+ "verifiers>=0.1.11.dev1",
+ "nemo-gym @ https://test-files.pythonhosted.org/packages/c0/58/451a826009a0b206c932e1ebde3dcff2a8b31152c77133fdde7e5f7ccd90/nemo_gym-0.2.9892rc0-py3-none-any.whl",
+]
+
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+[tool.hatch.build]
+include = ["nemo_code_gen.py", "pyproject.toml"]
+
+[tool.hatch.metadata]
+allow-direct-references = true
+
+[tool.verifiers.eval]
+num_examples = 5
+rollouts_per_example = 1
diff --git a/environments/nemo_gym/nemo_example_multi_step/nemo_example_multi_step.py b/environments/nemo_gym/nemo_example_multi_step/nemo_example_multi_step.py
new file mode 100644
index 0000000000..2505d60380
--- /dev/null
+++ b/environments/nemo_gym/nemo_example_multi_step/nemo_example_multi_step.py
@@ -0,0 +1,26 @@
+from typing import Any
+
+import verifiers as vf
+from verifiers.envs.integrations.nemo_gym import (
+ NemoGymEnv,
+ _build_dataset,
+ _reward_from_verify,
+)
+
+
+def load_environment(
+ dataset_split: str = "example",
+ max_turns: int = 8,
+ **kwargs: Any,
+) -> vf.Environment:
+ dataset, _ = _build_dataset(
+ resource_server="example_multi_step", dataset_split=dataset_split
+ )
+ rubric = vf.Rubric(funcs=[_reward_from_verify], weights=[1.0])
+ return NemoGymEnv(
+ resource_server="example_multi_step",
+ dataset=dataset,
+ rubric=rubric,
+ max_turns=max_turns,
+ **kwargs,
+ )
diff --git a/environments/nemo_gym/nemo_example_multi_step/pyproject.toml b/environments/nemo_gym/nemo_example_multi_step/pyproject.toml
new file mode 100644
index 0000000000..9773e8ad09
--- /dev/null
+++ b/environments/nemo_gym/nemo_example_multi_step/pyproject.toml
@@ -0,0 +1,24 @@
+[project]
+name = "nemo-example-multi-step"
+description = "NeMo Gym example_multi_step resource server (multi-tool synonym lookup)"
+tags = ["nemo-gym", "agent", "tools", "multi-turn", "sandbox"]
+version = "0.1.0"
+requires-python = ">=3.10"
+dependencies = [
+ "verifiers>=0.1.11.dev1",
+ "nemo-gym @ https://test-files.pythonhosted.org/packages/c0/58/451a826009a0b206c932e1ebde3dcff2a8b31152c77133fdde7e5f7ccd90/nemo_gym-0.2.9892rc0-py3-none-any.whl",
+]
+
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+[tool.hatch.build]
+include = ["nemo_example_multi_step.py", "pyproject.toml"]
+
+[tool.hatch.metadata]
+allow-direct-references = true
+
+[tool.verifiers.eval]
+num_examples = 5
+rollouts_per_example = 1
diff --git a/environments/nemo_gym/nemo_example_single_tool_call/nemo_example_single_tool_call.py b/environments/nemo_gym/nemo_example_single_tool_call/nemo_example_single_tool_call.py
new file mode 100644
index 0000000000..dbef56ea7d
--- /dev/null
+++ b/environments/nemo_gym/nemo_example_single_tool_call/nemo_example_single_tool_call.py
@@ -0,0 +1,26 @@
+from typing import Any
+
+import verifiers as vf
+from verifiers.envs.integrations.nemo_gym import (
+ NemoGymEnv,
+ _build_dataset,
+ _reward_from_verify,
+)
+
+
+def load_environment(
+ dataset_split: str = "example",
+ max_turns: int = 4,
+ **kwargs: Any,
+) -> vf.Environment:
+ dataset, _ = _build_dataset(
+ resource_server="example_single_tool_call", dataset_split=dataset_split
+ )
+ rubric = vf.Rubric(funcs=[_reward_from_verify], weights=[1.0])
+ return NemoGymEnv(
+ resource_server="example_single_tool_call",
+ dataset=dataset,
+ rubric=rubric,
+ max_turns=max_turns,
+ **kwargs,
+ )
diff --git a/environments/nemo_gym/nemo_example_single_tool_call/pyproject.toml b/environments/nemo_gym/nemo_example_single_tool_call/pyproject.toml
new file mode 100644
index 0000000000..95be44f8b2
--- /dev/null
+++ b/environments/nemo_gym/nemo_example_single_tool_call/pyproject.toml
@@ -0,0 +1,24 @@
+[project]
+name = "nemo-example-single-tool-call"
+description = "NeMo Gym example_single_tool_call resource server (simple weather tool)"
+tags = ["nemo-gym", "agent", "tools", "sandbox"]
+version = "0.1.0"
+requires-python = ">=3.10"
+dependencies = [
+ "verifiers>=0.1.11.dev1",
+ "nemo-gym @ https://test-files.pythonhosted.org/packages/c0/58/451a826009a0b206c932e1ebde3dcff2a8b31152c77133fdde7e5f7ccd90/nemo_gym-0.2.9892rc0-py3-none-any.whl",
+]
+
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+[tool.hatch.build]
+include = ["nemo_example_single_tool_call.py", "pyproject.toml"]
+
+[tool.hatch.metadata]
+allow-direct-references = true
+
+[tool.verifiers.eval]
+num_examples = 5
+rollouts_per_example = 1
diff --git a/environments/nemo_gym/nemo_instruction_following/nemo_instruction_following.py b/environments/nemo_gym/nemo_instruction_following/nemo_instruction_following.py
new file mode 100644
index 0000000000..11066225ac
--- /dev/null
+++ b/environments/nemo_gym/nemo_instruction_following/nemo_instruction_following.py
@@ -0,0 +1,29 @@
+from typing import Any
+
+import verifiers as vf
+from verifiers.envs.integrations.nemo_gym import (
+ NemoGymEnv,
+ _build_dataset,
+ _reward_from_verify,
+)
+
+
+def load_environment(
+ dataset_split: str = "example",
+ **kwargs: Any,
+) -> vf.Environment:
+ dataset, _ = _build_dataset(
+ resource_server="instruction_following", dataset_split=dataset_split
+ )
+ rubric = vf.Rubric(funcs=[_reward_from_verify], weights=[1.0])
+ return NemoGymEnv(
+ resource_server="instruction_following",
+ dataset=dataset,
+ rubric=rubric,
+ max_turns=1,
+ extra_pip_packages=[
+ "git+https://github.com/abukharin-nv/verifiable-instructions.git",
+ "https://github.com/explosion/spacy-models/releases/download/en_core_web_sm-3.8.0/en_core_web_sm-3.8.0-py3-none-any.whl",
+ ],
+ **kwargs,
+ )
diff --git a/environments/nemo_gym/nemo_instruction_following/pyproject.toml b/environments/nemo_gym/nemo_instruction_following/pyproject.toml
new file mode 100644
index 0000000000..fef3513277
--- /dev/null
+++ b/environments/nemo_gym/nemo_instruction_following/pyproject.toml
@@ -0,0 +1,24 @@
+[project]
+name = "nemo-instruction-following"
+description = "NeMo Gym instruction_following resource server (IFEval/IFBench instruction following)"
+tags = ["nemo-gym", "instruction-following", "single-turn", "sandbox"]
+version = "0.1.0"
+requires-python = ">=3.10"
+dependencies = [
+ "verifiers>=0.1.11.dev1",
+ "nemo-gym @ https://test-files.pythonhosted.org/packages/c0/58/451a826009a0b206c932e1ebde3dcff2a8b31152c77133fdde7e5f7ccd90/nemo_gym-0.2.9892rc0-py3-none-any.whl",
+]
+
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+[tool.hatch.build]
+include = ["nemo_instruction_following.py", "pyproject.toml"]
+
+[tool.hatch.metadata]
+allow-direct-references = true
+
+[tool.verifiers.eval]
+num_examples = 5
+rollouts_per_example = 1
diff --git a/environments/nemo_gym/nemo_math_advanced_calculations/nemo_math_advanced_calculations.py b/environments/nemo_gym/nemo_math_advanced_calculations/nemo_math_advanced_calculations.py
new file mode 100644
index 0000000000..819b885be4
--- /dev/null
+++ b/environments/nemo_gym/nemo_math_advanced_calculations/nemo_math_advanced_calculations.py
@@ -0,0 +1,26 @@
+from typing import Any
+
+import verifiers as vf
+from verifiers.envs.integrations.nemo_gym import (
+ NemoGymEnv,
+ _build_dataset,
+ _reward_from_verify,
+)
+
+
+def load_environment(
+ dataset_split: str = "example",
+ max_turns: int = 8,
+ **kwargs: Any,
+) -> vf.Environment:
+ dataset, _ = _build_dataset(
+ resource_server="math_advanced_calculations", dataset_split=dataset_split
+ )
+ rubric = vf.Rubric(funcs=[_reward_from_verify], weights=[1.0])
+ return NemoGymEnv(
+ resource_server="math_advanced_calculations",
+ dataset=dataset,
+ rubric=rubric,
+ max_turns=max_turns,
+ **kwargs,
+ )
diff --git a/environments/nemo_gym/nemo_math_advanced_calculations/pyproject.toml b/environments/nemo_gym/nemo_math_advanced_calculations/pyproject.toml
new file mode 100644
index 0000000000..a61dcd96c1
--- /dev/null
+++ b/environments/nemo_gym/nemo_math_advanced_calculations/pyproject.toml
@@ -0,0 +1,24 @@
+[project]
+name = "nemo-math-advanced-calculations"
+description = "NeMo Gym math_advanced_calculations resource server (counter-intuitive calculator tools)"
+tags = ["nemo-gym", "agent", "multi-turn", "sandbox"]
+version = "0.1.0"
+requires-python = ">=3.10"
+dependencies = [
+ "verifiers>=0.1.11.dev1",
+ "nemo-gym @ https://test-files.pythonhosted.org/packages/c0/58/451a826009a0b206c932e1ebde3dcff2a8b31152c77133fdde7e5f7ccd90/nemo_gym-0.2.9892rc0-py3-none-any.whl",
+]
+
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+[tool.hatch.build]
+include = ["nemo_math_advanced_calculations.py", "pyproject.toml"]
+
+[tool.hatch.metadata]
+allow-direct-references = true
+
+[tool.verifiers.eval]
+num_examples = 5
+rollouts_per_example = 1
diff --git a/environments/nemo_gym/nemo_math_with_code/nemo_math_with_code.py b/environments/nemo_gym/nemo_math_with_code/nemo_math_with_code.py
new file mode 100644
index 0000000000..0ec80bae04
--- /dev/null
+++ b/environments/nemo_gym/nemo_math_with_code/nemo_math_with_code.py
@@ -0,0 +1,28 @@
+from typing import Any
+
+import verifiers as vf
+from verifiers.envs.integrations.nemo_gym import (
+ NemoGymEnv,
+ _build_dataset,
+ _reward_from_verify,
+)
+
+
+def load_environment(
+ dataset_split: str = "example",
+ max_turns: int = 8,
+ **kwargs: Any,
+) -> vf.Environment:
+ dataset, _ = _build_dataset(
+ resource_server="math_with_code", dataset_split=dataset_split
+ )
+ rubric = vf.Rubric(funcs=[_reward_from_verify], weights=[1.0])
+ return NemoGymEnv(
+ resource_server="math_with_code",
+ dataset=dataset,
+ rubric=rubric,
+ max_turns=max_turns,
+ config_overrides={"max_execution_time": 10},
+ extra_pip_packages=["numpy", "scipy", "pandas"],
+ **kwargs,
+ )
diff --git a/environments/nemo_gym/nemo_math_with_code/pyproject.toml b/environments/nemo_gym/nemo_math_with_code/pyproject.toml
new file mode 100644
index 0000000000..ac80ca23bf
--- /dev/null
+++ b/environments/nemo_gym/nemo_math_with_code/pyproject.toml
@@ -0,0 +1,24 @@
+[project]
+name = "nemo-math-with-code"
+description = "NeMo Gym math_with_code resource server (math with Python code execution)"
+tags = ["nemo-gym", "math", "multi-turn", "sandbox"]
+version = "0.1.0"
+requires-python = ">=3.10"
+dependencies = [
+ "verifiers>=0.1.11.dev1",
+ "nemo-gym @ https://test-files.pythonhosted.org/packages/c0/58/451a826009a0b206c932e1ebde3dcff2a8b31152c77133fdde7e5f7ccd90/nemo_gym-0.2.9892rc0-py3-none-any.whl",
+]
+
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+[tool.hatch.build]
+include = ["nemo_math_with_code.py", "pyproject.toml"]
+
+[tool.hatch.metadata]
+allow-direct-references = true
+
+[tool.verifiers.eval]
+num_examples = 5
+rollouts_per_example = 1
diff --git a/environments/nemo_gym/nemo_mcqa/nemo_mcqa.py b/environments/nemo_gym/nemo_mcqa/nemo_mcqa.py
new file mode 100644
index 0000000000..2f3507becb
--- /dev/null
+++ b/environments/nemo_gym/nemo_mcqa/nemo_mcqa.py
@@ -0,0 +1,23 @@
+from typing import Any
+
+import verifiers as vf
+from verifiers.envs.integrations.nemo_gym import (
+ NemoGymEnv,
+ _build_dataset,
+ _reward_from_verify,
+)
+
+
+def load_environment(
+ dataset_split: str = "example",
+ **kwargs: Any,
+) -> vf.Environment:
+ dataset, _ = _build_dataset(resource_server="mcqa", dataset_split=dataset_split)
+ rubric = vf.Rubric(funcs=[_reward_from_verify], weights=[1.0])
+ return NemoGymEnv(
+ resource_server="mcqa",
+ dataset=dataset,
+ rubric=rubric,
+ max_turns=1,
+ **kwargs,
+ )
diff --git a/environments/nemo_gym/nemo_mcqa/pyproject.toml b/environments/nemo_gym/nemo_mcqa/pyproject.toml
new file mode 100644
index 0000000000..e2a3863968
--- /dev/null
+++ b/environments/nemo_gym/nemo_mcqa/pyproject.toml
@@ -0,0 +1,24 @@
+[project]
+name = "nemo-mcqa"
+description = "NeMo Gym mcqa resource server (MMLU/GPQA-style multiple choice QA)"
+tags = ["nemo-gym", "knowledge", "single-turn", "sandbox"]
+version = "0.1.0"
+requires-python = ">=3.10"
+dependencies = [
+ "verifiers>=0.1.11.dev1",
+ "nemo-gym @ https://test-files.pythonhosted.org/packages/c0/58/451a826009a0b206c932e1ebde3dcff2a8b31152c77133fdde7e5f7ccd90/nemo_gym-0.2.9892rc0-py3-none-any.whl",
+]
+
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+[tool.hatch.build]
+include = ["nemo_mcqa.py", "pyproject.toml"]
+
+[tool.hatch.metadata]
+allow-direct-references = true
+
+[tool.verifiers.eval]
+num_examples = 5
+rollouts_per_example = 1
diff --git a/environments/nemo_gym/nemo_structured_outputs/nemo_structured_outputs.py b/environments/nemo_gym/nemo_structured_outputs/nemo_structured_outputs.py
new file mode 100644
index 0000000000..529ea32605
--- /dev/null
+++ b/environments/nemo_gym/nemo_structured_outputs/nemo_structured_outputs.py
@@ -0,0 +1,26 @@
+from typing import Any
+
+import verifiers as vf
+from verifiers.envs.integrations.nemo_gym import (
+ NemoGymEnv,
+ _build_dataset,
+ _reward_from_verify,
+)
+
+
+def load_environment(
+ dataset_split: str = "example",
+ **kwargs: Any,
+) -> vf.Environment:
+ dataset, _ = _build_dataset(
+ resource_server="structured_outputs", dataset_split=dataset_split
+ )
+ rubric = vf.Rubric(funcs=[_reward_from_verify], weights=[1.0])
+ return NemoGymEnv(
+ resource_server="structured_outputs",
+ dataset=dataset,
+ rubric=rubric,
+ max_turns=1,
+ extra_pip_packages=["openapi-schema-validator==0.6.3"],
+ **kwargs,
+ )
diff --git a/environments/nemo_gym/nemo_structured_outputs/pyproject.toml b/environments/nemo_gym/nemo_structured_outputs/pyproject.toml
new file mode 100644
index 0000000000..ec474d9205
--- /dev/null
+++ b/environments/nemo_gym/nemo_structured_outputs/pyproject.toml
@@ -0,0 +1,24 @@
+[project]
+name = "nemo-structured-outputs"
+description = "NeMo Gym structured_outputs resource server (JSON schema validation)"
+tags = ["nemo-gym", "instruction-following", "single-turn", "sandbox"]
+version = "0.1.0"
+requires-python = ">=3.10"
+dependencies = [
+ "verifiers>=0.1.11.dev1",
+ "nemo-gym @ https://test-files.pythonhosted.org/packages/c0/58/451a826009a0b206c932e1ebde3dcff2a8b31152c77133fdde7e5f7ccd90/nemo_gym-0.2.9892rc0-py3-none-any.whl",
+]
+
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+[tool.hatch.build]
+include = ["nemo_structured_outputs.py", "pyproject.toml"]
+
+[tool.hatch.metadata]
+allow-direct-references = true
+
+[tool.verifiers.eval]
+num_examples = 5
+rollouts_per_example = 1
diff --git a/environments/nemo_gym/nemo_workplace_assistant/README.md b/environments/nemo_gym/nemo_workplace_assistant/README.md
new file mode 100644
index 0000000000..2cfddc92b9
--- /dev/null
+++ b/environments/nemo_gym/nemo_workplace_assistant/README.md
@@ -0,0 +1,61 @@
+# nemo-workplace-assistant
+
+
+
+
+
+### Overview
+- **Environment ID**: `nemo-workplace-assistant`
+- **Short description**: Tool-rich NeMo Gym `workplace_assistant` resource server adapter environment.
+- **Tags**: nemo-gym, tools, session-state, sandbox
+
+### Why this example
+This environment is a representative NeMo Gym integration pattern because it exercises:
+- session seeding (`/seed_session`) before tool use
+- many dynamic tools exposed per row from `responses_create_params.tools`
+- final scoring through `/verify`
+
+### Datasets
+- **Primary dataset(s)**: `resources_servers/workplace_assistant/data/.jsonl` from `nemo-gym`.
+- **Source links**: [NeMo Gym](https://github.com/NVIDIA-NeMo/Gym)
+- **Split sizes**: use `dataset_split` (`example`, `train`, `validation`) and optional `dataset_limit`.
+
+### Task
+- **Type**: Multi-turn tool use
+- **Rubric overview**: reward is `verify_response.reward` returned by the NeMo server `/verify` endpoint.
+
+### Quickstart
+Install local environment package:
+
+```bash
+uv run vf-install nemo_workplace_assistant -p ./environments
+```
+
+Run an evaluation:
+
+```bash
+uv run vf-eval nemo-workplace-assistant -m anthropic/claude-sonnet-4.5 -n 1 -r 1
+```
+
+Override sandbox NeMo package (if needed):
+
+```bash
+uv run vf-eval nemo-workplace-assistant -m anthropic/claude-sonnet-4.5 -n 1 -r 1 \
+ --env-args '{"nemo_package":"nemo-gym","nemo_package_version":"0.2.9892rc0"}'
+```
+
+### Environment Arguments
+| Arg | Type | Default | Description |
+| --- | ---- | ------- | ----------- |
+| `dataset_split` | str | `"example"` | Dataset split (`example`, `train`, `validation`) |
+| `max_turns` | int | `16` | Max turns per rollout |
+| `nemo_package` | str | `"nemo-gym"` | Package installed inside sandbox |
+| `nemo_package_version` | str \| None | `None` | Optional version pin when using package name |
+
+Any additional kwargs are forwarded to `verifiers.envs.integrations.nemo_gym_env.load_environment`.
+
+### Metrics
+| Metric | Meaning |
+| ------ | ------- |
+| `reward` | scalar reward from NeMo `/verify` response |
+| `num_turns` | number of turns in rollout |
diff --git a/environments/nemo_gym/nemo_workplace_assistant/nemo_workplace_assistant.py b/environments/nemo_gym/nemo_workplace_assistant/nemo_workplace_assistant.py
new file mode 100644
index 0000000000..ce307e48bc
--- /dev/null
+++ b/environments/nemo_gym/nemo_workplace_assistant/nemo_workplace_assistant.py
@@ -0,0 +1,26 @@
+from typing import Any
+
+import verifiers as vf
+from verifiers.envs.integrations.nemo_gym import (
+ NemoGymEnv,
+ _build_dataset,
+ _reward_from_verify,
+)
+
+
+def load_environment(
+ dataset_split: str = "example",
+ max_turns: int = 16,
+ **kwargs: Any,
+) -> vf.Environment:
+ dataset, _ = _build_dataset(
+ resource_server="workplace_assistant", dataset_split=dataset_split
+ )
+ rubric = vf.Rubric(funcs=[_reward_from_verify], weights=[1.0])
+ return NemoGymEnv(
+ resource_server="workplace_assistant",
+ dataset=dataset,
+ rubric=rubric,
+ max_turns=max_turns,
+ **kwargs,
+ )
diff --git a/environments/nemo_gym/nemo_workplace_assistant/pyproject.toml b/environments/nemo_gym/nemo_workplace_assistant/pyproject.toml
new file mode 100644
index 0000000000..596dc3b372
--- /dev/null
+++ b/environments/nemo_gym/nemo_workplace_assistant/pyproject.toml
@@ -0,0 +1,24 @@
+[project]
+name = "nemo-workplace-assistant"
+description = "NeMo Gym workplace_assistant resource server environment via verifiers NeMo integration"
+tags = ["nemo-gym", "tools", "multi-turn", "session-state", "sandbox"]
+version = "0.1.0"
+requires-python = ">=3.10"
+dependencies = [
+ "verifiers>=0.1.11.dev1",
+ "nemo-gym @ https://test-files.pythonhosted.org/packages/c0/58/451a826009a0b206c932e1ebde3dcff2a8b31152c77133fdde7e5f7ccd90/nemo_gym-0.2.9892rc0-py3-none-any.whl",
+]
+
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+[tool.hatch.build]
+include = ["nemo_workplace_assistant.py", "README.md", "pyproject.toml"]
+
+[tool.hatch.metadata]
+allow-direct-references = true
+
+[tool.verifiers.eval]
+num_examples = 5
+rollouts_per_example = 1
diff --git a/environments/nemo_gym/nemo_xlam_fc/nemo_xlam_fc.py b/environments/nemo_gym/nemo_xlam_fc/nemo_xlam_fc.py
new file mode 100644
index 0000000000..9a2d4ea97b
--- /dev/null
+++ b/environments/nemo_gym/nemo_xlam_fc/nemo_xlam_fc.py
@@ -0,0 +1,23 @@
+from typing import Any
+
+import verifiers as vf
+from verifiers.envs.integrations.nemo_gym import (
+ NemoGymEnv,
+ _build_dataset,
+ _reward_from_verify,
+)
+
+
+def load_environment(
+ dataset_split: str = "example",
+ **kwargs: Any,
+) -> vf.Environment:
+ dataset, _ = _build_dataset(resource_server="xlam_fc", dataset_split=dataset_split)
+ rubric = vf.Rubric(funcs=[_reward_from_verify], weights=[1.0])
+ return NemoGymEnv(
+ resource_server="xlam_fc",
+ dataset=dataset,
+ rubric=rubric,
+ max_turns=1,
+ **kwargs,
+ )
diff --git a/environments/nemo_gym/nemo_xlam_fc/pyproject.toml b/environments/nemo_gym/nemo_xlam_fc/pyproject.toml
new file mode 100644
index 0000000000..be9b5266c9
--- /dev/null
+++ b/environments/nemo_gym/nemo_xlam_fc/pyproject.toml
@@ -0,0 +1,24 @@
+[project]
+name = "nemo-xlam-fc"
+description = "NeMo Gym xlam_fc resource server (function call matching and validation)"
+tags = ["nemo-gym", "agent", "single-turn", "sandbox"]
+version = "0.1.0"
+requires-python = ">=3.10"
+dependencies = [
+ "verifiers>=0.1.11.dev1",
+ "nemo-gym @ https://test-files.pythonhosted.org/packages/c0/58/451a826009a0b206c932e1ebde3dcff2a8b31152c77133fdde7e5f7ccd90/nemo_gym-0.2.9892rc0-py3-none-any.whl",
+]
+
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+[tool.hatch.build]
+include = ["nemo_xlam_fc.py", "pyproject.toml"]
+
+[tool.hatch.metadata]
+allow-direct-references = true
+
+[tool.verifiers.eval]
+num_examples = 5
+rollouts_per_example = 1
diff --git a/verifiers/envs/integrations/nemo_gym/__init__.py b/verifiers/envs/integrations/nemo_gym/__init__.py
new file mode 100644
index 0000000000..f47cf0b1c6
--- /dev/null
+++ b/verifiers/envs/integrations/nemo_gym/__init__.py
@@ -0,0 +1,4 @@
+from .env import NemoGymEnv
+from .utils import _build_dataset, _reward_from_verify
+
+__all__ = ["NemoGymEnv", "_build_dataset", "_reward_from_verify"]
diff --git a/verifiers/envs/integrations/nemo_gym/env.py b/verifiers/envs/integrations/nemo_gym/env.py
new file mode 100644
index 0000000000..8b8e39b5c5
--- /dev/null
+++ b/verifiers/envs/integrations/nemo_gym/env.py
@@ -0,0 +1,663 @@
+from __future__ import annotations
+
+import asyncio
+import json
+import os
+import shlex
+from dataclasses import dataclass
+from typing import Any
+
+import aiohttp
+from datasets import Dataset
+
+import verifiers as vf
+from verifiers.types import AssistantMessage, Messages, State, ToolMessage
+from verifiers.utils.message_utils import concat_messages, normalize_messages
+
+from .utils import (
+ _completion_to_nemo_response,
+ _json_dumps,
+ _nemo_tools_to_tool_defs,
+ _stringify,
+)
+
+try:
+ from prime_sandboxes import AsyncSandboxClient, CreateSandboxRequest
+except ImportError as e:
+ raise ImportError(
+ "NemoGymEnv requires prime-sandboxes. Install with: uv add prime-sandboxes"
+ ) from e
+
+_SERVER_LOG_PATH = "/tmp/nemo_gym_resource_server.log"
+_DEFAULT_NEMO_PACKAGE = "nemo-gym @ https://test-files.pythonhosted.org/packages/c0/58/451a826009a0b206c932e1ebde3dcff2a8b31152c77133fdde7e5f7ccd90/nemo_gym-0.2.9892rc0-py3-none-any.whl"
+
+
+@dataclass
+class _NemoGymServer:
+ sandbox_id: str
+ exposure_id: str
+ base_url: str
+ openapi_paths: set[str]
+
+
+class NemoGymEnv(vf.MultiTurnEnv):
+ def __init__(
+ self,
+ *,
+ resource_server: str,
+ dataset: Dataset,
+ rubric: vf.Rubric,
+ max_turns: int = 16,
+ config_overrides: dict[str, Any] | None = None,
+ extra_pip_packages: list[str] | None = None,
+ nemo_package: str = _DEFAULT_NEMO_PACKAGE,
+ nemo_package_version: str | None = None,
+ seed_session_on_start: bool = True,
+ system_prompt: str | None = None,
+ docker_image: str = "python:3.12",
+ sandbox_cpu_cores: int = 2,
+ sandbox_memory_gb: int = 4,
+ sandbox_disk_size_gb: int = 10,
+ sandbox_timeout_minutes: int = 60,
+ sandbox_port: int = 8000,
+ server_start_timeout_s: int = 120,
+ http_timeout_s: int = 60,
+ **kwargs: Any,
+ ):
+ self.resource_server = resource_server
+ self.nemo_package = nemo_package
+ self.nemo_package_version = nemo_package_version
+ self.extra_pip_packages = list(extra_pip_packages or [])
+ self.config_overrides = dict(config_overrides or {})
+ self.seed_session_on_start = seed_session_on_start
+
+ self.docker_image = docker_image
+ self.sandbox_cpu_cores = sandbox_cpu_cores
+ self.sandbox_memory_gb = sandbox_memory_gb
+ self.sandbox_disk_size_gb = sandbox_disk_size_gb
+ self.sandbox_timeout_minutes = sandbox_timeout_minutes
+ self.sandbox_port = sandbox_port
+ self.server_start_timeout_s = server_start_timeout_s
+ self.http_timeout_s = http_timeout_s
+
+ self._http_session: aiohttp.ClientSession | None = None
+ self._server: _NemoGymServer | None = None
+ self._sandbox_lock: asyncio.Lock | None = None
+
+ super().__init__(
+ dataset=dataset,
+ rubric=rubric,
+ max_turns=max_turns,
+ system_prompt=system_prompt,
+ message_type="chat",
+ **kwargs,
+ )
+
+ def _nemo_package_spec(self) -> str:
+ if self.nemo_package_version:
+ return f"{self.nemo_package}=={self.nemo_package_version}"
+ return self.nemo_package
+
+ def _install_command(self) -> str:
+ package_specs = [self._nemo_package_spec(), "httpx", *self.extra_pip_packages]
+ quoted_specs = " ".join(shlex.quote(spec) for spec in package_specs)
+
+ env_chunks: list[str] = []
+ for key in ("PIP_INDEX_URL", "PIP_EXTRA_INDEX_URL"):
+ value = os.getenv(key)
+ if value:
+ env_chunks.append(f"{key}={shlex.quote(value)}")
+
+ env_prefix = " ".join(env_chunks)
+ command = f"python -m pip install --no-cache-dir {quoted_specs}"
+ return f"{env_prefix} {command}".strip()
+
+ def _server_launcher_script(self) -> str:
+ serialized_overrides = _json_dumps(self.config_overrides)
+ return f"""
+import importlib
+import inspect
+import json
+import sys
+
+import uvicorn
+from omegaconf import OmegaConf
+
+from nemo_gym.base_resources_server import SimpleResourcesServer
+from nemo_gym.server_utils import BaseServerConfig, ServerClient
+
+RESOURCE_SERVER = {self.resource_server!r}
+PORT = {self.sandbox_port}
+SERVER_CONFIG_OVERRIDES = json.loads({serialized_overrides!r})
+
+# Add the server's package directory to sys.path and PYTHONPATH so relative
+# imports (e.g. "from lcb_integration import ...") resolve correctly.
+# PYTHONPATH is needed so Ray workers also inherit the path.
+import os
+_rs_pkg = importlib.import_module("resources_servers")
+for _search_path in getattr(_rs_pkg, "__path__", []):
+ _server_dir = _search_path + "/" + RESOURCE_SERVER
+ if _server_dir not in sys.path:
+ sys.path.insert(0, _server_dir)
+ _pypath = os.environ.get("PYTHONPATH", "")
+ if _server_dir not in _pypath:
+ os.environ["PYTHONPATH"] = _server_dir + (":" + _pypath if _pypath else "")
+
+module = importlib.import_module(f"resources_servers.{{RESOURCE_SERVER}}.app")
+server_cls = None
+for obj in module.__dict__.values():
+ if (
+ inspect.isclass(obj)
+ and issubclass(obj, SimpleResourcesServer)
+ and obj is not SimpleResourcesServer
+ and obj.__module__ == module.__name__
+ ):
+ server_cls = obj
+ break
+
+if server_cls is None:
+ raise RuntimeError(
+ f"Could not locate SimpleResourcesServer subclass in {{module.__name__}}"
+ )
+
+config_cls = server_cls.model_fields["config"].annotation
+config_payload = {{
+ "name": RESOURCE_SERVER,
+ "entrypoint": "app.py",
+ "host": "0.0.0.0",
+ "port": PORT,
+ "domain": "other",
+}}
+config_payload.update(SERVER_CONFIG_OVERRIDES)
+
+config = config_cls(**config_payload)
+
+server_client = ServerClient(
+ head_server_config=BaseServerConfig(host="127.0.0.1", port=11000),
+ global_config_dict=OmegaConf.create({{}}),
+)
+server = server_cls(config=config, server_client=server_client)
+app = server.setup_webserver()
+server.setup_exception_middleware(app)
+
+uvicorn.run(
+ app,
+ host="0.0.0.0",
+ port=PORT,
+ timeout_graceful_shutdown=0.5,
+ log_level="info",
+)
+""".strip()
+
+ def _start_server_command(self) -> str:
+ launcher_path = "/tmp/nemo_gym_server_launcher.py"
+ launcher_script = self._server_launcher_script()
+ return (
+ f"cat > {launcher_path} <<'PY'\n"
+ f"{launcher_script}\n"
+ "PY\n"
+ f"nohup python {launcher_path} > {_SERVER_LOG_PATH} 2>&1 &"
+ )
+
+ def _build_sandbox_request(self) -> CreateSandboxRequest:
+ params: dict[str, Any] = {
+ "name": f"nemo-gym-{self.resource_server}",
+ "docker_image": self.docker_image,
+ "start_command": "tail -f /dev/null",
+ "cpu_cores": self.sandbox_cpu_cores,
+ "memory_gb": self.sandbox_memory_gb,
+ "disk_size_gb": self.sandbox_disk_size_gb,
+ "timeout_minutes": self.sandbox_timeout_minutes,
+ "environment_vars": {"ENABLE_WEB_INTERFACE": "false"},
+ }
+ return CreateSandboxRequest(**params)
+
+ def _exposure_to_base_url(self, exposure: Any) -> str:
+ endpoint = getattr(exposure, "external_endpoint", None)
+ if isinstance(endpoint, str) and endpoint.strip():
+ return f"http://{endpoint.strip()}"
+
+ raw_url = str(getattr(exposure, "url", "") or "").strip()
+ if raw_url.startswith("tcp://"):
+ host_port = raw_url[len("tcp://") :].rstrip("/")
+ if host_port:
+ return f"http://{host_port}"
+ if raw_url.startswith("http://") or raw_url.startswith("https://"):
+ return raw_url.rstrip("/")
+
+ raise RuntimeError("NeMo Gym sandbox exposure did not provide a usable URL.")
+
+ async def _ensure_http_session(self) -> aiohttp.ClientSession:
+ if self._http_session is None or self._http_session.closed:
+ timeout = aiohttp.ClientTimeout(total=float(self.http_timeout_s))
+ self._http_session = aiohttp.ClientSession(timeout=timeout)
+ return self._http_session
+
+ async def _ensure_server(self) -> _NemoGymServer:
+ if self._server is not None:
+ return self._server
+ if self._sandbox_lock is None:
+ self._sandbox_lock = asyncio.Lock()
+ async with self._sandbox_lock:
+ if self._server is not None:
+ return self._server
+ self._server = await self._create_sandbox()
+ return self._server
+
+ async def _request(
+ self,
+ *,
+ base_url: str,
+ method: str,
+ endpoint: str,
+ payload: Any | None = None,
+ cookie: str | None = None,
+ ) -> tuple[int, Any, dict[str, str]]:
+ session = await self._ensure_http_session()
+ url = f"{base_url.rstrip('/')}/{endpoint.lstrip('/')}"
+ headers = {"cookie": cookie} if cookie else None
+ request_kwargs: dict[str, Any] = {}
+ if payload is not None:
+ request_kwargs["json"] = payload
+
+ async with session.request(
+ method,
+ url,
+ headers=headers,
+ **request_kwargs,
+ ) as response:
+ text = await response.text()
+ if not text:
+ body: Any = {}
+ else:
+ try:
+ body = json.loads(text)
+ except json.JSONDecodeError:
+ body = text
+ return int(response.status), body, dict(response.headers)
+
+ async def _wait_for_server_ready(self, base_url: str) -> dict[str, Any]:
+ loop = asyncio.get_running_loop()
+ start = loop.time()
+ last_error = "no attempts"
+
+ while (loop.time() - start) < float(self.server_start_timeout_s):
+ try:
+ status, body, _ = await self._request(
+ base_url=base_url,
+ method="GET",
+ endpoint="/openapi.json",
+ )
+ if status == 200 and isinstance(body, dict):
+ return body
+ last_error = f"HTTP {status}: {_stringify(body)[:400]}"
+ except Exception as exc:
+ last_error = f"{type(exc).__name__}: {exc}"
+
+ await asyncio.sleep(2)
+
+ raise vf.SandboxError(
+ "NeMo Gym server failed to become ready within "
+ f"{self.server_start_timeout_s}s at {base_url}. Last error: {last_error}"
+ )
+
+ async def _server_log_tail(
+ self,
+ sandboxes: AsyncSandboxClient,
+ sandbox_id: str,
+ lines: int = 120,
+ ) -> str:
+ try:
+ result = await sandboxes.execute_command(
+ sandbox_id,
+ f"tail -n {lines} {_SERVER_LOG_PATH} 2>/dev/null || true",
+ timeout=10,
+ )
+ return (result.stdout or "").strip()
+ except Exception:
+ return ""
+
+ async def _create_sandbox(self) -> _NemoGymServer:
+ await self._ensure_http_session()
+
+ async with AsyncSandboxClient() as sandboxes:
+ sandbox: Any | None = None
+ exposure: Any | None = None
+ try:
+ sandbox = await sandboxes.create(self._build_sandbox_request())
+ print(
+ f"[NemoGymEnv] Created sandbox {sandbox.id} for '{self.resource_server}'"
+ )
+ await sandboxes.wait_for_creation(sandbox.id)
+
+ install_result = await sandboxes.execute_command(
+ sandbox.id,
+ self._install_command(),
+ timeout=900,
+ )
+ if install_result.exit_code != 0:
+ stderr = (install_result.stderr or "").strip()
+ stdout = (install_result.stdout or "").strip()
+ raise vf.SandboxError(
+ "Failed to install NeMo Gym in sandbox. "
+ f"stdout: {stdout[-500:]} stderr: {stderr[-500:]}"
+ )
+
+ start_result = await sandboxes.execute_command(
+ sandbox.id,
+ self._start_server_command(),
+ timeout=30,
+ )
+ if start_result.exit_code != 0:
+ stderr = (start_result.stderr or "").strip()
+ raise vf.SandboxError(
+ f"Failed to launch NeMo Gym resource server: {stderr[-500:]}"
+ )
+
+ exposure = await sandboxes.expose(
+ sandbox.id,
+ port=self.sandbox_port,
+ name="nemo-gym",
+ protocol="TCP",
+ )
+ base_url = self._exposure_to_base_url(exposure)
+
+ openapi = await self._wait_for_server_ready(base_url)
+ openapi_paths = set((openapi.get("paths") or {}).keys())
+
+ return _NemoGymServer(
+ sandbox_id=sandbox.id,
+ exposure_id=str(getattr(exposure, "exposure_id", "")),
+ base_url=base_url,
+ openapi_paths=openapi_paths,
+ )
+ except Exception as exc:
+ if sandbox is not None:
+ logs = await self._server_log_tail(sandboxes, sandbox.id)
+ if exposure is not None:
+ try:
+ await sandboxes.unexpose(sandbox.id, exposure.exposure_id)
+ except Exception:
+ pass
+ try:
+ await sandboxes.delete(sandbox.id)
+ except Exception:
+ pass
+ else:
+ logs = ""
+
+ if isinstance(exc, vf.SandboxError):
+ detail = str(exc)
+ else:
+ detail = f"{type(exc).__name__}: {exc}"
+ if logs:
+ detail = f"{detail}\nServer log tail:\n{logs}"
+ sandbox_id = sandbox.id if sandbox is not None else "N/A"
+ raise vf.SandboxError(
+ f"Failed at sandbox startup for NeMo Gym resource server "
+ f"'{self.resource_server}' (sandbox={sandbox_id}): {detail}"
+ ) from exc
+
+ def _get_server(self) -> _NemoGymServer:
+ if self._server is None:
+ raise RuntimeError("No server available — was setup_state() called?")
+ return self._server
+
+ async def _seed_session(self, base_url: str, payload: dict[str, Any]) -> str | None:
+ status, body, headers = await self._request(
+ base_url=base_url,
+ method="POST",
+ endpoint="/seed_session",
+ payload=payload,
+ )
+ if status >= 400:
+ raise vf.SandboxError(
+ f"seed_session failed with status {status}: {_stringify(body)[:400]}"
+ )
+
+ set_cookie = headers.get("set-cookie") or headers.get("Set-Cookie")
+ if isinstance(set_cookie, str):
+ cookie = set_cookie.split(";", 1)[0].strip()
+ if cookie:
+ return cookie
+ return None
+
+ async def setup_state(self, state: State) -> State:
+ state = await super().setup_state(state)
+ server = await self._ensure_server()
+
+ # dataset_row_json is a JSON string we set in _build_dataset — deserialize
+ # it here rather than using info dict fields directly, because HF Arrow
+ # serialization corrupts heterogeneous nested schemas.
+ dataset_row = json.loads(state["info"]["dataset_row_json"])
+ responses_create_params = dataset_row["responses_create_params"]
+
+ tool_defs_raw = _nemo_tools_to_tool_defs(
+ responses_create_params.get("tools", [])
+ )
+ state["tool_defs"] = self._normalize_tool_defs(tool_defs_raw) or []
+ state["nemo_dataset_row"] = dataset_row
+ state["verify_response"] = None
+
+ seed_payload = {
+ k: v for k, v in dataset_row.items() if k != "responses_create_params"
+ }
+ cookie: str | None = None
+ if self.seed_session_on_start and "/seed_session" in server.openapi_paths:
+ cookie = await self._seed_session(server.base_url, seed_payload)
+ state["nemo_cookie"] = cookie
+ return state
+
+ async def env_response(
+ self,
+ messages: Messages,
+ state: State,
+ **kwargs: Any,
+ ) -> Messages:
+ if not messages:
+ return []
+
+ last_message = messages[-1]
+ if (
+ not isinstance(last_message, AssistantMessage)
+ or not last_message.tool_calls
+ ):
+ return []
+
+ server = self._get_server()
+ cookie = state.get("nemo_cookie")
+
+ tool_messages: Messages = []
+ for tool_call in last_message.tool_calls:
+ call_id = tool_call.id
+ tool_name = tool_call.name
+ endpoint = f"/{tool_name}"
+
+ try:
+ parsed_args = json.loads(tool_call.arguments)
+ except Exception as exc:
+ tool_messages.append(
+ ToolMessage(
+ role="tool",
+ tool_call_id=call_id,
+ content=_json_dumps(
+ {
+ "error": "Invalid JSON tool arguments",
+ "detail": f"{type(exc).__name__}: {exc}",
+ "arguments": tool_call.arguments,
+ }
+ ),
+ )
+ )
+ continue
+
+ try:
+ status, body, _ = await self._request(
+ base_url=server.base_url,
+ method="POST",
+ endpoint=endpoint,
+ payload=parsed_args,
+ cookie=cookie,
+ )
+ except Exception as exc:
+ tool_messages.append(
+ ToolMessage(
+ role="tool",
+ tool_call_id=call_id,
+ content=_json_dumps(
+ {
+ "error": "Tool request failed",
+ "endpoint": endpoint,
+ "detail": f"{type(exc).__name__}: {exc}",
+ }
+ ),
+ )
+ )
+ continue
+
+ if status >= 400:
+ content = _json_dumps(
+ {
+ "error": "Tool endpoint returned non-success status",
+ "endpoint": endpoint,
+ "status_code": status,
+ "body": body,
+ }
+ )
+ elif isinstance(body, str):
+ content = body
+ else:
+ content = _json_dumps(body)
+
+ tool_messages.append(
+ ToolMessage(role="tool", tool_call_id=call_id, content=content)
+ )
+
+ return tool_messages
+
+ @vf.stop
+ async def no_tool_calls(self, state: State, **kwargs: Any) -> bool:
+ trajectory = state.get("trajectory")
+ if not trajectory:
+ return False
+ last_message = trajectory[-1]["completion"][-1]
+ return (
+ isinstance(last_message, AssistantMessage) and not last_message.tool_calls
+ )
+
+ @vf.cleanup
+ async def cleanup_nemo(self, state: State) -> None:
+ await self._verify(state)
+
+ def _completion_for_verify(self, state: State) -> Messages:
+ completion = state.get("completion")
+ if isinstance(completion, list):
+ return normalize_messages(completion, field_name="state.completion")
+
+ trajectory = state.get("trajectory", [])
+ if not isinstance(trajectory, list) or not trajectory:
+ return []
+
+ last_step = trajectory[-1]
+ last_prompt = normalize_messages(
+ last_step["prompt"], field_name="trajectory.prompt"
+ )
+ last_completion = normalize_messages(
+ last_step["completion"],
+ field_name="trajectory.completion",
+ )
+ full_conversation = concat_messages([last_prompt, last_completion])
+
+ final_env_response = state.get("final_env_response")
+ if final_env_response is not None:
+ final_messages = normalize_messages(
+ final_env_response, field_name="final_env_response"
+ )
+ full_conversation = concat_messages([full_conversation, final_messages])
+
+ prompt_messages = normalize_messages(state["prompt"], field_name="state.prompt")
+ return full_conversation[len(prompt_messages) :]
+
+ async def _verify(self, state: State) -> None:
+ try:
+ dataset_row = state["nemo_dataset_row"]
+ responses_create_params = dataset_row["responses_create_params"]
+
+ completion = self._completion_for_verify(state)
+ nemo_response = _completion_to_nemo_response(
+ completion=completion,
+ model_name=str(state.get("model", "")),
+ trajectory_id=str(state.get("trajectory_id", "unknown")),
+ responses_create_params=responses_create_params,
+ )
+
+ verify_payload = {
+ "responses_create_params": responses_create_params,
+ "response": nemo_response,
+ **{
+ k: v
+ for k, v in dataset_row.items()
+ if k != "responses_create_params"
+ },
+ }
+
+ server = self._get_server()
+ cookie = state.get("nemo_cookie")
+ status, body, _ = await self._request(
+ base_url=server.base_url,
+ method="POST",
+ endpoint="/verify",
+ payload=verify_payload,
+ cookie=cookie,
+ )
+
+ if status >= 400:
+ state["verify_response"] = {
+ "reward": 0.0,
+ "error": f"Verify endpoint returned status {status}: {_stringify(body)[:400]}",
+ }
+ else:
+ state["verify_response"] = body
+
+ if (
+ "/close" in server.openapi_paths
+ and dataset_row.get("env_id") is not None
+ ):
+ try:
+ await self._request(
+ base_url=server.base_url,
+ method="POST",
+ endpoint="/close",
+ payload={"env_id": dataset_row["env_id"]},
+ cookie=cookie,
+ )
+ except Exception:
+ pass
+ except Exception as exc:
+ state["verify_response"] = {
+ "reward": 0.0,
+ "error": f"Verification failed: {type(exc).__name__}: {exc}",
+ }
+
+ async def _destroy_sandbox(self, server: _NemoGymServer) -> None:
+ if not server.sandbox_id:
+ return
+ async with AsyncSandboxClient() as sandboxes:
+ if server.exposure_id:
+ try:
+ await sandboxes.unexpose(server.sandbox_id, server.exposure_id)
+ except Exception:
+ pass
+ try:
+ await sandboxes.delete(server.sandbox_id)
+ except Exception:
+ pass
+
+ @vf.teardown
+ async def teardown_server(self) -> None:
+ if self._server is not None:
+ await self._destroy_sandbox(self._server)
+ self._server = None
+ if self._http_session is not None and not self._http_session.closed:
+ await self._http_session.close()
+ self._http_session = None
diff --git a/verifiers/envs/integrations/nemo_gym/utils.py b/verifiers/envs/integrations/nemo_gym/utils.py
new file mode 100644
index 0000000000..4b58fedfee
--- /dev/null
+++ b/verifiers/envs/integrations/nemo_gym/utils.py
@@ -0,0 +1,305 @@
+from __future__ import annotations
+
+import importlib.util
+import json
+import time
+from pathlib import Path
+from typing import Any, cast
+
+from datasets import Dataset
+
+from verifiers.types import AssistantMessage, Messages, State, ToolMessage
+
+
+_ALLOWED_DATASET_SPLITS = {"example", "train", "validation"}
+
+
+def _json_dumps(value: Any) -> str:
+ return json.dumps(value, ensure_ascii=False)
+
+
+def _stringify(value: Any) -> str:
+ if value is None:
+ return ""
+ if isinstance(value, str):
+ return value
+ try:
+ return _json_dumps(value)
+ except (TypeError, ValueError):
+ return str(value)
+
+
+def _sanitize_json_schema(value: Any) -> Any:
+ if isinstance(value, dict):
+ sanitized: dict[str, Any] = {}
+ for key, raw_child in value.items():
+ if raw_child is None:
+ continue
+ child = _sanitize_json_schema(raw_child)
+ if child is None:
+ continue
+ sanitized[key] = child
+
+ properties = sanitized.get("properties")
+ if isinstance(properties, dict):
+ sanitized["properties"] = {
+ name: schema
+ for name, schema in properties.items()
+ if isinstance(schema, (dict, bool))
+ }
+ required = sanitized.get("required")
+ if isinstance(required, list):
+ allowed = set(sanitized["properties"].keys())
+ sanitized["required"] = [
+ name
+ for name in required
+ if isinstance(name, str) and name in allowed
+ ]
+
+ return sanitized
+
+ if isinstance(value, list):
+ return [
+ child
+ for item in value
+ if (child := _sanitize_json_schema(item)) is not None
+ ]
+
+ return value
+
+
+def _normalize_parameters_schema(value: Any) -> dict[str, Any]:
+ if not isinstance(value, dict):
+ return {"type": "object", "properties": {}}
+ return _sanitize_json_schema(value)
+
+
+def _nemo_tools_to_tool_defs(raw_tools: Any) -> list[dict[str, Any]]:
+ if not isinstance(raw_tools, list):
+ return []
+
+ tool_defs: list[dict[str, Any]] = []
+ for raw_tool in raw_tools:
+ if not isinstance(raw_tool, dict):
+ continue
+
+ # OpenAI Chat Completions-style tool schema.
+ if raw_tool.get("type") == "function" and isinstance(
+ raw_tool.get("function"), dict
+ ):
+ fn = cast(dict[str, Any], raw_tool["function"])
+ name = fn.get("name")
+ if not isinstance(name, str) or not name:
+ continue
+ tool_def: dict[str, Any] = {
+ "name": name,
+ "description": _stringify(fn.get("description", "")),
+ "parameters": _normalize_parameters_schema(fn.get("parameters")),
+ }
+ strict = fn.get("strict", raw_tool.get("strict"))
+ if isinstance(strict, bool):
+ tool_def["strict"] = strict
+ tool_defs.append(tool_def)
+ continue
+
+ # OpenAI Responses API function tool schema.
+ tool_type = raw_tool.get("type")
+ if tool_type not in (None, "function"):
+ continue
+
+ name = raw_tool.get("name")
+ if not isinstance(name, str) or not name:
+ continue
+
+ tool_def = {
+ "name": name,
+ "description": _stringify(raw_tool.get("description", "")),
+ "parameters": _normalize_parameters_schema(raw_tool.get("parameters")),
+ }
+ strict = raw_tool.get("strict")
+ if isinstance(strict, bool):
+ tool_def["strict"] = strict
+ tool_defs.append(tool_def)
+
+ return tool_defs
+
+
+def _resolve_resources_servers_root() -> Path:
+ resources_spec = importlib.util.find_spec("resources_servers")
+ if resources_spec and resources_spec.submodule_search_locations:
+ root = Path(next(iter(resources_spec.submodule_search_locations))).resolve()
+ if root.exists():
+ return root
+
+ nemo_spec = importlib.util.find_spec("nemo_gym")
+ if nemo_spec and nemo_spec.origin:
+ nemo_root = Path(nemo_spec.origin).resolve().parent
+ sibling = nemo_root.parent / "resources_servers"
+ if sibling.exists():
+ return sibling
+
+ raise RuntimeError(
+ "Unable to locate NeMo Gym resources_servers package. "
+ "Install `nemo-gym` or pass `dataset_path` explicitly."
+ )
+
+
+def _resolve_dataset_path(
+ resource_server: str,
+ dataset_split: str,
+ dataset_path: str | None,
+) -> Path:
+ if dataset_path is not None:
+ path = Path(dataset_path).expanduser().resolve()
+ if not path.exists():
+ raise FileNotFoundError(f"dataset_path does not exist: {path}")
+ return path
+
+ resources_root = _resolve_resources_servers_root()
+ path = resources_root / resource_server / "data" / f"{dataset_split}.jsonl"
+ if not path.exists():
+ raise FileNotFoundError(
+ "Could not find dataset file for server "
+ f"'{resource_server}' split '{dataset_split}': {path}"
+ )
+ return path
+
+
+def _load_rows_from_jsonl(path: Path) -> list[dict[str, Any]]:
+ rows: list[dict[str, Any]] = []
+ with path.open("r", encoding="utf-8") as f:
+ for line_no, line in enumerate(f, start=1):
+ line = line.strip()
+ if not line:
+ continue
+ try:
+ row = json.loads(line)
+ except json.JSONDecodeError as exc:
+ raise ValueError(
+ f"Invalid JSON in {path} line {line_no}: {exc}"
+ ) from exc
+ if not isinstance(row, dict):
+ raise ValueError(f"Row {line_no} in {path} is not an object")
+ if "responses_create_params" not in row:
+ raise ValueError(
+ f"Row {line_no} in {path} is missing required key "
+ "'responses_create_params'"
+ )
+ rows.append(row)
+ if not rows:
+ raise ValueError(f"Dataset file {path} contains no rows")
+ return rows
+
+
+def _build_dataset(
+ resource_server: str,
+ dataset_split: str,
+ dataset_path: str | None = None,
+ dataset_limit: int | None = None,
+) -> tuple[Dataset, Path]:
+ resolved_path = _resolve_dataset_path(resource_server, dataset_split, dataset_path)
+ rows = _load_rows_from_jsonl(resolved_path)
+
+ if dataset_limit is not None:
+ if dataset_limit <= 0:
+ raise ValueError("dataset_limit must be > 0 when provided")
+ rows = rows[:dataset_limit]
+
+ dataset_rows: list[dict[str, Any]] = []
+ for row in rows:
+ responses_create_params = row.get("responses_create_params")
+ if not isinstance(responses_create_params, dict):
+ raise ValueError("responses_create_params must be an object")
+
+ raw_input = responses_create_params.get("input", [])
+ if isinstance(raw_input, str):
+ prompt = [{"role": "user", "content": raw_input}]
+ elif isinstance(raw_input, list):
+ prompt = raw_input
+ else:
+ prompt = [{"role": "user", "content": _stringify(raw_input)}]
+ dataset_rows.append(
+ {
+ "prompt": prompt,
+ "answer": _stringify(row.get("answer", "")),
+ "task": resource_server,
+ "info": {
+ "dataset_row_json": _json_dumps(row),
+ "resource_server": resource_server,
+ },
+ }
+ )
+
+ return Dataset.from_list(dataset_rows), resolved_path
+
+
+def _completion_to_nemo_response(
+ completion: Messages,
+ model_name: str,
+ trajectory_id: str,
+ responses_create_params: dict[str, Any],
+) -> dict[str, Any]:
+ output: list[dict[str, Any]] = []
+ message_idx = 0
+
+ for msg in completion:
+ if isinstance(msg, AssistantMessage):
+ text = msg.content or ""
+ if isinstance(text, list):
+ text = "\n".join(getattr(p, "text", str(p)) for p in text)
+ if text:
+ output.append(
+ {
+ "id": f"msg_{message_idx}",
+ "type": "message",
+ "role": "assistant",
+ "content": [
+ {"type": "output_text", "text": text, "annotations": []}
+ ],
+ }
+ )
+ message_idx += 1
+
+ for tc in msg.tool_calls or []:
+ output.append(
+ {
+ "id": tc.id,
+ "type": "function_call",
+ "call_id": tc.id,
+ "name": tc.name,
+ "arguments": tc.arguments,
+ }
+ )
+ message_idx += 1
+
+ elif isinstance(msg, ToolMessage):
+ content = msg.content
+ if isinstance(content, list):
+ content = "\n".join(getattr(p, "text", str(p)) for p in content)
+ output.append(
+ {
+ "type": "function_call_output",
+ "call_id": msg.tool_call_id,
+ "output": content or "",
+ }
+ )
+
+ return {
+ "id": f"verifiers-{trajectory_id}",
+ "created_at": int(time.time()),
+ "model": model_name,
+ "object": "response",
+ "output": output,
+ "parallel_tool_calls": bool(
+ responses_create_params.get("parallel_tool_calls", False)
+ ),
+ "tool_choice": responses_create_params.get("tool_choice", "none"),
+ "tools": responses_create_params.get("tools", []),
+ }
+
+
+def _reward_from_verify(state: State, **kwargs: Any) -> float:
+ verify_response = state.get("verify_response")
+ if not isinstance(verify_response, dict):
+ return 0.0
+ return float(verify_response.get("reward", 0.0) or 0.0)
diff --git a/verifiers/utils/install_utils.py b/verifiers/utils/install_utils.py
index b15b34edbd..8561ef304e 100644
--- a/verifiers/utils/install_utils.py
+++ b/verifiers/utils/install_utils.py
@@ -200,7 +200,15 @@ def install_from_local(env_name: str, env_dir: str = "./environments") -> bool:
True if installation succeeded
"""
env_folder = normalize_package_name(env_name)
- env_path = Path(env_dir) / env_folder
+ base = Path(env_dir)
+ env_path = base / env_folder
+
+ # Also check one level of subdirectories (e.g. environments/nemo_gym/nemo_mcqa/).
+ if not env_path.exists() and base.is_dir():
+ for subdir in base.iterdir():
+ if subdir.is_dir() and (subdir / env_folder).is_dir():
+ env_path = subdir / env_folder
+ break
if not env_path.exists():
logger.error(f"Local environment not found: {env_path}")
diff --git a/verifiers/utils/message_utils.py b/verifiers/utils/message_utils.py
index da8994f6f7..1334508902 100644
--- a/verifiers/utils/message_utils.py
+++ b/verifiers/utils/message_utils.py
@@ -107,6 +107,8 @@ def from_raw_message(message: dict) -> Message:
return TextMessage.model_validate(message)
elif message["role"] == "system":
return SystemMessage.model_validate(message)
+ elif message["role"] == "developer":
+ return SystemMessage.model_validate({**message, "role": "system"})
elif message["role"] == "user":
return UserMessage.model_validate(message)
elif message["role"] == "assistant":