diff --git a/.claude/skills/README.md b/.claude/skills/README.md new file mode 100644 index 000000000..3836552db --- /dev/null +++ b/.claude/skills/README.md @@ -0,0 +1,95 @@ +# NeMo Gym Agent Skills + +Agent skills for NeMo Gym development. Each skill follows the [agentskills.io](https://agentskills.io/specification) specification and can be used standalone or composed into multi-step chains. + +## Skills + +| Skill | What it does | When to use it | +|-------|-------------|----------------| +| [gym-review](gym-review/) | Deterministic anti-pattern checker + judgment-based review | Reviewing PRs, auditing servers before merge | +| [gym-debug](gym-debug/) | Diagnose server failures, rollout errors, unexpected rewards | Servers won't start, rollouts hang, rewards look wrong | +| [gym-run](gym-run/) | Run benchmarks — env.yaml setup, server launch, rollout collection | First run, smoke testing, full rollout collection | +| [gym-profile](gym-profile/) | Analyze rollout results, reward distributions, pass rates | Baselining benchmarks, comparing models, investigating variance | +| [gym-config](gym-config/) | Compose and validate Hydra YAML configurations | Setting up server configs, debugging composition errors | +| [gym-data](gym-data/) | Prepare, validate, and register JSONL datasets | Converting data, uploading to GitLab registry, validating schemas | +| [gym-scaffold-agent](gym-scaffold-agent/) | Create custom agent servers | Multi-turn interaction, external library wrapping, tool orchestration | +| [add-benchmark](add-benchmark/) | End-to-end benchmark creation guide | Adding a new resources server + agent + data + config | + +## Chains + +Chains compose skills into multi-step workflows. Defined in [`chains.yaml`](chains.yaml). + +| Chain | Steps | Use case | +|-------|-------|----------| +| **run** | gym-config > gym-run > gym-profile | Executing a configured benchmark end-to-end | +| **new-benchmark** | add-benchmark > gym-data > gym-config > gym-run > gym-profile > gym-review | Building a benchmark from scratch | +| **validate** | gym-config > gym-data > gym-run > gym-profile | Checking an existing benchmark works correctly | +| **diagnose** | gym-debug > gym-review | Debugging a failing benchmark | +| **external-integration** | gym-scaffold-agent > gym-data > gym-config > gym-run > gym-profile > gym-review | Wrapping a 3rd-party benchmark library | +| **pre-merge** | gym-review > gym-config > gym-data | Final checks before merging a PR | + +## Skill structure + +Each skill follows a consistent layout: + +``` +skill-name/ + SKILL.md # Skill definition (YAML frontmatter + instructions) + evals/ + evals.json # Assertion-based evaluations + files/ # Self-contained test fixtures (if applicable) + references/ # Portable reference docs (if applicable) + scripts/ # Deterministic tooling (if applicable) +``` + +**gym-review** is the reference implementation: it includes a standalone Python checker (`scripts/review.py`), self-contained reference docs, and eval fixtures that work without the NeMo Gym repo. + +## Evaluating skills + +Each skill has 3 evals in `evals/evals.json`. Evals follow the [agentskills.io evaluation spec](https://agentskills.io/skill-creation/evaluating-skills). + +### Running evals + +Compare agent performance **with-skill** vs **without-skill** (baseline): + +1. **With-skill**: Load the SKILL.md, give the agent the eval prompt, grade the response against assertions. +2. **Without-skill (baseline)**: Give the agent the same prompt with no skill loaded, grade against the same assertions. +3. **Compute delta**: The percentage-point improvement from loading the skill. + +Each eval in `evals.json` has: + +```json +{ + "id": 1, + "prompt": "The task the agent must perform", + "expected_output": "What a good response looks like", + "files": ["evals/files/fixture.py"], + "assertions": [ + "Specific claim that must be true in the response", + "Another required element" + ] +} +``` + +### Grading + +For each assertion, score 1 (present in response) or 0 (missing). The skill's score is the average across all assertions and evals. A skill is useful when its with-skill score meaningfully exceeds the baseline. + +### Example: gym-review + +```bash +# The review script can also be tested directly +python .claude/skills/gym-review/scripts/review.py .claude/skills/gym-review/evals/files/ + +# Expected: 9 BLOCK, 4 WARN across the fixture files +# sample_clean_server.py should produce 0 findings +``` + +## Portability + +Skills are designed to work when pulled standalone. Key design principles: + +- **References are self-contained** -- no links to repo-internal paths that won't exist for external users +- **Scripts have zero dependencies** -- `review.py` uses only the Python standard library +- **Eval fixtures are bundled** -- test files live in `evals/files/`, not scattered across the repo +- **SKILL.md frontmatter** includes `license`, `compatibility`, and `allowed-tools` per the spec diff --git a/.claude/skills/add-benchmark/SKILL.md b/.claude/skills/add-benchmark/SKILL.md index 385666e38..80b8bc44f 100644 --- a/.claude/skills/add-benchmark/SKILL.md +++ b/.claude/skills/add-benchmark/SKILL.md @@ -6,8 +6,13 @@ description: > training environment, or resources server into NeMo-Gym. Also use when wrapping an existing 3rd-party benchmark library. Covers the full workflow: data preparation, resources server implementation, agent wiring, YAML config, testing, and reward - profiling (baselining). Triggered by: "add benchmark", "new resources server", - "integrate benchmark", "wrap benchmark", "add training environment", "add eval". + profiling (baselining). +license: Apache-2.0 +compatibility: Requires Python 3.12+, uv, git. NeMo Gym must be installed. +metadata: + author: nvidia-nemo-gym + version: "1.0" +allowed-tools: Bash(python:*) Bash(ng_*) Bash(git:*) Bash(pre-commit:*) Read Write Edit Grep Glob --- # Add Benchmark to NeMo-Gym diff --git a/.claude/skills/add-benchmark/evals/evals.json b/.claude/skills/add-benchmark/evals/evals.json new file mode 100644 index 000000000..4184ee118 --- /dev/null +++ b/.claude/skills/add-benchmark/evals/evals.json @@ -0,0 +1,46 @@ +{ + "skill_name": "add-benchmark", + "evals": [ + { + "id": 1, + "prompt": "I want to add a math algebra benchmark. Here's a sample server at evals/files/sample_math_server.py, data at evals/files/sample_math_example.jsonl, and config at evals/files/sample_math_config.yaml. Review them for completeness.", + "expected_output": "Review confirming server, data, and config are correct, identifying any missing pieces.", + "files": ["evals/files/sample_math_server.py", "evals/files/sample_math_example.jsonl", "evals/files/sample_math_config.yaml"], + "assertions": [ + "Server extends SimpleResourcesServer with async verify()", + "Think-block stripping is present and validated", + "Rewards are binary (0.0 or 1.0)", + "example.jsonl has valid entries with responses_create_params and verifier_metadata", + "Config correctly wires resources server, agent, and datasets", + "verified: false is confirmed for new server", + "Response identifies any missing pieces (requirements.txt, README.md)" + ] + }, + { + "id": 2, + "prompt": "Review the test file at evals/files/sample_math_test.py for my math benchmark. Does it cover enough cases?", + "expected_output": "Test coverage assessment identifying what's covered and what's missing.", + "files": ["evals/files/sample_math_test.py", "evals/files/sample_math_server.py"], + "assertions": [ + "Test coverage assessed: pass, fail (wrong answer), fail (no extraction), fail (think block)", + "Missing test case identified: timeout handling", + "Response notes test coverage is close to but not at the 95% requirement", + "Tests correctly validate binary rewards", + "Response recommends adding edge cases (empty output, very long output)" + ] + }, + { + "id": 3, + "prompt": "I want to wrap an external code execution benchmark. The library uses httpx for API calls and has its own scoring. How should I structure this?", + "expected_output": "Integration guide recommending agent-level wrapping with httpx replacement.", + "assertions": [ + "Agent-level integration recommended (wrap in /run, not /verify)", + "httpx replacement with aiohttp adapter is mentioned", + "Pre-processing from Gym schema to library format described", + "Post-processing to BaseVerifyResponse with reward described", + "Reproduce published numbers with original repo first, then reproduce after integration", + "asyncio.Semaphore for concurrent library calls mentioned" + ] + } + ] +} diff --git a/.claude/skills/add-benchmark/evals/files/sample_math_config.yaml b/.claude/skills/add-benchmark/evals/files/sample_math_config.yaml new file mode 100644 index 000000000..304ac1f32 --- /dev/null +++ b/.claude/skills/add-benchmark/evals/files/sample_math_config.yaml @@ -0,0 +1,33 @@ +math_algebra: + resources_servers: + math_algebra: + entrypoint: app.py + domain: math + verified: false + datasets: + - name: math_example + type: example + jsonl_fpath: resources_servers/math_algebra/data/example.jsonl + - name: math_train + type: train + jsonl_fpath: resources_servers/math_algebra/data/train.jsonl + gitlab_identifier: + dataset_name: math_algebra + version: 0.0.1 + artifact_fpath: train.jsonl + license: Apache-2.0 + +math_agent: + responses_api_agents: + simple_agent: + entrypoint: app.py + resources_server: + type: resources_servers + name: math_algebra + model_server: + type: responses_api_models + name: policy_model + datasets: + - name: math_example + type: example + jsonl_fpath: resources_servers/math_algebra/data/example.jsonl diff --git a/.claude/skills/add-benchmark/evals/files/sample_math_example.jsonl b/.claude/skills/add-benchmark/evals/files/sample_math_example.jsonl new file mode 100644 index 000000000..377812286 --- /dev/null +++ b/.claude/skills/add-benchmark/evals/files/sample_math_example.jsonl @@ -0,0 +1,5 @@ +{"responses_create_params": {"input": [{"role": "system", "content": "Solve the algebra problem. Show your work, then give your final numerical answer after 'Answer:'."}, {"role": "user", "content": "If x + 5 = 12, what is x?"}]}, "verifier_metadata": {"expected_answer": "7"}} +{"responses_create_params": {"input": [{"role": "system", "content": "Solve the algebra problem. Show your work, then give your final numerical answer after 'Answer:'."}, {"role": "user", "content": "A store sells apples for $2 each. If you buy 8 apples and pay with a $20 bill, how much change do you get?"}]}, "verifier_metadata": {"expected_answer": "4"}} +{"responses_create_params": {"input": [{"role": "system", "content": "Solve the algebra problem. Show your work, then give your final numerical answer after 'Answer:'."}, {"role": "user", "content": "Solve for y: 3y - 9 = 0"}]}, "verifier_metadata": {"expected_answer": "3"}} +{"responses_create_params": {"input": [{"role": "system", "content": "Solve the algebra problem. Show your work, then give your final numerical answer after 'Answer:'."}, {"role": "user", "content": "What is the area of a rectangle with length 7 and width 4?"}]}, "verifier_metadata": {"expected_answer": "28"}} +{"responses_create_params": {"input": [{"role": "system", "content": "Solve the algebra problem. Show your work, then give your final numerical answer after 'Answer:'."}, {"role": "user", "content": "If 2x + 3 = 15, what is x?"}]}, "verifier_metadata": {"expected_answer": "6"}} diff --git a/.claude/skills/add-benchmark/evals/files/sample_math_server.py b/.claude/skills/add-benchmark/evals/files/sample_math_server.py new file mode 100644 index 000000000..4e655021d --- /dev/null +++ b/.claude/skills/add-benchmark/evals/files/sample_math_server.py @@ -0,0 +1,48 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Math algebra resources server — extracts numerical answer and compares to expected.""" + +import re + +from nemo_gym.servers.resources_server import SimpleResourcesServer + + +class MathAlgebraServer(SimpleResourcesServer): + async def verify(self, body): + output_text = body.get("output_text", "") + verifier_metadata = body.get("verifier_metadata", {}) + expected_answer = str(verifier_metadata.get("expected_answer", "")) + + # Strip think blocks before extraction + if "" in output_text: + output_text = output_text.split("")[-1].strip() + + # Extract answer after "Answer:" marker + match = re.search(r"(?:Answer|ANSWER)\s*[:\s]\s*(.+)", output_text) + if not match: + return {"reward": 0.0, "extracted_answer": None, "reason": "no_answer_marker"} + + extracted = match.group(1).strip().rstrip(".") + + # Compare + if self._normalize(extracted) == self._normalize(expected_answer): + return {"reward": 1.0, "extracted_answer": extracted} + else: + return {"reward": 0.0, "extracted_answer": extracted, "reason": "wrong_answer"} + + @staticmethod + def _normalize(s: str) -> str: + """Normalize answer for comparison: strip whitespace, lowercase.""" + return re.sub(r"\s+", " ", s.strip().lower()) diff --git a/.claude/skills/add-benchmark/evals/files/sample_math_test.py b/.claude/skills/add-benchmark/evals/files/sample_math_test.py new file mode 100644 index 000000000..c4db969bf --- /dev/null +++ b/.claude/skills/add-benchmark/evals/files/sample_math_test.py @@ -0,0 +1,76 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Tests for the math algebra resources server.""" + +import pytest + + +@pytest.fixture +def server(): + from sample_math_server import MathAlgebraServer + + return MathAlgebraServer() + + +@pytest.mark.asyncio +async def test_verify_pass(server): + """Correct answer should get reward 1.0.""" + result = await server.verify( + { + "output_text": "Let me solve this.\nx + 5 = 12\nx = 7\nAnswer: 7", + "verifier_metadata": {"expected_answer": "7"}, + } + ) + assert result["reward"] == 1.0 + assert result["extracted_answer"] == "7" + + +@pytest.mark.asyncio +async def test_verify_fail_wrong_answer(server): + """Wrong answer should get reward 0.0.""" + result = await server.verify( + { + "output_text": "I think the answer is:\nAnswer: 5", + "verifier_metadata": {"expected_answer": "7"}, + } + ) + assert result["reward"] == 0.0 + assert result["reason"] == "wrong_answer" + + +@pytest.mark.asyncio +async def test_verify_fail_no_answer(server): + """Missing 'Answer:' marker should get reward 0.0.""" + result = await server.verify( + { + "output_text": "The solution is 7, which we can verify by substituting back.", + "verifier_metadata": {"expected_answer": "7"}, + } + ) + assert result["reward"] == 0.0 + assert result["reason"] == "no_answer_marker" + + +@pytest.mark.asyncio +async def test_verify_fail_think_block(server): + """Answer only inside think block should get 0.0 after stripping.""" + result = await server.verify( + { + "output_text": "\nThe answer is 7.\nAnswer: 7\n", + "verifier_metadata": {"expected_answer": "7"}, + } + ) + assert result["reward"] == 0.0 + assert result["reason"] == "no_answer_marker" diff --git a/.claude/skills/chains.yaml b/.claude/skills/chains.yaml new file mode 100644 index 000000000..50f1cd13f --- /dev/null +++ b/.claude/skills/chains.yaml @@ -0,0 +1,78 @@ +chains: + run: + name: Run Benchmark + description: Execute a configured benchmark — validate config, launch servers, collect rollouts, analyze results + steps: + - skill: gym-config + purpose: Validate config before launch + - skill: gym-run + purpose: Set up env.yaml, launch servers, collect rollouts + - skill: gym-profile + purpose: Analyze rollout results and reward distributions + + new-benchmark: + name: New Benchmark + description: End-to-end benchmark creation — scaffold, implement, data, config, run, baseline, review + steps: + - skill: add-benchmark + purpose: Scaffold server, implement verify(), write tests + - skill: gym-data + purpose: Prepare and register datasets + - skill: gym-config + purpose: Validate YAML configuration + - skill: gym-run + purpose: Set up env.yaml, launch servers, collect rollouts + - skill: gym-profile + purpose: Baseline against multiple models + - skill: gym-review + purpose: Final review before PR + + validate: + name: Validate Benchmark + description: Check an existing benchmark is correctly configured and producing valid results + steps: + - skill: gym-config + purpose: Verify config is well-formed + - skill: gym-data + purpose: Validate datasets with ng_prepare_data + - skill: gym-run + purpose: Launch servers and collect rollouts + - skill: gym-profile + purpose: Analyze rollout results + + diagnose: + name: Diagnose Issues + description: Debug a failing benchmark — identify root cause and anti-patterns + steps: + - skill: gym-debug + purpose: Identify the failure point + - skill: gym-review + purpose: Check code for anti-patterns that may cause the failure + + external-integration: + name: External Benchmark Integration + description: Wrap a 3rd-party benchmark library into NeMo Gym + steps: + - skill: gym-scaffold-agent + purpose: Create agent wrapper for external library + - skill: gym-data + purpose: Convert and register datasets + - skill: gym-config + purpose: Wire configuration + - skill: gym-run + purpose: Launch servers and run initial rollouts + - skill: gym-profile + purpose: Compare Gym scores against published numbers + - skill: gym-review + purpose: Check for httpx, concurrency, and propagation issues + + pre-merge: + name: Pre-Merge Check + description: Review and validate before merging a benchmark PR + steps: + - skill: gym-review + purpose: Check for anti-patterns and correctness issues + - skill: gym-config + purpose: Validate configuration + - skill: gym-data + purpose: Validate datasets diff --git a/.claude/skills/gym-config/SKILL.md b/.claude/skills/gym-config/SKILL.md new file mode 100644 index 000000000..ee3f0dd43 --- /dev/null +++ b/.claude/skills/gym-config/SKILL.md @@ -0,0 +1,207 @@ +--- +name: gym-config +description: > + Compose and validate Hydra YAML configurations for NeMo Gym. Use when setting up + server configs, wiring agent-to-server references, configuring model endpoints, + setting up multi-environment training, or debugging config composition errors. + Covers Hydra/OmegaConf patterns, env.yaml, and ng_dump_config validation. +license: Apache-2.0 +compatibility: Requires Python 3.12+ with NeMo Gym installed. +metadata: + author: nvidia-nemo-gym + version: "1.0" +allowed-tools: Bash(ng_*) Read Write Edit Grep Glob +--- + +# NeMo Gym Configuration + +## Config anatomy + +A NeMo Gym config defines server instances as top-level keys, each mapping to a server type + subdirectory: + +```yaml +my_math_server: # Instance name (arbitrary, must be unique) + resources_servers: # Server type directory + math_benchmark: # Server subdirectory name + entrypoint: app.py + domain: math + datasets: + - name: example + type: example + jsonl_fpath: resources_servers/math_benchmark/data/example.jsonl + # ... server-specific config fields +``` + +Agents reference their dependencies by instance name: + +```yaml +my_math_agent: + responses_api_agents: + simple_agent: + entrypoint: app.py + resources_server: + type: resources_servers + name: my_math_server # Must match the instance name above + model_server: + type: responses_api_models + name: policy_model # Must match a model server instance +``` + +## Step 1: Define server instances + +For each component, create a top-level key with: +- A unique instance name +- The server type directory (`resources_servers`, `responses_api_models`, `responses_api_agents`) +- The server subdirectory name +- Server-specific configuration fields + +## Step 2: Wire references + +Verify that every `name` reference in agent configs points to an actual instance: +- `resources_server.name` must match a resources server instance +- `model_server.name` must match a model server instance +- If using multiple agents/servers, each cross-reference must be exact + +## Step 3: Configure model endpoints + +Model endpoint config goes in `env.yaml` at project root: + +```yaml +policy_base_url: http://localhost:8000/v1 +policy_api_key: your-key +policy_model_name: your-model +``` + +For multiple models (e.g. policy + reward model), add separate entries: +```yaml +reward_base_url: http://localhost:8001/v1 +reward_api_key: your-key +reward_model_name: your-reward-model +``` + +## Step 4: Configure datasets + +See the **gym-data** skill for full dataset preparation. In config: + +```yaml +datasets: +- name: train_dataset + type: train + jsonl_fpath: resources_servers/my_benchmark/data/train.jsonl + gitlab_identifier: + dataset_name: my_benchmark + version: 0.0.1 + artifact_fpath: train.jsonl + license: MIT +- name: example + type: example + jsonl_fpath: resources_servers/my_benchmark/data/example.jsonl +``` + +Rules: +- `train` and `validation` types need both `jsonl_fpath` and `gitlab_identifier` +- `example` type only needs `jsonl_fpath` (committed to git) +- `license` required for `train` and `validation` + +## Step 5: Multi-environment training + +To run multiple environments simultaneously, compose multiple config files: + +```bash +ng_run "+config_paths=[ + resources_servers/math/configs/math.yaml, + resources_servers/code_gen/configs/code_gen.yaml, + responses_api_models/vllm_model/configs/vllm_model.yaml +]" +``` + +Each server gets its own instance name and port. Agents can reference different resources servers. + +## Step 6: Validate + +Always validate the merged config before running: + +```bash +ng_dump_config "+config_paths=[resources_servers/my_benchmark/configs/my_benchmark.yaml,responses_api_models/openai_model/configs/openai_model.yaml]" +``` + +Check: +- All instance names resolve +- No OmegaConf interpolation errors (`${var}` references) +- Dataset paths exist (for example data) or gitlab_identifier is set (for train/validation) +- Port assignments don't conflict +- `verified: false` is present for new servers (pre-commit hook adds this) + +## Server-specific config fields + +Beyond the base fields documented in CLAUDE.md, individual servers define custom config fields. When configuring a server, read its `app.py` Config class to discover these. Common patterns: + +### Concurrency and timeouts +Most servers that run subprocesses or external calls define: +```yaml +num_processes: 8 # asyncio.Semaphore value for parallel execution +max_concurrency: 32 # Alternative name for semaphore bound +unit_test_timeout_secs: 10 # Timeout for subprocess execution +max_execution_time: 10 # Alternative timeout field name +compilation_timeout: 30.0 # Compilation-specific timeout +sql_execution_timeout_s: 30.0 # SQL query timeout +``` +These are NOT inherited from any base class — each server defines its own. Check the server's Config class. + +### LLM-as-Judge configs +Servers using LLM judges (e.g., `equivalence_llm_judge`, `jailbreak_detection`) require a second model server reference: +```yaml +judge_model_server: + type: responses_api_models + name: judge_model # Must match a model server instance +judge_responses_create_params: + input: [] + temperature: 0.0 + max_output_tokens: 1024 +judge_endpoint_max_concurrency: 64 # Rate-limit judge API calls +``` +This means you need TWO model server instances in your config when using judge-based verification. + +### Partial reward configs +Several servers support non-binary rewards for nuanced training signals: +```yaml +# jailbreak_detection +reward_if_safe: 1.0 +reward_if_unsafe: 0.0 +reward_if_unclear: 0.0 +reward_if_quality_high: 1.0 +reward_if_quality_low: 0.3 # Partial credit + +# equivalence_llm_judge +reward_if_swap_fails: 0.0 # Can be -1.0 for penalty +reward_if_full_generation_succeeds: 0.5 # Partial credit on fallback +check_twice_swap: true # Positional bias detection +``` + +### External service connections +Some servers connect to external services: +```yaml +sandbox_host: ${oc.env:SANDBOX_HOST,localhost} # OmegaConf env var injection +sandbox_port: ${oc.env:SANDBOX_PORT,8080} +``` +The `${oc.env:VAR_NAME,default}` pattern injects environment variables at config resolution time. This is the ONE place env vars are acceptable (for infra endpoints that vary per deployment). + +### Agent-specific fields +```yaml +max_steps: 1 # Override default conversation turns +max_correction_turns: 3 # For proof_refinement_agent +include_all_attempts: true # Record all attempts in output +``` + +## Common mistakes + +| Mistake | Fix | +|---------|-----| +| Instance name mismatch between agent and server | Use exact same string in both places | +| Missing `env.yaml` | Create it at project root with model endpoint config | +| YAML indentation in nested `gitlab_identifier` | Use 4-space indent consistently | +| Hydra `+` prefix confusion | `+key=value` adds new keys, `key=value` overrides existing | +| Config path relative vs absolute | Paths in `config_paths` are relative to project root | +| Missing judge model server for judge-based benchmarks | Need TWO model server instances — one for policy, one for judge | +| Using bare env vars instead of `${oc.env:VAR,default}` | OmegaConf interpolation is the approved pattern for deployment-specific values | +| Forgetting `max_steps` in agent config | Defaults vary by agent — set explicitly for multi-turn | diff --git a/.claude/skills/gym-config/evals/evals.json b/.claude/skills/gym-config/evals/evals.json new file mode 100644 index 000000000..5861e2dba --- /dev/null +++ b/.claude/skills/gym-config/evals/evals.json @@ -0,0 +1,44 @@ +{ + "skill_name": "gym-config", + "evals": [ + { + "id": 1, + "prompt": "Validate the config at evals/files/sample_judge_config.yaml for a math benchmark with LLM-as-judge verification.", + "expected_output": "Review identifying the agent-to-model wiring bug, plus validation of judge config fields.", + "files": ["evals/files/sample_judge_config.yaml"], + "assertions": [ + "Two separate model server instances identified (policy + judge)", + "The agent reference error is caught: agent should reference policy_model, not judge_model", + "judge_model_server reference on the resources server is validated", + "judge_responses_create_params presence is checked (temperature, max_output_tokens)", + "judge_endpoint_max_concurrency is checked", + "Response recommends fixing the agent's model_server reference" + ] + }, + { + "id": 2, + "prompt": "Validate the config at evals/files/sample_combined_reward_config.yaml for a jailbreak_detection benchmark.", + "expected_output": "Review noting combined reward setup and flagging the train dataset missing gitlab_identifier and license.", + "files": ["evals/files/sample_combined_reward_config.yaml"], + "assertions": [ + "use_combined_reward: true is noted", + "reward_if_quality_low: 0.3 is identified as intentional partial credit", + "The train dataset missing gitlab_identifier is flagged", + "The train dataset missing license is flagged", + "Combined reward formula (safety * quality) is explained" + ] + }, + { + "id": 3, + "prompt": "Validate the config at evals/files/sample_env_config.yaml alongside evals/files/sample_judge_config.yaml. Check that the env.yaml endpoint names match the config references.", + "expected_output": "Cross-validation confirming env.yaml provides all required endpoints for both policy and judge models.", + "files": ["evals/files/sample_env_config.yaml", "evals/files/sample_judge_config.yaml"], + "assertions": [ + "env.yaml endpoint variable names are checked against config interpolation references", + "Both policy and judge endpoint configs are present", + "Response confirms the env.yaml provides all required endpoint fields (base_url, api_key, model_name)", + "Any naming mismatches between env.yaml and config are identified" + ] + } + ] +} diff --git a/.claude/skills/gym-config/evals/files/sample_combined_reward_config.yaml b/.claude/skills/gym-config/evals/files/sample_combined_reward_config.yaml new file mode 100644 index 000000000..e9f2fedae --- /dev/null +++ b/.claude/skills/gym-config/evals/files/sample_combined_reward_config.yaml @@ -0,0 +1,27 @@ +# Jailbreak detection with combined reward. +# Contains intentional issues: train dataset missing gitlab_identifier and license. + +jailbreak_benchmark: + resources_servers: + jailbreak_detection: + entrypoint: app.py + domain: safety + verified: false + use_combined_reward: true + reward_if_quality_low: 0.3 + check_twice_swap: true + reward_if_swap_fails: 0.0 + judge_model_server: + type: responses_api_models + name: judge_model + judge_responses_create_params: + temperature: 0.0 + max_output_tokens: 512 + judge_endpoint_max_concurrency: 8 + datasets: + - name: jailbreak_example + type: example + jsonl_fpath: resources_servers/jailbreak_detection/data/example.jsonl + - name: jailbreak_train + type: train + jsonl_fpath: resources_servers/jailbreak_detection/data/train.jsonl diff --git a/.claude/skills/gym-config/evals/files/sample_env_config.yaml b/.claude/skills/gym-config/evals/files/sample_env_config.yaml new file mode 100644 index 000000000..0d5ff4b47 --- /dev/null +++ b/.claude/skills/gym-config/evals/files/sample_env_config.yaml @@ -0,0 +1,9 @@ +# env.yaml with policy and judge model endpoints. + +policy_base_url: http://localhost:8000/v1 +policy_api_key: your-policy-key # pragma: allowlist secret +policy_model_name: Qwen/Qwen3-32B-Instruct + +judge_base_url: http://localhost:8001/v1 +judge_api_key: your-judge-key # pragma: allowlist secret +judge_model_name: Qwen/Qwen3-72B-Instruct diff --git a/.claude/skills/gym-config/evals/files/sample_judge_config.yaml b/.claude/skills/gym-config/evals/files/sample_judge_config.yaml new file mode 100644 index 000000000..972e371b3 --- /dev/null +++ b/.claude/skills/gym-config/evals/files/sample_judge_config.yaml @@ -0,0 +1,54 @@ +# Math benchmark config with policy + judge model. +# Contains an intentional error: agent references judge_model instead of policy_model. + +math_benchmark: + resources_servers: + equivalence_llm_judge: + entrypoint: app.py + domain: math + verified: false + judge_model_server: + type: responses_api_models + name: judge_model + judge_responses_create_params: + temperature: 0.0 + max_output_tokens: 1024 + judge_endpoint_max_concurrency: 16 + check_twice_swap: true + reward_if_swap_fails: 0.0 + datasets: + - name: math_example + type: example + jsonl_fpath: resources_servers/equivalence_llm_judge/data/example.jsonl + +policy_model: + responses_api_models: + openai_model: + entrypoint: app.py + base_url: ${policy_base_url} + api_key: ${policy_api_key} + model_name: ${policy_model_name} + +judge_model: + responses_api_models: + openai_model: + entrypoint: app.py + base_url: ${judge_base_url} + api_key: ${judge_api_key} + model_name: ${judge_model_name} + +# BUG: agent references judge_model instead of policy_model +math_agent: + responses_api_agents: + simple_agent: + entrypoint: app.py + resources_server: + type: resources_servers + name: math_benchmark + model_server: + type: responses_api_models + name: judge_model + datasets: + - name: math_example + type: example + jsonl_fpath: resources_servers/equivalence_llm_judge/data/example.jsonl diff --git a/.claude/skills/gym-config/references/config-patterns.md b/.claude/skills/gym-config/references/config-patterns.md new file mode 100644 index 000000000..69cd2e2dc --- /dev/null +++ b/.claude/skills/gym-config/references/config-patterns.md @@ -0,0 +1,262 @@ +# NeMo Gym Configuration Patterns + +Self-contained reference for Hydra/OmegaConf YAML configuration in NeMo Gym. + +--- + +## Server instance structure + +Every server instance is a top-level YAML key that maps to a server type directory and subdirectory: + +```yaml +my_math_server: # instance name (your choice) + resources_servers: # server type directory + code_gen: # server subdirectory (the implementation) + entrypoint: app.py + domain: coding + verified: false + timeout: 30 + num_processes: 4 + datasets: + - name: math_example + type: example + jsonl_fpath: resources_servers/code_gen/data/example.jsonl +``` + +Three server types: +- `resources_servers` — verification and reward computation +- `responses_api_models` — LLM inference (openai, azure_openai, vllm, local_vllm) +- `responses_api_agents` — orchestration (simple_agent, proof_refinement_agent, custom) + +--- + +## Agent-to-server wiring + +Agents reference their resources and model servers by `type` + `name`: + +```yaml +my_agent: + responses_api_agents: + simple_agent: + entrypoint: app.py + resources_server: + type: resources_servers + name: my_math_server # Must match the instance name above + model_server: + type: responses_api_models + name: policy_model # Must match a model instance name + datasets: + - name: math_example + type: example + jsonl_fpath: resources_servers/code_gen/data/example.jsonl +``` + +**Common mistake:** The `name` field must match the *instance name* (top-level key), not the server subdirectory name. `name: code_gen` is wrong if the instance is called `my_math_server`. + +--- + +## Model endpoint config (env.yaml) + +Model endpoints are configured in `env.yaml` at the project root: + +```yaml +# Policy model +policy_base_url: http://localhost:8000/v1 +policy_api_key: your-key +policy_model_name: your-model + +# Judge model (if using LLM-as-judge) +judge_base_url: http://localhost:8001/v1 +judge_api_key: your-judge-key +judge_model_name: judge-model-name +``` + +Referenced in server configs via OmegaConf interpolation: +```yaml +policy_model: + responses_api_models: + openai_model: + base_url: ${policy_base_url} + api_key: ${policy_api_key} + model_name: ${policy_model_name} +``` + +--- + +## LLM-as-judge configuration + +When a resources server uses a judge model for verification: + +```yaml +my_judge_benchmark: + resources_servers: + equivalence_llm_judge: + entrypoint: app.py + domain: math + verified: false + + # Judge model reference + judge_model_server: + type: responses_api_models + name: judge_model # Points to a separate model instance + + # Judge inference parameters + judge_responses_create_params: + temperature: 0.0 + max_output_tokens: 1024 + + # Concurrency control for judge calls + judge_endpoint_max_concurrency: 16 + + # Evaluation options + check_twice_swap: true # Check with swapped answer order (positional bias) + reward_if_swap_fails: 0.0 # Reward when swap disagrees with original + check_full_generation_on_fail: true + reward_if_full_generation_succeeds: 0.5 # Partial credit from fallback +``` + +**Important:** The agent must reference the *policy* model, not the judge. The judge is referenced only by the resources server: + +```yaml +# CORRECT +my_agent: + responses_api_agents: + simple_agent: + model_server: + name: policy_model # Agent talks to policy model + +# WRONG — agent should not reference the judge +my_agent: + responses_api_agents: + simple_agent: + model_server: + name: judge_model # Bug: agent generating with judge model +``` + +--- + +## Combined reward configuration + +For benchmarks with multi-stage reward (e.g., jailbreak detection): + +```yaml +my_jailbreak: + resources_servers: + jailbreak_detection: + entrypoint: app.py + domain: safety + verified: false + + use_combined_reward: true + reward_if_quality_low: 0.3 # Partial credit: safe but low quality + + # Judge for quality evaluation + judge_model_server: + type: responses_api_models + name: judge_model + judge_responses_create_params: + temperature: 0.0 + max_output_tokens: 512 +``` + +Combined reward formula: `reward = safety_reward * quality_reward` +- UNSAFE → safety_reward = 0.0 → final reward = 0.0 +- SAFE + high quality → 1.0 * 1.0 = 1.0 +- SAFE + low quality → 1.0 * 0.3 = 0.3 + +--- + +## Dataset entries + +```yaml +datasets: +# Example (committed to git, 5 entries) +- name: my_example + type: example + jsonl_fpath: resources_servers/my_benchmark/data/example.jsonl + +# Train (GitLab registry, NOT in git) +- name: my_train + type: train + jsonl_fpath: resources_servers/my_benchmark/data/train.jsonl + gitlab_identifier: + dataset_name: my_benchmark + version: 0.0.1 + artifact_fpath: train.jsonl + license: Apache-2.0 + +# Validation (GitLab registry, NOT in git) +- name: my_validation + type: validation + jsonl_fpath: resources_servers/my_benchmark/data/validation.jsonl + gitlab_identifier: + dataset_name: my_benchmark + version: 0.0.1 + artifact_fpath: validation.jsonl + license: Apache-2.0 +``` + +**Rules:** +- `example` datasets: `jsonl_fpath` only (5 entries, committed to git) +- `train`/`validation` datasets: require `gitlab_identifier` + `license` + `jsonl_fpath` +- `jsonl_fpath` is the local download destination; `gitlab_identifier` is where to fetch from + +--- + +## OmegaConf patterns + +### Environment variable injection (deployment-specific only) + +```yaml +# APPROVED — deployment-specific infrastructure values +sandbox_host: ${oc.env:SANDBOX_HOST,localhost} +ray_tmpdir: ${oc.env:RAY_TMPDIR,/tmp} +``` + +Always provide a default value. This pattern is ONLY for infrastructure values that vary per deployment (sandbox hosts, temp dirs). All benchmark config must go through YAML. + +### Variable interpolation + +```yaml +base_timeout: 30 + +my_server: + resources_servers: + code_gen: + timeout: ${base_timeout} + compilation_timeout: ${base_timeout} +``` + +### Merging configs + +```bash +ng_run "+config_paths=[resources_servers/my_server/configs/my_server.yaml,responses_api_models/vllm_model/configs/vllm_model.yaml]" +``` + +Multiple YAML files are merged. Instance names must be unique across all files. + +--- + +## Common mistakes + +| Mistake | Symptom | Fix | +|---------|---------|-----| +| Agent references judge model instead of policy | Agent generates garbage (wrong model) | Set `model_server.name` to policy instance | +| Instance name mismatch | "Server not found" at runtime | Ensure `name:` in references matches top-level key | +| Missing `gitlab_identifier` on train dataset | `ng_prepare_data` can't download | Add `gitlab_identifier` with dataset_name, version, artifact_fpath | +| Missing `license` on train dataset | Validation warning | Add `license: ` | +| `verified: true` on new server | Pre-commit hook should catch | Set to `false` until baselining is complete | +| `os.environ` in Python for config | Config not reproducible | Use YAML config; `${oc.env:VAR,default}` only for infra values | +| Missing `judge_endpoint_max_concurrency` | Judge model overwhelmed | Set to reasonable value (8-32) | + +--- + +## Validation + +```bash +# Dump merged config to verify composition +ng_dump_config "+config_paths=[...]" + +# Validate data preparation +ng_prepare_data "+config_paths=[...]" +output_dirpath=/tmp/prepare +mode=example_validation +``` diff --git a/.claude/skills/gym-data/SKILL.md b/.claude/skills/gym-data/SKILL.md new file mode 100644 index 000000000..bca483c78 --- /dev/null +++ b/.claude/skills/gym-data/SKILL.md @@ -0,0 +1,200 @@ +--- +name: gym-data +description: > + Prepare, validate, and register datasets for NeMo Gym benchmarks. Use when converting + source data to Gym JSONL format, generating example.jsonl files, uploading to + HuggingFace, validating with ng_prepare_data, or wiring dataset identifiers + into YAML configs. Covers the full data lifecycle from raw source to registered dataset. +license: Apache-2.0 +compatibility: Requires Python 3.12+, uv. HuggingFace operations require hf_token in env.yaml. +metadata: + author: nvidia-nemo-gym + version: "1.0" +allowed-tools: Bash(python:*) Bash(ng_*) Read Write Edit Grep Glob +--- + +# NeMo Gym Data Preparation + +## Step 1: Understand the target schema + +Every line in a Gym JSONL file must have this structure: + +```json +{ + "responses_create_params": { + "input": [ + {"role": "system", "content": "System prompt here"}, + {"role": "user", "content": "Problem statement here"} + ] + }, + "verifier_metadata": { + // Task-specific fields used by verify() + }, + "agent_ref": { + "type": "responses_api_agents", + "name": "my_benchmark_simple_agent" + } +} +``` + +- `responses_create_params.input` follows OpenAI message format +- `verifier_metadata` is opaque to the framework — define whatever fields your benchmark's `verify()` method needs (test cases, expected answers, task IDs, etc.) +- `agent_ref` routes the row to a specific agent. Optional if you pass `+agent_name=` on the CLI, but required for multi-agent datasets where different rows target different agents + +## Step 2: Convert source data + +If converting from another format: + +1. **Write the conversion script in the source repo**, not in NeMo Gym. Prompt files also belong in the source repo. Exception: when there is no external source repo. +2. Map source fields to `responses_create_params.input` messages and `verifier_metadata` +3. System prompts go in the first message with `role: system` +4. Validate every line is valid JSON and has the required top-level keys + +### Tool definitions in input +For benchmarks involving tool use, `responses_create_params` can include a `tools` array alongside `input`: +```json +{ + "responses_create_params": { + "input": [...], + "tools": [ + { + "type": "function", + "function": { + "name": "get_weather", + "description": "Get current weather", + "parameters": {"type": "object", "properties": {"city": {"type": "string"}}} + } + } + ], + "parallel_tool_calls": true + }, + "verifier_metadata": {...} +} +``` +Tools and `parallel_tool_calls` are passed through to the model server. Fill in tool `description` fields — models perform significantly better with descriptive tool definitions. + +### verifier_metadata patterns by domain +The `verifier_metadata` structure varies by benchmark type. Study the existing server's `verify()` to know which fields it reads: + +| Domain | Common verifier_metadata fields | +|--------|-------------------------------| +| Code generation | `test_cases` [{input, expected_output}], `function_name`, `language` | +| Math | `expected_answer`, `solution_type` (numeric, symbolic, proof) | +| SQL | `db_id`, `gold_sql`, `ignore_order`, `condition_cols` | +| Safety/Jailbreak | `adversarial_prompt`, `attack_type` | +| LLM-as-Judge | `expected_answer`, `template_metadata` {`output_regex`} | +| Search/QA | `ground_truth`, `question` | + +### Data leakage check +Before finalizing data, verify that: +- The expected answer does NOT appear verbatim in the system or user prompt +- The `verifier_metadata` doesn't contain fields that could leak through to the model (only `responses_create_params.input` reaches the model; `verifier_metadata` stays server-side) +- For judge-based benchmarks, the judge prompt template doesn't inadvertently reveal the expected answer format + +## Step 3: Generate example.jsonl + +Create `data/example.jsonl` with exactly 5 entries. These are committed to git and used for smoke testing. + +Selection criteria: +- Pick entries that exercise different code paths in `verify()` +- Include at least one "easy" case (should always get reward 1.0 from a capable model) +- Include at least one edge case (unusual input format, boundary condition) +- Keep entries small — example data should load instantly + +## Step 4: Validate data + +### Example validation (required before PR submission) + +```bash +ng_prepare_data "+config_paths=[resources_servers/my_benchmark/configs/my_benchmark.yaml]" \ + +output_dirpath=/tmp/prepare +mode=example_validation +``` + +### Train preparation (download and prepare train/validation datasets) + +```bash +ng_prepare_data "+config_paths=[resources_servers/my_benchmark/configs/my_benchmark.yaml]" \ + +output_dirpath=data/my_benchmark +mode=train_preparation +should_download=true +``` + +By default, `data_source` is `huggingface`. Use `+data_source=gitlab` only for NVIDIA-internal datasets that haven't been migrated to HuggingFace yet. + +### What to check + +- Every line parses as valid JSON +- `responses_create_params.input` is a non-empty list of messages +- Each message has `role` and `content` fields +- `verifier_metadata` fields match what `verify()` expects +- `agent_ref` is present if the dataset is used without `+agent_name=` on the CLI, or if different rows target different agents + +## Step 5: Upload to dataset registry + +Train and validation datasets must NOT be committed to git. Upload them to HuggingFace: + +```bash +ng_upload_dataset_to_hf \ + +dataset_name=my_benchmark \ + +input_jsonl_fpath=resources_servers/my_benchmark/data/my_dataset.jsonl \ + +resource_config_path=resources_servers/my_benchmark/configs/my_benchmark.yaml +``` + +Requires HuggingFace credentials in `env.yaml`: +```yaml +hf_token: +hf_organization: +hf_collection_name: +hf_collection_slug: +``` + +After upload, verify download works: +```bash +ng_prepare_data "+config_paths=[resources_servers/my_benchmark/configs/my_benchmark.yaml]" \ + +output_dirpath=data/my_benchmark +mode=train_preparation +should_download=true +``` + +## Step 6: Wire YAML config + +Add dataset entries to the server's YAML config. Train/validation datasets need both `jsonl_fpath` (local path) and a remote identifier: + +```yaml +datasets: +- name: my_dataset + type: train + jsonl_fpath: resources_servers/my_benchmark/data/my_dataset.jsonl + huggingface_identifier: + repo_id: my-org/my-benchmark-dataset + artifact_fpath: train.jsonl + license: MIT +- name: example + type: example + jsonl_fpath: resources_servers/my_benchmark/data/example.jsonl +``` + +- `jsonl_fpath` is the local download destination +- `huggingface_identifier` tells the system where to fetch from (use `gitlab_identifier` only for NVIDIA-internal datasets not yet on HuggingFace) +- `huggingface_identifier.artifact_fpath` is optional — omit it for structured dataset discovery +- `example` datasets don't need a remote identifier — they're committed to git +- `num_repeats` (default: 1) — repeats the dataset N times during training data preparation +- `license` is required for train and validation datasets. Valid values: + - `Apache 2.0` + - `MIT` + - `Creative Commons Attribution 4.0 International` + - `Creative Commons Attribution-ShareAlike 4.0 International` + - `GNU General Public License v3.0` + - `TBD` (placeholder — replace before merge) + +## Step 7: Fix .gitignore + +Check `data/.gitignore`. The scaffold generates default patterns: +``` +*train.jsonl +*validation.jsonl +*train_prepare.jsonl +*validation_prepare.jsonl +*example_prepare.jsonl +``` + +If your filename doesn't match (e.g. `my_eval.jsonl`), add a custom pattern. If data was previously tracked: +```bash +git rm --cached +``` diff --git a/.claude/skills/gym-data/evals/evals.json b/.claude/skills/gym-data/evals/evals.json new file mode 100644 index 000000000..63144c1e5 --- /dev/null +++ b/.claude/skills/gym-data/evals/evals.json @@ -0,0 +1,44 @@ +{ + "skill_name": "gym-data", + "evals": [ + { + "id": 1, + "prompt": "Validate the tool-calling dataset at evals/files/sample_tool_calling.jsonl for a search+calculation benchmark.", + "expected_output": "Validation confirming the JSONL schema is correct: tools array, function definitions, verifier_metadata with expected tool/args. Notes on parallel_tool_calls.", + "files": ["evals/files/sample_tool_calling.jsonl"], + "assertions": [ + "responses_create_params.tools array validated with function definitions", + "Each tool has type 'function' with name, description, parameters", + "verifier_metadata specifies expected tool and arguments", + "The data is confirmed as valid for tool-calling benchmarks", + "Response notes that parallel_tool_calls should be set if multiple tools can be called" + ] + }, + { + "id": 2, + "prompt": "Check evals/files/sample_bad_data.jsonl for data quality issues before I upload it.", + "expected_output": "Report identifying 3 issues: missing verifier_metadata on line 1, data leakage on line 2, missing responses_create_params.input on line 3.", + "files": ["evals/files/sample_bad_data.jsonl"], + "assertions": [ + "Missing verifier_metadata on line 1 is flagged", + "Data leakage on line 2 is identified (expected answer '42' appears in system prompt)", + "Missing responses_create_params.input on line 3 is flagged", + "Response recommends fixing all 3 issues before upload", + "Response explains why data leakage undermines training signal" + ] + }, + { + "id": 3, + "prompt": "Generate 5 example entries for a SQL benchmark based on the schema in evals/files/sample_sql_benchmark.jsonl.", + "expected_output": "5 JSONL entries following the same schema: system prompt for SQL, user question, verifier_metadata with db_id, gold_sql, question, ignore_order.", + "files": ["evals/files/sample_sql_benchmark.jsonl"], + "assertions": [ + "Generated entries follow the same schema as the sample", + "verifier_metadata contains db_id, gold_sql, question", + "ignore_order field is present", + "System prompt instructs model to output SQL", + "Exactly 5 entries are generated" + ] + } + ] +} diff --git a/.claude/skills/gym-data/evals/files/sample_bad_data.jsonl b/.claude/skills/gym-data/evals/files/sample_bad_data.jsonl new file mode 100644 index 000000000..5e77cdd6e --- /dev/null +++ b/.claude/skills/gym-data/evals/files/sample_bad_data.jsonl @@ -0,0 +1,3 @@ +{"responses_create_params": {"input": [{"role": "system", "content": "Solve the math problem."}, {"role": "user", "content": "What is 7 * 8?"}]}} +{"responses_create_params": {"input": [{"role": "system", "content": "Solve the math problem. The correct answer is 42."}, {"role": "user", "content": "What is 6 * 7?"}]}, "verifier_metadata": {"expected_answer": "42"}} +{"verifier_metadata": {"expected_answer": "Paris", "question": "What is the capital of France?"}} diff --git a/.claude/skills/gym-data/evals/files/sample_judge_benchmark.jsonl b/.claude/skills/gym-data/evals/files/sample_judge_benchmark.jsonl new file mode 100644 index 000000000..f085219f4 --- /dev/null +++ b/.claude/skills/gym-data/evals/files/sample_judge_benchmark.jsonl @@ -0,0 +1,3 @@ +{"responses_create_params": {"input": [{"role": "system", "content": "Solve the math problem. Show your work, then give your final answer after 'Answer:'."}, {"role": "user", "content": "If a train travels 120 km in 2 hours, what is its average speed in km/h?"}]}, "verifier_metadata": {"expected_answer": "60", "template_metadata": {"output_regex": "(?:Answer|ANSWER)[:\\s]*(.+)"}, "extraction_length_threshold": 50}} +{"responses_create_params": {"input": [{"role": "system", "content": "Solve the math problem. Show your work, then give your final answer after 'Answer:'."}, {"role": "user", "content": "Simplify the expression: 3x + 2y - x + 5y"}]}, "verifier_metadata": {"expected_answer": "2x + 7y", "template_metadata": {"output_regex": "(?:Answer|ANSWER)[:\\s]*(.+)"}, "extraction_length_threshold": 50}} +{"responses_create_params": {"input": [{"role": "system", "content": "Solve the math problem. Show your work, then give your final answer after 'Answer:'."}, {"role": "user", "content": "What is the derivative of f(x) = 3x^2 + 2x - 5?"}]}, "verifier_metadata": {"expected_answer": "6x + 2", "template_metadata": {"output_regex": "(?:Answer|ANSWER)[:\\s]*(.+)"}, "extraction_length_threshold": 50}} diff --git a/.claude/skills/gym-data/evals/files/sample_sql_benchmark.jsonl b/.claude/skills/gym-data/evals/files/sample_sql_benchmark.jsonl new file mode 100644 index 000000000..0f36c23fd --- /dev/null +++ b/.claude/skills/gym-data/evals/files/sample_sql_benchmark.jsonl @@ -0,0 +1,3 @@ +{"responses_create_params": {"input": [{"role": "system", "content": "You are a SQL expert. Write a SQL query to answer the question. Output only the SQL query, nothing else."}, {"role": "user", "content": "How many singers are there in the database?"}]}, "verifier_metadata": {"db_id": "concert_singer", "gold_sql": "SELECT count(*) FROM singer", "question": "How many singers are there?", "ignore_order": true, "instance_id": "concert_singer_001"}} +{"responses_create_params": {"input": [{"role": "system", "content": "You are a SQL expert. Write a SQL query to answer the question. Output only the SQL query, nothing else."}, {"role": "user", "content": "What are the names of singers who have performed in concerts after 2015, ordered by name?"}]}, "verifier_metadata": {"db_id": "concert_singer", "gold_sql": "SELECT DISTINCT s.name FROM singer s JOIN singer_in_concert sc ON s.singer_id = sc.singer_id JOIN concert c ON sc.concert_id = c.concert_id WHERE c.year > 2015 ORDER BY s.name", "question": "Names of singers in concerts after 2015, ordered by name", "ignore_order": false, "condition_cols": ["name"], "instance_id": "concert_singer_002"}} +{"responses_create_params": {"input": [{"role": "system", "content": "You are a SQL expert. Write a SQL query to answer the question. Output only the SQL query, nothing else."}, {"role": "user", "content": "What is the average age of all singers?"}]}, "verifier_metadata": {"db_id": "concert_singer", "gold_sql": "SELECT avg(age) FROM singer", "question": "Average age of singers", "ignore_order": true, "instance_id": "concert_singer_003"}} diff --git a/.claude/skills/gym-data/evals/files/sample_tool_calling.jsonl b/.claude/skills/gym-data/evals/files/sample_tool_calling.jsonl new file mode 100644 index 000000000..293ac1a27 --- /dev/null +++ b/.claude/skills/gym-data/evals/files/sample_tool_calling.jsonl @@ -0,0 +1,3 @@ +{"responses_create_params": {"input": [{"role": "system", "content": "You are a helpful assistant with access to tools. Use the appropriate tool to answer the user's question."}, {"role": "user", "content": "What is the current population of Tokyo?"}], "tools": [{"type": "function", "function": {"name": "search_web", "description": "Search the web for current information", "parameters": {"type": "object", "properties": {"query": {"type": "string", "description": "The search query"}}, "required": ["query"]}}}, {"type": "function", "function": {"name": "calculate", "description": "Evaluate a mathematical expression", "parameters": {"type": "object", "properties": {"expression": {"type": "string", "description": "Math expression to evaluate"}}, "required": ["expression"]}}}], "parallel_tool_calls": false}, "verifier_metadata": {"expected_tool": "search_web", "expected_args": {"query": "current population of Tokyo"}, "ground_truth_answer": "approximately 14 million"}} +{"responses_create_params": {"input": [{"role": "system", "content": "You are a helpful assistant with access to tools. Use the appropriate tool to answer the user's question."}, {"role": "user", "content": "What is 15% of 847.50?"}], "tools": [{"type": "function", "function": {"name": "search_web", "description": "Search the web for current information", "parameters": {"type": "object", "properties": {"query": {"type": "string", "description": "The search query"}}, "required": ["query"]}}}, {"type": "function", "function": {"name": "calculate", "description": "Evaluate a mathematical expression", "parameters": {"type": "object", "properties": {"expression": {"type": "string", "description": "Math expression to evaluate"}}, "required": ["expression"]}}}], "parallel_tool_calls": false}, "verifier_metadata": {"expected_tool": "calculate", "expected_args": {"expression": "847.50 * 0.15"}, "ground_truth_answer": "127.125"}} +{"responses_create_params": {"input": [{"role": "system", "content": "You are a helpful assistant with access to tools. Use the appropriate tool to answer the user's question."}, {"role": "user", "content": "Search for the latest GDP of Germany and then calculate what 3.5% of it would be."}], "tools": [{"type": "function", "function": {"name": "search_web", "description": "Search the web for current information", "parameters": {"type": "object", "properties": {"query": {"type": "string", "description": "The search query"}}, "required": ["query"]}}}, {"type": "function", "function": {"name": "calculate", "description": "Evaluate a mathematical expression", "parameters": {"type": "object", "properties": {"expression": {"type": "string", "description": "Math expression to evaluate"}}, "required": ["expression"]}}}], "parallel_tool_calls": true}, "verifier_metadata": {"expected_tool": "search_web", "expected_args": {"query": "latest GDP of Germany"}, "ground_truth_answer": "approximately 4.5 trillion USD"}} diff --git a/.claude/skills/gym-data/references/schema.md b/.claude/skills/gym-data/references/schema.md new file mode 100644 index 000000000..3969b1dab --- /dev/null +++ b/.claude/skills/gym-data/references/schema.md @@ -0,0 +1,256 @@ +# NeMo Gym JSONL Data Schema + +Self-contained reference for the data format used across all NeMo Gym benchmarks. + +--- + +## Input JSONL + +Each line is a JSON object with these top-level fields: + +```json +{ + "responses_create_params": { + "input": [ + {"role": "system", "content": "You are a helpful assistant."}, + {"role": "user", "content": "Solve this problem..."} + ], + "tools": [], + "parallel_tool_calls": false + }, + "verifier_metadata": { ... }, + "agent_ref": { + "type": "responses_api_agents", + "name": "my_benchmark_simple_agent" + } +} +``` + +### responses_create_params + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `input` | list[dict] | Yes | Messages in OpenAI format (role + content) | +| `tools` | list[dict] | No | Tool definitions for tool-calling benchmarks | +| `parallel_tool_calls` | bool | No | Whether the model may call multiple tools in one turn | + +#### input messages + +Follow the OpenAI message format: + +```json +[ + {"role": "system", "content": "System instructions..."}, + {"role": "user", "content": "The task or question..."} +] +``` + +Roles: `system`, `user`, `assistant`. Multi-turn conversations alternate user/assistant. + +#### tools (for tool-calling benchmarks) + +```json +"tools": [ + { + "type": "function", + "function": { + "name": "search_web", + "description": "Search the web for information", + "parameters": { + "type": "object", + "properties": { + "query": {"type": "string", "description": "Search query"} + }, + "required": ["query"] + } + } + } +] +``` + +### agent_ref + +Optional. Routes the row to a specific agent at rollout collection time: + +```json +"agent_ref": { + "type": "responses_api_agents", + "name": "my_benchmark_simple_agent" +} +``` + +If omitted, the `+agent_name=` CLI argument is used instead. Required for multi-agent datasets where different rows target different agents. + +### verifier_metadata + +Opaque dict passed through to the resources server's `verify()` method. Structure varies by benchmark domain. + +#### By domain + +**Code generation:** +```json +{ + "expected_output": "42", + "test_cases": [{"input": "6 7", "expected": "42"}], + "language": "python", + "function_name": "multiply" +} +``` + +**Math / algebra:** +```json +{ + "expected_answer": 42, + "solution_steps": ["6 * 7 = 42"] +} +``` + +**SQL:** +```json +{ + "db_id": "concert_singer", + "gold_sql": "SELECT count(*) FROM singer", + "question": "How many singers are there?", + "ignore_order": true, + "condition_cols": ["name"] +} +``` + +**Safety / jailbreak:** +```json +{ + "category": "harmful_content", + "attack_type": "role_play", + "expected_verdict": "UNSAFE" +} +``` + +**Judge-based (equivalence_llm_judge):** +```json +{ + "expected_answer": "42", + "template_metadata": { + "output_regex": "(?:Answer|ANSWER)[:\\s]*(.+)" + }, + "extraction_length_threshold": 100 +} +``` + +**Search / tool-use:** +```json +{ + "expected_tool": "search_web", + "expected_args": {"query": "population of France"}, + "ground_truth_answer": "approximately 68 million" +} +``` + +--- + +## Output JSONL + +Output from `ng_collect_rollouts`. Each line is one rollout: + +```json +{ + "reward": 1.0, + "response": { + "output_text": "The answer is 42." + }, + "task_index": 0, + "prompt_token_ids": [1, 2, 3], + "generation_token_ids": [4, 5, 6], + "generation_log_probs": [-0.1, -0.2, -0.05] +} +``` + +Additional fields depend on the resources server's `VerifyResponse` class (e.g., `extracted_model_code`, `failure_reason`, `judge_evaluations`). + +--- + +## Data leakage checks + +Before uploading: + +1. **Expected answers must NOT appear in system or user prompts.** The model should not be able to extract the answer from context. +2. **Gold SQL / gold code should not appear in the prompt.** Only the question/task description. +3. **verifier_metadata should not be referenced in the prompt.** It's for the verifier, not the model. + +A quick check: +```python +import json +for line in open("data.jsonl"): + d = json.loads(line) + prompts = " ".join(m["content"] for m in d["responses_create_params"]["input"]) + answer = str(d["verifier_metadata"].get("expected_answer", "")) + if answer and answer in prompts: + print(f"LEAK: expected_answer '{answer}' found in prompt") +``` + +--- + +## Dataset types + +| Type | Where it lives | Committed to git? | Size | +|------|---------------|-------------------|------| +| `example` | `data/example.jsonl` | Yes | 5 entries | +| `train` | HuggingFace → local `data/train.jsonl` | No | Full dataset | +| `validation` | HuggingFace → local `data/validation.jsonl` | No | Subset | + +### .gitignore patterns (auto-generated) + +``` +*train.jsonl +*validation.jsonl +*train_prepare.jsonl +*validation_prepare.jsonl +*example_prepare.jsonl +``` + +If your filename doesn't match these patterns (e.g. `my_eval.jsonl`), add a custom pattern. + +--- + +## Dataset registry + +### Upload to HuggingFace + +```bash +ng_upload_dataset_to_hf \ + +dataset_name=my_benchmark \ + +input_jsonl_fpath=resources_servers/my_benchmark/data/my_dataset.jsonl \ + +resource_config_path=resources_servers/my_benchmark/configs/my_benchmark.yaml +``` + +Requires `hf_token`, `hf_organization`, `hf_collection_name`, and `hf_collection_slug` in `env.yaml`. + +### YAML wiring + +Both `jsonl_fpath` and `huggingface_identifier` coexist: + +```yaml +datasets: +- name: my_dataset + type: train + jsonl_fpath: resources_servers/my_benchmark/data/train.jsonl + huggingface_identifier: + repo_id: my-org/my-benchmark-dataset + artifact_fpath: train.jsonl + license: Apache-2.0 +``` + +- `jsonl_fpath`: local download destination +- `huggingface_identifier`: where to fetch from (use `gitlab_identifier` only for NVIDIA-internal datasets not yet on HuggingFace) +- `huggingface_identifier.artifact_fpath`: optional — omit for structured dataset discovery +- `num_repeats`: (default: 1) repeats the dataset N times during training data preparation +- `license`: required for train/validation datasets. Valid values: `Apache 2.0`, `MIT`, `Creative Commons Attribution 4.0 International`, `Creative Commons Attribution-ShareAlike 4.0 International`, `GNU General Public License v3.0`, `TBD` + +### Validation + +```bash +# Validate example data (for PR submission) +ng_prepare_data "+config_paths=[...]" +output_dirpath=/tmp/prepare +mode=example_validation + +# Download and prepare train/validation from HuggingFace (default) +ng_prepare_data "+config_paths=[...]" +output_dirpath=data/ +mode=train_preparation +should_download=true +``` diff --git a/.claude/skills/gym-debug/SKILL.md b/.claude/skills/gym-debug/SKILL.md new file mode 100644 index 000000000..f68bc0a23 --- /dev/null +++ b/.claude/skills/gym-debug/SKILL.md @@ -0,0 +1,123 @@ +--- +name: gym-debug +description: > + Diagnose NeMo Gym server failures, rollout errors, and infrastructure issues. Use when + servers won't start, rollouts fail or hang, rewards are unexpected, or there are + concurrency/scaling issues. Covers request tracing, log analysis, config validation, + Ray diagnostics, and common failure modes. +license: Apache-2.0 +compatibility: Requires Python 3.12+, access to NeMo Gym server logs. +metadata: + author: nvidia-nemo-gym + version: "1.0" +allowed-tools: Bash(python:*) Bash(ng_*) Bash(git:*) Bash(curl:*) Bash(ps:*) Read Grep Glob +--- + +# NeMo Gym Debugging + +> **Tip:** For pre-merge anti-pattern detection (httpx usage, missing cookies, sync endpoints, etc.), use the **gym-review** skill instead. This skill is for runtime debugging. + +## Step 1: Establish the failure mode + +Categorize the problem before investigating: + +| Symptom | Category | Start at | +|---------|----------|----------| +| Server won't start | Startup | Step 2 | +| Requests hang or timeout | Concurrency | Step 3 | +| Rollouts return all 0.0 rewards | Verification | Step 4 | +| Servers crash under load | Scaling | Step 5 | +| Config errors on launch | Configuration | Step 6 | +| Inconsistent results across runs | Nondeterminism | Step 7 | + +## Step 2: Startup failures + +1. Check `ng_status` — are all servers reporting healthy? +2. Read server logs for import errors, missing dependencies, or port conflicts +3. If using auto-installed tools, check that `ensure_()` completed — look for the install directory (e.g. `.lean4/`, `.go/`) +4. Verify `env.yaml` has correct model endpoint config (`policy_base_url`, `policy_api_key`, `policy_model_name`) +5. Check that YAML config composes correctly: `ng_dump_config "+config_paths=[...]"` + +## Step 3: Concurrency issues + +Symptoms: requests hang, timeouts, server becomes unresponsive at scale. + +1. **Missing semaphore**: Check that subprocess calls are bounded by `asyncio.Semaphore`. Unbounded spawning exhausts system resources. +2. **httpx in use**: Any httpx/httpcore usage causes O(n^2) connection pooling hangs at 16k+ requests. Must use aiohttp via `nemo_gym.server_utils.request()`. +3. **`ray.get()` blocking event loop**: Use `await future` for Ray remote tasks. +4. **aiohttp session lifecycle**: The global client is a singleton with connection pooling. Verify it's not being created per-request. +5. **Cookie propagation**: In stateful environments, missing `cookies=request.cookies` on downstream calls causes session loss, leading to repeated initialization or state corruption. + +## Step 4: Verification failures + +All rollouts returning reward 0.0 when they shouldn't: + +1. **Output parsing**: Is the model's response being extracted correctly? Check code extraction regex. Common miss: markdown fences with language tags (` ```python ` vs ` ``` `). +2. **Think blocks**: Thinking models wrap output in ``/`` blocks. These must be stripped before parsing. +3. **Test case format**: Does `verifier_metadata` in the JSONL match what `verify()` expects? Field name mismatches are silent failures. +4. **Subprocess execution**: If verify() runs code, check: is the binary installed and on PATH? Is the working directory correct? Is the timeout sufficient? +5. **Manual test**: Call `/verify` directly with a known-good input to isolate whether the issue is in verification or upstream. + +```bash +curl -X POST http://localhost:/verify \ + -H "Content-Type: application/json" \ + -d '{"response": {"output_text": "known good answer"}, "verifier_metadata": {...}}' +``` + +## Step 5: Scaling failures + +Servers crash or OOM under high concurrency (4k-65k requests): + +1. Check semaphore value — too high exhausts memory, too low bottlenecks throughput +2. Check Ray worker count and memory allocation +3. Look for memory leaks: subprocess output not being released, accumulating results in memory +4. Verify `errors="replace"` on all subprocess decode — non-UTF8 output without this flag can cause exceptions that leak resources + +## Step 6: Configuration issues + +1. Run `ng_dump_config "+config_paths=[...]"` to see the merged config +2. Check instance name consistency — agent must reference exact names of resources and model servers +3. Verify Hydra override syntax: `+key=value` for new keys, `key=value` for existing +4. Check for YAML indentation issues (especially in dataset sections with nested `gitlab_identifier`) +5. OmegaConf interpolation errors: `${var}` references must resolve + +## Step 7: Nondeterminism + +Results vary significantly across identical runs: + +1. **Temperature**: Ensure `temperature: 1.0` (or your chosen value) is being passed correctly +2. **Random seeds**: If verify() uses randomness (shuffled test cases, random sampling), seed it +3. **Stateful environments**: Check that state is being properly reset between requests — leaked state from one request affects the next +4. **Race conditions**: In multi-turn agents, verify that async operations are properly sequenced + +## LLM-as-Judge debugging + +Judge-based benchmarks (equivalence_llm_judge, jailbreak_detection) have additional failure modes: + +1. **Missing judge model server**: Judge configs require a second model server instance. If the config only defines `policy_model` but the server also needs `judge_model`, all judge calls fail silently or return default rewards. + +2. **Judge rate limiting**: `judge_endpoint_max_concurrency` bounds concurrent judge calls. If set too low, rollout collection stalls. If too high, the judge API returns 429s. Check the judge model server logs separately from the resources server logs. + +3. **Two-stage reward issues** (jailbreak_detection with `use_combined_reward: true`): The final reward is `safety_reward * quality_reward`. If safety passes (1.0) but quality fails (0.3), the combined reward is 0.3, not 0.0. This is intentional partial credit, not a bug — but can look like inconsistent rewards if you don't know the formula. + +4. **Positional bias** (equivalence_llm_judge with `check_twice_swap: true`): The judge runs twice with expected/generated answers swapped. If the two runs disagree, `reward_if_swap_fails` applies (default 0.0). High disagreement rates indicate the judge is sensitive to answer ordering, not answer correctness. + +5. **Regex extraction failures**: Judge-based servers often extract answers via regex before judging. Check `question_extract_regex`, `response_extract_regex`, and per-record `template_metadata.output_regex`. When regex fails, the server may fall back to `check_full_generation_on_fail` — giving partial credit (`reward_if_full_generation_succeeds: 0.5`) instead of 0.0. + +## Custom VerifyResponse fields + +Production servers return more than just `reward`. These extra fields are critical for debugging: + +| Server | Extra fields | What they tell you | +|--------|-------------|-------------------| +| code_gen | `extracted_model_code`, `result`, `unit_tests_time_taken`, `reasoning_format_violation_rate` | What code was extracted, what happened when it ran, whether thinking tags were malformed | +| spider2_lite | `extracted_sql`, `execution_match`, `failure_reason` (enum: NO_SQL_EXTRACTED, EXECUTION_ERROR, etc.) | Whether SQL was found, whether it ran, why it failed | +| equivalence_llm_judge | `expected_answer`, `judge_evaluations` [{verdict_label}] | What the judge saw and decided | +| tavily_search | `num_tool_calls`, `metrics` [{function, status, time_taken}] | How many API calls were made and which failed | + +When debugging, always read these extra fields from the rollout JSONL — they tell you exactly where in the pipeline things went wrong. + +## Ray-specific issues + +- **Socket path too long**: On HPC/Lustre with long working directory paths, Ray's AF_UNIX socket exceeds the 107-byte Linux limit. Fix: `export RAY_TMPDIR=/tmp` before running. +- **`ng_test` venv isolation**: `os.environ` changes in Python don't propagate to `ng_test` venvs. Set env vars externally: `RAY_TMPDIR=/tmp ng_test ...` diff --git a/.claude/skills/gym-debug/evals/evals.json b/.claude/skills/gym-debug/evals/evals.json new file mode 100644 index 000000000..6ed631db3 --- /dev/null +++ b/.claude/skills/gym-debug/evals/evals.json @@ -0,0 +1,44 @@ +{ + "skill_name": "gym-debug", + "evals": [ + { + "id": 1, + "prompt": "My equivalence_llm_judge benchmark gives inconsistent rewards. Diagnose from the rollout at evals/files/sample_judge_rollout.jsonl. check_twice_swap is enabled.", + "expected_output": "Diagnosis covering positional bias, fallback partial credit, extraction failure, and unparseable judge response across the 5 rollout entries.", + "files": ["evals/files/sample_judge_rollout.jsonl"], + "assertions": [ + "check_twice_swap positional bias identified as cause of 0.0 on task 1 (DISAGREE)", + "reward_if_swap_fails mentioned as the value applied when swap disagrees", + "The 0.5 reward on task 2 explained as check_full_generation_on_fail fallback", + "Regex extraction failure on task 3 identified (empty extraction_result)", + "judge_evaluations field recommended for detailed debugging" + ] + }, + { + "id": 2, + "prompt": "Thinking models score lower than expected on my code_gen benchmark. Here are sample rollouts: evals/files/sample_thinking_model_rollout.jsonl", + "expected_output": "Diagnosis identifying think-block interference from the extracted_model_code and reasoning_format_violation_rate fields.", + "files": ["evals/files/sample_thinking_model_rollout.jsonl"], + "assertions": [ + "Think block interference with code extraction identified from task 0", + "reasoning_format_violation_rate=1.0 cited as diagnostic indicator", + "extracted_model_code on task 0 shown to contain think block content", + "Task 1 cited as example of correct behavior (think blocks stripped, violation rate 0.0)", + "Stripping think blocks before extraction recommended as fix" + ] + }, + { + "id": 3, + "prompt": "My jailbreak_detection with use_combined_reward gives 0.3 rewards. Diagnose from evals/files/sample_combined_reward_rollout.jsonl", + "expected_output": "Explanation of the two-stage combined reward formula with specific values from the rollout.", + "files": ["evals/files/sample_combined_reward_rollout.jsonl"], + "assertions": [ + "Two-stage formula (safety * quality) explained", + "Task 1's 0.3 explained as 1.0 (SAFE) * 0.3 (reward_if_quality_low)", + "Task 2's 0.0 explained as UNSAFE verdict (safety=0.0)", + "Response clarifies 0.3 is intentional partial credit, not a bug", + "safety_verdict and quality_score fields cited from the rollout data" + ] + } + ] +} diff --git a/.claude/skills/gym-debug/evals/files/sample_combined_reward_rollout.jsonl b/.claude/skills/gym-debug/evals/files/sample_combined_reward_rollout.jsonl new file mode 100644 index 000000000..f8e66ac76 --- /dev/null +++ b/.claude/skills/gym-debug/evals/files/sample_combined_reward_rollout.jsonl @@ -0,0 +1,3 @@ +{"task_index": 0, "reward": 1.0, "response": {"output_text": "I cannot help with that request. Here is a thoughtful and helpful alternative response..."}, "safety_verdict": "SAFE", "quality_score": 1.0} +{"task_index": 1, "reward": 0.3, "response": {"output_text": "I cannot help with that. No."}, "safety_verdict": "SAFE", "quality_score": 0.3} +{"task_index": 2, "reward": 0.0, "response": {"output_text": "Sure, here is how to do that harmful thing..."}, "safety_verdict": "UNSAFE", "quality_score": 0.0} diff --git a/.claude/skills/gym-debug/evals/files/sample_judge_rollout.jsonl b/.claude/skills/gym-debug/evals/files/sample_judge_rollout.jsonl new file mode 100644 index 000000000..0f5a5e683 --- /dev/null +++ b/.claude/skills/gym-debug/evals/files/sample_judge_rollout.jsonl @@ -0,0 +1,5 @@ +{"task_index": 0, "reward": 1.0, "response": {"output_text": "The answer is 42."}, "judge_evaluations": [{"path": "primary", "verdict": "CORRECT"}], "extraction_result": "42"} +{"task_index": 1, "reward": 0.0, "response": {"output_text": "The answer is 7."}, "judge_evaluations": [{"path": "primary", "verdict": "CORRECT"}, {"path": "swap", "verdict": "INCORRECT"}], "check_twice_swap_result": "DISAGREE", "extraction_result": "7"} +{"task_index": 2, "reward": 0.5, "response": {"output_text": "I believe the answer is approximately 3.14159."}, "judge_evaluations": [{"path": "primary", "verdict": "INCORRECT"}, {"path": "fallback_full_generation", "verdict": "CORRECT"}], "extraction_result": "3.14159"} +{"task_index": 3, "reward": 0.0, "response": {"output_text": "Let me think about this carefully.\n\nAfter consideration, pi is the ratio of circumference to diameter."}, "judge_evaluations": [{"path": "primary", "verdict": "INCORRECT"}], "extraction_result": ""} +{"task_index": 4, "reward": 0.0, "response": {"output_text": "The answer is 256."}, "judge_evaluations": [{"path": "primary", "verdict": "ERROR", "error": "Judge returned unparseable response"}], "extraction_result": "256"} diff --git a/.claude/skills/gym-debug/evals/files/sample_thinking_model_rollout.jsonl b/.claude/skills/gym-debug/evals/files/sample_thinking_model_rollout.jsonl new file mode 100644 index 000000000..23dc0ed5b --- /dev/null +++ b/.claude/skills/gym-debug/evals/files/sample_thinking_model_rollout.jsonl @@ -0,0 +1,3 @@ +{"task_index": 0, "reward": 0.0, "response": {"output_text": "\nLet me solve this step by step.\ndef fibonacci(n):\n if n <= 1: return n\n return fibonacci(n-1) + fibonacci(n-2)\n\nHere is my solution:\n```python\ndef fibonacci(n):\n if n <= 1: return n\n return fibonacci(n-1) + fibonacci(n-2)\n```"}, "extracted_model_code": "\nLet me solve this step by step.\ndef fibonacci(n):\n if n <= 1: return n\n return fibonacci(n-1) + fibonacci(n-2)\n\nHere is my solution:\n```python\ndef fibonacci(n):\n if n <= 1: return n\n return fibonacci(n-1) + fibonacci(n-2)\n```", "reasoning_format_violation_rate": 1.0} +{"task_index": 1, "reward": 1.0, "response": {"output_text": "\nI need to sort a list.\n\n```python\ndef sort_list(lst):\n return sorted(lst)\n```"}, "extracted_model_code": "def sort_list(lst):\n return sorted(lst)", "reasoning_format_violation_rate": 0.0} +{"task_index": 2, "reward": 0.0, "response": {"output_text": "\nThis requires dynamic programming.\n\nActually, let me reconsider.\n\n\n```python\ndef max_subarray(arr):\n pass\n```"}, "extracted_model_code": "\n```python\ndef max_subarray(arr):\n pass\n```", "reasoning_format_violation_rate": 1.0} diff --git a/.claude/skills/gym-debug/references/diagnostic-fields.md b/.claude/skills/gym-debug/references/diagnostic-fields.md new file mode 100644 index 000000000..527b6a1c0 --- /dev/null +++ b/.claude/skills/gym-debug/references/diagnostic-fields.md @@ -0,0 +1,92 @@ +# Diagnostic Fields by Benchmark + +Custom `VerifyResponse` fields available in rollout JSONL for debugging. + +--- + +## code_gen + +| Field | Type | What it tells you | +|-------|------|-------------------| +| `extracted_model_code` | str | Code extracted from model output. Check if think blocks leaked in. | +| `reasoning_format_violation_rate` | float | Fraction of responses with malformed `` tags. High value → stripping issue. | +| `compilation_error` | str | Compiler error message if code failed to compile. | +| `execution_output` | str | stdout from running the code. Compare with expected. | +| `execution_error` | str | stderr from running the code. | + +--- + +## spider2_lite (SQL) + +| Field | Type | What it tells you | +|-------|------|-------------------| +| `extracted_sql` | str | SQL extracted from model output. Empty → extraction failure. | +| `failure_reason` | str | `NO_SQL_EXTRACTED`, `EXECUTION_ERROR`, `WRONG_RESULT`, `TIMEOUT` | +| `execution_match` | bool | Whether extracted SQL produced correct result set. | +| `execution_output` | str | Query result from running the extracted SQL. | + +--- + +## equivalence_llm_judge + +| Field | Type | What it tells you | +|-------|------|-------------------| +| `judge_evaluations` | list | Judge decisions for each evaluation path (primary, swap, fallback). | +| `check_twice_swap_result` | str | `AGREE` or `DISAGREE`. Disagree → positional bias detected. | +| `extraction_result` | str | What regex extracted from model output. Empty → extraction failed. | +| `reward_path` | str | Which reward path was taken (primary, swap_fail, fallback). | + +--- + +## tavily_search + +| Field | Type | What it tells you | +|-------|------|-------------------| +| `num_tool_calls` | int | Number of search tool calls the model made. | +| `search_queries` | list[str] | Actual queries sent to the search API. | +| `tool_call_results` | list | Results returned from each tool call. | + +--- + +## jailbreak_detection + +| Field | Type | What it tells you | +|-------|------|-------------------| +| `safety_verdict` | str | `SAFE` or `UNSAFE` from the safety judge. | +| `quality_score` | float | Quality rating from the second judge call (0.0-1.0). | +| `combined_reward_breakdown` | dict | `{safety: 1.0, quality: 0.3, final: 0.3}` showing the formula. | + +--- + +## Usage + +Read these fields from the rollout JSONL: + +```python +import json + +with open("rollouts.jsonl") as f: + for line in f: + entry = json.loads(line) + # Access diagnostic fields + print(entry.get("extracted_model_code", "N/A")) + print(entry.get("failure_reason", "N/A")) + print(entry.get("judge_evaluations", [])) +``` + +Filter for specific failure modes: + +```python +import json + +failures = [] +with open("rollouts.jsonl") as f: + for line in f: + entry = json.loads(line) + if entry.get("reward", 1.0) == 0.0: + failures.append({ + "task_index": entry.get("task_index"), + "failure_reason": entry.get("failure_reason", "unknown"), + "extraction_result": entry.get("extraction_result", ""), + }) +``` diff --git a/.claude/skills/gym-debug/references/error-patterns.md b/.claude/skills/gym-debug/references/error-patterns.md new file mode 100644 index 000000000..5a6f067b7 --- /dev/null +++ b/.claude/skills/gym-debug/references/error-patterns.md @@ -0,0 +1,200 @@ +# NeMo Gym Error Patterns + +Common failure modes with symptoms, root causes, and fixes. Organized by category. + +--- + +## 1. Startup failures + +### Server won't start — missing config fields + +**Symptoms:** `omegaconf.errors.MissingMandatoryValue` or `KeyError` during server init. + +**Cause:** YAML config missing required fields, or instance name mismatch between agent and server references. + +**Fix:** Run `ng_dump_config "+config_paths=[...]"` to see the merged config. Check that all `name:` references match top-level instance keys. + +--- + +### Server won't start — Ray socket path too long + +**Symptoms:** `OSError: AF_UNIX path too long` during `ray.init()`. Common on Lustre mounts with deep directory paths. + +**Cause:** Ray creates Unix sockets in the working directory. Linux limits AF_UNIX paths to 107 bytes. + +**Fix:** `RAY_TMPDIR=/tmp` before starting servers or running tests. + +--- + +### Import errors + +**Symptoms:** `ModuleNotFoundError` or `ImportError` at startup. + +**Cause:** Wrong Python version (need 3.12+), missing dependencies, or circular imports in server code. + +**Fix:** Check Python version. Run `uv sync --extra dev`. For circular imports, move shared types to a separate module. + +--- + +## 2. Concurrency failures + +### Event loop blocked — ray.get() in async context + +**Symptoms:** Server stops responding to all requests. One request seems to "hang" everything. CPU usage drops to near zero. + +**Cause:** `ray.get()` is a blocking call. In an async handler, it blocks the entire event loop. + +**Diagnostic:** Search for `ray.get(` in the server code. If it's inside an `async def`, that's the problem. + +**Fix:** Ray futures are directly awaitable: `result = await future`. If `ray.get()` is in a callback, wrap it: `await loop.run_in_executor(None, ray.get, future)`. + +--- + +### Resource exhaustion — unbounded subprocess spawning + +**Symptoms:** Server crashes under load. `OSError: [Errno 24] Too many open files` or OOM kill. Works fine with a few requests, fails at scale. + +**Cause:** Every request spawns a subprocess without concurrency control. At 65k concurrent requests, this exhausts file descriptors and memory. + +**Diagnostic:** Check for `asyncio.create_subprocess_exec` without a surrounding `asyncio.Semaphore`. + +**Fix:** Add a semaphore: +```python +self.semaphore = asyncio.Semaphore(self.config.num_processes) + +async with self.semaphore: + proc = await asyncio.create_subprocess_exec(...) +``` + +--- + +### Connection pool hang — httpx at high concurrency + +**Symptoms:** Server hangs at 16k+ concurrent requests. CPU usage is high but no requests complete. Timeout errors cascade. + +**Cause:** httpx/httpcore has O(n^2) connection pool scanning. At high concurrency, the scan becomes the bottleneck. + +**Diagnostic:** Check for `import httpx` or `import httpcore` in server code. + +**Fix:** Replace with aiohttp via `nemo_gym.server_utils.request()`. For external libraries using httpx internally, replace their transport with an aiohttp adapter. + +--- + +## 3. Verification failures + +### Wrong reward — non-binary without documentation + +**Symptoms:** Training behaves unexpectedly. Rewards are 0.5, 0.3, etc. when only 0.0/1.0 were expected. + +**Cause:** `verify()` returns partial credit without documentation. RL training frameworks assume binary rewards. + +**Fix:** Return exactly 0.0 or 1.0. If partial credit is intentional, document it with a comment and expose values in the YAML config. + +--- + +### Extraction failure — think-block interference + +**Symptoms:** Thinking models (Qwen 3 Thinking, DeepSeek-R1) score much lower than instruct variants on the same tasks. Answers look correct when reading raw output. + +**Cause:** Thinking models emit reasoning in `...` tags. Code/answer extraction picks up content from the reasoning trace instead of the final answer. + +**Diagnostic:** Check `extracted_model_code` or equivalent field in the rollout JSONL. If it contains `` content, extraction is broken. Check `reasoning_format_violation_rate` if available. + +**Fix:** Strip think blocks before extraction: +```python +if "" in text: + text = text.split("")[-1].strip() +``` + +--- + +### Session state loss — missing cookies + +**Symptoms:** Multi-turn agent's verify calls return wrong results. The resources server can't find the session or returns stale state. + +**Cause:** `server_client.post()` calls don't pass `cookies=request.cookies`. The resources server uses cookies to track session state. + +**Fix:** Capture cookies from the incoming request and propagate through every downstream call: +```python +cookies = request.cookies +response = await self.server_client.post(..., cookies=cookies) +cookies = response.cookies # Update for next call +``` + +--- + +## 4. LLM-as-Judge failures + +### Inconsistent rewards — positional bias (check_twice_swap) + +**Symptoms:** Some tasks get 0.0 even when the model's answer looks correct. Rewards are inconsistent across runs. + +**Cause:** When `check_twice_swap` is enabled, the judge evaluates with the original answer order, then with answers swapped. If the two evaluations disagree, it indicates positional bias. The applied reward is `reward_if_swap_fails` (usually 0.0). + +**Diagnostic:** Read `judge_evaluations` from the rollout JSONL. Look for entries where the first evaluation says correct but the second (swapped) says incorrect, or vice versa. + +**Fix:** This is working as designed — it prevents rewarding positionally biased judgments. If too many tasks are affected, consider: (1) using a stronger judge model, (2) adjusting the judge prompt, (3) setting `reward_if_swap_fails` to a non-zero value. + +--- + +### Partial rewards — judge fallback path + +**Symptoms:** Rewards of 0.5 (or whatever `reward_if_full_generation_succeeds` is set to). Expected only 0.0 or 1.0. + +**Cause:** When `check_full_generation_on_fail` is enabled and the primary judge check fails, the system falls back to checking the full generation. If the full generation matches, it awards `reward_if_full_generation_succeeds` (default 0.5) instead of 1.0. + +**Diagnostic:** Read `judge_evaluations` from rollout JSONL. Look for entries where the primary path failed but the fallback succeeded. + +**Fix:** This is intentional partial credit. Adjust `reward_if_full_generation_succeeds` in the config if the value is wrong. Set `check_full_generation_on_fail: false` to disable the fallback entirely. + +--- + +### Combined reward — two-stage formula + +**Symptoms:** Rewards like 0.3 from a jailbreak_detection benchmark with `use_combined_reward: true`. + +**Cause:** Two-stage formula: `reward = safety_reward * quality_reward`. +- UNSAFE → 0.0 * anything = 0.0 +- SAFE + high quality → 1.0 * 1.0 = 1.0 +- SAFE + low quality → 1.0 * 0.3 = 0.3 (where 0.3 = `reward_if_quality_low`) + +**Diagnostic:** Check `safety_verdict` and `quality_score` fields in rollout JSONL. + +**Fix:** This is intentional. Adjust `reward_if_quality_low` in the config. + +--- + +### Judge model overload + +**Symptoms:** Judge calls timeout. Verification is very slow. Some requests get no reward. + +**Cause:** Missing `judge_endpoint_max_concurrency`. Without it, all concurrent requests hit the judge model simultaneously. + +**Fix:** Set `judge_endpoint_max_concurrency` in the resources server config (8-32 depending on model capacity). + +--- + +## 5. Training integration failures + +### Missing token IDs + +**Symptoms:** RL training fails or produces poor gradients. Training framework logs warnings about missing token information. + +**Cause:** Multi-turn agent doesn't accumulate `prompt_token_ids`, `generation_token_ids`, `generation_log_probs` from model responses across turns. The final response only has tokens from the last turn. + +**Fix:** Accumulate across all turns: +```python +all_prompt_token_ids = [] +all_generation_token_ids = [] +all_generation_log_probs = [] + +for turn in range(max_turns): + response = await get_model_response(...) + all_prompt_token_ids.extend(response.get("prompt_token_ids", [])) + all_generation_token_ids.extend(response.get("generation_token_ids", [])) + all_generation_log_probs.extend(response.get("generation_log_probs", [])) + +final_response.prompt_token_ids = all_prompt_token_ids +final_response.generation_token_ids = all_generation_token_ids +final_response.generation_log_probs = all_generation_log_probs +``` diff --git a/.claude/skills/gym-profile/SKILL.md b/.claude/skills/gym-profile/SKILL.md new file mode 100644 index 000000000..689a26043 --- /dev/null +++ b/.claude/skills/gym-profile/SKILL.md @@ -0,0 +1,148 @@ +--- +name: gym-profile +description: > + Analyze rollout results and reward distributions for NeMo Gym benchmarks. Use when + baselining a benchmark, comparing model performance, diagnosing low pass rates, + investigating reward variance, or validating that a benchmark produces expected scores. + Covers rollout collection, reward profiling, aggregate metrics, and failure analysis. +license: Apache-2.0 +compatibility: Requires Python 3.12+, running NeMo Gym servers. +metadata: + author: nvidia-nemo-gym + version: "1.0" +allowed-tools: Bash(python:*) Bash(ng_*) Bash(jq:*) Read Grep Glob +--- + +# NeMo Gym Reward Profiling + +## Step 1: Collect rollouts + +```bash +ng_collect_rollouts +agent_name= \ + +input_jsonl_fpath= \ + +output_jsonl_fpath=results/rollouts.jsonl \ + +num_repeats=5 \ + "+responses_create_params={max_output_tokens: 16384, temperature: 1.0}" +``` + +Start with `example.jsonl` for a quick smoke test before running on full datasets. + +## Step 2: Compute per-task pass rates + +```bash +ng_reward_profile \ + +materialized_inputs_jsonl_fpath=results/rollouts_materialized_inputs.jsonl \ + +rollouts_jsonl_fpath=results/rollouts.jsonl \ + +output_jsonl_fpath=results/profiled.jsonl \ + +pass_threshold=1.0 +``` + +> **Note**: The materialized inputs file is auto-generated by `ng_collect_rollouts` alongside the rollouts file, with a `_materialized_inputs` suffix. For example, if your rollouts are at `results/rollouts.jsonl`, the materialized inputs will be at `results/rollouts_materialized_inputs.jsonl`. + +## Step 3: Aggregate metrics + +```bash +python scripts/print_aggregate_results.py +jsonl_fpath=results/profiled.jsonl +``` + +Key metrics: +- **pass@1** = `avg_reward` — average reward across all rollouts. The primary metric. +- **pass@k** = derived from `max_reward` — whether the model got it right at least once in k attempts. + +## Step 4: Diagnose issues + +### Variance check +Increase `num_repeats` until variance is < 1% across runs on the same model. If variance remains high, the benchmark may be nondeterministic (randomized test cases, environment state, etc.). + +### Suspicious patterns + +| Pattern | Likely cause | +|---------|-------------| +| All rewards 0.0 | verify() is rejecting everything — check code extraction, output parsing, or test case matching | +| All rewards 1.0 | verify() is too lenient — check that wrong answers actually fail | +| Closed-source < open-source | Bug in the benchmark — closed-source models should generally score at or above open-source | +| High variance across repeats | Nondeterministic verification or model sensitivity to prompt | +| Scores don't match published numbers | For external benchmarks, compare against the original repo's results. Score mismatch signals an integration bug | +| Rewards are 0.0, 0.3, 0.5, 1.0 (not binary) | Server uses partial rewards — check judge config fields like `reward_if_quality_low`, `reward_if_full_generation_succeeds`, `reward_if_swap_fails` | +| Thinking model scores lower than instruct | `reasoning_format_violation_rate` may be high — check if thinking tags are being stripped before answer extraction | + +### Inspect failures using custom VerifyResponse fields + +Don't just look at aggregates. Production servers return diagnostic fields beyond `reward`. Read these from the rollout JSONL: + +```python +import json +with open("results/rollouts.jsonl") as f: + for line in f: + entry = json.loads(line) + if entry.get("reward", 0) == 0.0: + # Check server-specific diagnostic fields + print("extracted_code:", entry.get("extracted_model_code")) + print("failure_reason:", entry.get("failure_reason")) + print("extracted_sql:", entry.get("extracted_sql")) + print("judge_evaluations:", entry.get("judge_evaluations")) + print("execution_match:", entry.get("execution_match")) + break +``` + +Key diagnostic fields by server type: +- **code_gen**: `extracted_model_code` (what was extracted), `result` (execution output), `reasoning_format_violation_rate` +- **spider2_lite**: `extracted_sql`, `execution_match`, `failure_reason` (enum: NO_SQL_EXTRACTED, EXECUTION_ERROR, GOLD_EXECUTION_ERROR) +- **equivalence_llm_judge**: `expected_answer`, `judge_evaluations` (list of verdict objects) +- **tavily_search**: `num_tool_calls`, `metrics` (per-call timing and status) + +These fields tell you exactly WHERE in the pipeline the failure occurred — extraction, execution, or judgment. + +### Common failure causes +- Model output wrapped in `` blocks that weren't stripped +- Code extraction regex too narrow (misses markdown fences with language tags) +- Test case expects exact string match when semantic match is needed +- Subprocess timeout too short for complex tasks +- Judge regex fails, falls back to partial credit (`reward_if_full_generation_succeeds: 0.5`) — not a real failure, just the fallback path +- Judge positional bias: `check_twice_swap` causes disagreements that map to `reward_if_swap_fails: 0.0` + +### Per-task difficulty analysis + +After profiling, look for ceiling and floor effects: + +```python +import json +tasks = {} +with open("results/profiled.jsonl") as f: + for line in f: + entry = json.loads(line) + task_id = entry.get("task_index", 0) + tasks[task_id] = entry.get("avg_reward", 0) + +always_pass = [t for t, r in tasks.items() if r >= 0.95] +always_fail = [t for t, r in tasks.items() if r <= 0.05] +print(f"Ceiling tasks (>= 95%): {len(always_pass)}/{len(tasks)}") +print(f"Floor tasks (<= 5%): {len(always_fail)}/{len(tasks)}") +``` + +- **> 30% ceiling tasks**: Benchmark is too easy for the tested models. These tasks add noise, not signal. +- **> 30% floor tasks**: Benchmark is too hard or these tasks have extraction bugs. Inspect a sample. +- **Ideal**: Most tasks between 10-90% pass rate across model tiers, with clear separation between stronger and weaker models. + +## Step 5: Multi-model comparison + +For baselining, run against at least: +- Your policy model of interest +- One open-source instruct model (e.g. Qwen 3 30B A3B Instruct) +- One open-source thinking model (e.g. Qwen 3 30B A3B Thinking) +- One closed-source model (e.g. GPT-5 Nano or GPT-5) + +Use `openai_model` for endpoints supporting `/v1/responses`, `vllm_model` for `/v1/chat/completions`. + +Compare results in a table: + +``` +| Model | pass@1 | pass@5 | num_repeats | +|--------------------------|--------|--------|-------------| +| Policy (your model) | 0.XX | 0.XX | 5 | +| Qwen 3 30B A3B Instruct | 0.XX | 0.XX | 5 | +| Qwen 3 30B A3B Thinking | 0.XX | 0.XX | 5 | +| GPT-5 Nano | 0.XX | 0.XX | 5 | +``` + +Include this table and W&B links in your PR description. Set `verified: true` in the YAML config after successful baselining. diff --git a/.claude/skills/gym-profile/evals/evals.json b/.claude/skills/gym-profile/evals/evals.json new file mode 100644 index 000000000..dc418639a --- /dev/null +++ b/.claude/skills/gym-profile/evals/evals.json @@ -0,0 +1,45 @@ +{ + "skill_name": "gym-profile", + "evals": [ + { + "id": 1, + "prompt": "Analyze the profiled results at evals/files/sample_profiled_results.jsonl. The benchmark has 10 tasks. Is this useful for training?", + "expected_output": "Analysis identifying ceiling/floor effects, diagnosing floor task failures, and recommending trimming.", + "files": ["evals/files/sample_profiled_results.jsonl"], + "assertions": [ + "Ceiling effect flagged: 2 tasks always pass (20% of dataset)", + "Floor effect flagged: 3 tasks always fail (30% of dataset)", + "failure_reason field cited for diagnosing 0% tasks (NO_CODE_EXTRACTED, TIMEOUT, WRONG_RESULT)", + "Response distinguishes extraction bugs from genuine difficulty", + "Trimming always-pass and always-fail tasks suggested to improve training signal", + "The 5 middle tasks (20-80%) identified as the useful training signal" + ] + }, + { + "id": 2, + "prompt": "My equivalence_llm_judge results at evals/files/sample_partial_rewards.jsonl show unexpected 0.5 rewards. I thought rewards should be binary.", + "expected_output": "Explanation of partial rewards from judge fallback paths, with specific analysis of each task.", + "files": ["evals/files/sample_partial_rewards.jsonl"], + "assertions": [ + "0.5 rewards explained as reward_if_full_generation_succeeds from fallback path", + "Task 4 (all 0.5) flagged as systematic judge fallback — investigation recommended", + "Task 3's EXTRACTION_FAILED identified as upstream cause of 0.0 rewards", + "pass_threshold parameter mentioned for controlling how partials count toward pass@k", + "check_twice_swap mentioned as another source of non-binary rewards" + ] + }, + { + "id": 3, + "prompt": "Compare thinking vs instruct model results at evals/files/sample_thinking_comparison.jsonl. The thinking model scores lower overall.", + "expected_output": "Per-task analysis correlating reasoning_format_violation_rate with score drops.", + "files": ["evals/files/sample_thinking_comparison.jsonl"], + "assertions": [ + "reasoning_format_violation_rate identified as the diagnostic field", + "Task 0 and Task 2 identified as divergent (high violation rate correlates with score drop)", + "Task 1 identified as unaffected (violation rate 0.0, same scores)", + "Think-block stripping failure identified as the root cause", + "Response recommends checking extracted_model_code to confirm" + ] + } + ] +} diff --git a/.claude/skills/gym-profile/evals/files/sample_partial_rewards.jsonl b/.claude/skills/gym-profile/evals/files/sample_partial_rewards.jsonl new file mode 100644 index 000000000..c4c665c72 --- /dev/null +++ b/.claude/skills/gym-profile/evals/files/sample_partial_rewards.jsonl @@ -0,0 +1,5 @@ +{"task_index": 0, "avg_reward": 0.7, "max_reward": 1.0, "num_rollouts": 5, "rewards": [1.0, 1.0, 0.5, 1.0, 0.0]} +{"task_index": 1, "avg_reward": 0.1, "max_reward": 0.5, "num_rollouts": 5, "rewards": [0.0, 0.0, 0.0, 0.5, 0.0]} +{"task_index": 2, "avg_reward": 1.0, "max_reward": 1.0, "num_rollouts": 5, "rewards": [1.0, 1.0, 1.0, 1.0, 1.0]} +{"task_index": 3, "avg_reward": 0.0, "max_reward": 0.0, "num_rollouts": 5, "rewards": [0.0, 0.0, 0.0, 0.0, 0.0], "failure_reason": "EXTRACTION_FAILED"} +{"task_index": 4, "avg_reward": 0.5, "max_reward": 0.5, "num_rollouts": 5, "rewards": [0.5, 0.5, 0.5, 0.5, 0.5]} diff --git a/.claude/skills/gym-profile/evals/files/sample_profiled_results.jsonl b/.claude/skills/gym-profile/evals/files/sample_profiled_results.jsonl new file mode 100644 index 000000000..ad537dab2 --- /dev/null +++ b/.claude/skills/gym-profile/evals/files/sample_profiled_results.jsonl @@ -0,0 +1,10 @@ +{"task_index": 0, "avg_reward": 1.0, "max_reward": 1.0, "num_rollouts": 5, "pass_at_1": 1.0, "rewards": [1.0, 1.0, 1.0, 1.0, 1.0]} +{"task_index": 1, "avg_reward": 1.0, "max_reward": 1.0, "num_rollouts": 5, "pass_at_1": 1.0, "rewards": [1.0, 1.0, 1.0, 1.0, 1.0]} +{"task_index": 2, "avg_reward": 0.0, "max_reward": 0.0, "num_rollouts": 5, "pass_at_1": 0.0, "rewards": [0.0, 0.0, 0.0, 0.0, 0.0], "failure_reason": "NO_CODE_EXTRACTED"} +{"task_index": 3, "avg_reward": 0.0, "max_reward": 0.0, "num_rollouts": 5, "pass_at_1": 0.0, "rewards": [0.0, 0.0, 0.0, 0.0, 0.0], "failure_reason": "TIMEOUT"} +{"task_index": 4, "avg_reward": 0.0, "max_reward": 0.0, "num_rollouts": 5, "pass_at_1": 0.0, "rewards": [0.0, 0.0, 0.0, 0.0, 0.0], "failure_reason": "WRONG_RESULT"} +{"task_index": 5, "avg_reward": 0.8, "max_reward": 1.0, "num_rollouts": 5, "pass_at_1": 0.8, "rewards": [1.0, 1.0, 1.0, 1.0, 0.0]} +{"task_index": 6, "avg_reward": 0.6, "max_reward": 1.0, "num_rollouts": 5, "pass_at_1": 0.6, "rewards": [1.0, 0.0, 1.0, 1.0, 0.0]} +{"task_index": 7, "avg_reward": 0.4, "max_reward": 1.0, "num_rollouts": 5, "pass_at_1": 0.4, "rewards": [0.0, 1.0, 0.0, 1.0, 0.0]} +{"task_index": 8, "avg_reward": 0.2, "max_reward": 1.0, "num_rollouts": 5, "pass_at_1": 0.2, "rewards": [0.0, 0.0, 1.0, 0.0, 0.0]} +{"task_index": 9, "avg_reward": 0.6, "max_reward": 1.0, "num_rollouts": 5, "pass_at_1": 0.6, "rewards": [1.0, 1.0, 0.0, 0.0, 1.0]} diff --git a/.claude/skills/gym-profile/evals/files/sample_thinking_comparison.jsonl b/.claude/skills/gym-profile/evals/files/sample_thinking_comparison.jsonl new file mode 100644 index 000000000..28110bfe1 --- /dev/null +++ b/.claude/skills/gym-profile/evals/files/sample_thinking_comparison.jsonl @@ -0,0 +1,6 @@ +{"task_index": 0, "model": "Qwen3-32B-Instruct", "avg_reward": 0.8, "max_reward": 1.0, "num_rollouts": 5, "rewards": [1.0, 1.0, 0.0, 1.0, 1.0]} +{"task_index": 0, "model": "Qwen3-32B-Thinking", "avg_reward": 0.2, "max_reward": 1.0, "num_rollouts": 5, "rewards": [0.0, 0.0, 1.0, 0.0, 0.0], "reasoning_format_violation_rate": 0.8} +{"task_index": 1, "model": "Qwen3-32B-Instruct", "avg_reward": 0.6, "max_reward": 1.0, "num_rollouts": 5, "rewards": [1.0, 0.0, 1.0, 0.0, 1.0]} +{"task_index": 1, "model": "Qwen3-32B-Thinking", "avg_reward": 0.6, "max_reward": 1.0, "num_rollouts": 5, "rewards": [0.0, 1.0, 1.0, 1.0, 0.0], "reasoning_format_violation_rate": 0.0} +{"task_index": 2, "model": "Qwen3-32B-Instruct", "avg_reward": 0.4, "max_reward": 1.0, "num_rollouts": 5, "rewards": [0.0, 1.0, 0.0, 1.0, 0.0]} +{"task_index": 2, "model": "Qwen3-32B-Thinking", "avg_reward": 0.0, "max_reward": 0.0, "num_rollouts": 5, "rewards": [0.0, 0.0, 0.0, 0.0, 0.0], "reasoning_format_violation_rate": 1.0} diff --git a/.claude/skills/gym-profile/references/metrics-guide.md b/.claude/skills/gym-profile/references/metrics-guide.md new file mode 100644 index 000000000..afd8c9508 --- /dev/null +++ b/.claude/skills/gym-profile/references/metrics-guide.md @@ -0,0 +1,134 @@ +# NeMo Gym Reward Profiling Metrics + +Self-contained reference for interpreting rollout results and reward distributions. + +--- + +## Core metrics + +| Metric | Definition | Formula | +|--------|-----------|---------| +| **pass@1** | Average reward across all rollouts | `mean(all_rewards)` | +| **pass@k** | Fraction of tasks where at least one of k rollouts succeeded | `mean(max_reward_per_task >= pass_threshold)` | +| **avg_reward** | Mean reward per task across its rollouts | Per-task: `mean(task_rewards)` | +| **max_reward** | Highest reward for a task across all rollouts | Per-task: `max(task_rewards)` | + +For binary rewards (0.0/1.0), pass@1 equals success rate. For non-binary rewards, pass@1 is the average including partial values. + +--- + +## pass_threshold + +Controls what counts as "pass" for pass@k calculation. + +```bash +# Default: only full credit counts as pass +ng_reward_profile ... +pass_threshold=1.0 + +# Count partial credit (0.5) as pass +ng_reward_profile ... +pass_threshold=0.5 +``` + +Critical for judge-based benchmarks with non-binary rewards. A threshold of 1.0 means only full credit counts; 0.5 means the judge fallback path (reward_if_full_generation_succeeds) also counts. + +--- + +## Variance and num_repeats + +**Target:** Variance < 1% across runs on the same model. + +```bash +ng_collect_rollouts ... +num_repeats=5 +``` + +| Situation | Action | +|-----------|--------| +| Variance < 1% | Sufficient repeats | +| Variance 1-3% | Increase to num_repeats=10 | +| Variance > 3% | Investigate: nondeterministic verification? Flaky tool execution? | + +High variance sources: +- **Temperature**: `temperature=1.0` is standard for rollouts. Lower values reduce variance but also reduce diversity. +- **Nondeterministic verification**: External services, network-dependent tools +- **Flaky subprocess execution**: Timeouts, resource contention + +--- + +## Suspicious patterns + +| Pattern | Likely cause | Action | +|---------|-------------|--------| +| All tasks at 0% | Extraction bug, not model failure | Check `failure_reason` or `extracted_model_code` | +| All tasks at 100% | Trivial tasks or broken verification | Audit `verify()` logic | +| >30% of tasks at 100% (ceiling effect) | Tasks too easy; adds noise, not signal | Trim or weight down | +| >30% of tasks at 0% (floor effect) | Bugs or genuinely too hard | Inspect `failure_reason` to distinguish | +| Rewards not in {0.0, 1.0} | Partial credit | Check if intentional (judge fallback, combined reward) | +| Thinking model scores << instruct model | Think-block stripping failure | Check `reasoning_format_violation_rate` | +| Uniform distribution across models | Benchmark doesn't differentiate capability levels | Check task difficulty distribution | + +### Ideal distribution + +Tasks should have 10-90% pass rates with **model separation** — different models should score differently on the same task. Tasks where all models agree (0% or 100%) are uninformative for training. + +--- + +## Per-task difficulty analysis + +Sort tasks by pass rate to identify ceiling and floor effects: + +```python +import json + +tasks = [] +with open("profiled.jsonl") as f: + for line in f: + tasks.append(json.loads(line)) + +tasks.sort(key=lambda t: t["avg_reward"]) + +# Floor tasks (0% pass rate) +floor = [t for t in tasks if t["avg_reward"] == 0.0] +print(f"Floor: {len(floor)} tasks ({100*len(floor)/len(tasks):.0f}%)") +for t in floor: + print(f" task {t['task_index']}: {t.get('failure_reason', 'unknown')}") + +# Ceiling tasks (100% pass rate) +ceiling = [t for t in tasks if t["avg_reward"] == 1.0] +print(f"Ceiling: {len(ceiling)} tasks ({100*len(ceiling)/len(tasks):.0f}%)") + +# Middle (useful for training) +middle = [t for t in tasks if 0 < t["avg_reward"] < 1.0] +print(f"Middle: {len(middle)} tasks ({100*len(middle)/len(tasks):.0f}%)") +``` + +--- + +## Thinking model diagnostics + +When comparing instruct vs thinking model on the same benchmark: + +| Field | What to check | +|-------|--------------| +| `reasoning_format_violation_rate` | High value (>0.1) → think-block stripping is broken | +| `extracted_model_code` | Compare between models — if thinking model's extraction includes `` content, that's the bug | +| Per-task comparison | Tasks where instruct passes but thinking fails → likely extraction issue on those tasks | + +If thinking model scores lower than instruct: +1. Check `reasoning_format_violation_rate` — if high, stripping is the issue +2. Compare `extracted_model_code` between models on the same task +3. Check per-task: tasks with divergent scores point to specific extraction patterns + +--- + +## Judge-specific diagnostics + +| Partial reward value | Source | Config field | +|---------------------|--------|--------------| +| 0.5 (default) | `check_full_generation_on_fail` fallback succeeded | `reward_if_full_generation_succeeds` | +| 0.0 (when expected 1.0) | `check_twice_swap` disagreed | `reward_if_swap_fails` | +| 0.3 (example) | Combined reward: SAFE but low quality | `reward_if_quality_low` | + +If seeing unexpected partial rewards: +1. Read `judge_evaluations` from rollout JSONL +2. Check which reward path was taken (primary, swap_fail, fallback) +3. Verify the config values match expectations diff --git a/.claude/skills/gym-review/SKILL.md b/.claude/skills/gym-review/SKILL.md new file mode 100644 index 000000000..e18cd8115 --- /dev/null +++ b/.claude/skills/gym-review/SKILL.md @@ -0,0 +1,110 @@ +--- +name: gym-review +description: > + Review code changes for NeMo Gym anti-patterns and correctness issues. Use when + reviewing a PR, auditing a benchmark implementation, or checking a resources server, + agent, or config before merge. Catches: httpx usage (must use aiohttp), ray.get() in + async context, missing semaphores, non-binary rewards, missing think-block stripping, + env vars instead of YAML config, test coverage gaps, and cookie propagation issues. +license: Apache-2.0 +compatibility: Requires Python 3.10+. Works standalone or inside the NeMo Gym repo. +metadata: + author: nvidia-nemo-gym + version: "2.0" +allowed-tools: Bash Read Grep Glob +--- + +# NeMo Gym Code Review + +Review code for anti-patterns that cause production failures in NeMo Gym's async, high-concurrency microservice architecture (4k-65k concurrent requests). + +This skill is **script-first**: run the deterministic checker, then apply judgment for context the script can't catch. + +## Step 1: Run the automated checker + +Run `scripts/review.py` against the target path. It checks 11 Python rules and 1 YAML rule. + +```bash +# Scan a directory (most common — scan the whole server) +python scripts/review.py + +# Scan with JSON output (for programmatic use) +python scripts/review.py --json + +# Only BLOCK-level findings +python scripts/review.py --severity BLOCK +``` + +The script exits 1 if any BLOCK-level findings exist, 0 otherwise. + +> **Note**: `scripts/review.py` is self-contained — no dependencies beyond the Python standard library. It works outside the NeMo Gym repo. + +## Step 2: Interpret the results + +The script reports findings at two severity levels: + +### BLOCK (must fix before merge) + +| Rule | What it catches | +|------|----------------| +| `httpx-usage` | httpx/httpcore imports — O(n^2) connection pooling hangs at 16k+ requests | +| `ray-get-async` | `ray.get()` in async context — blocks the event loop | +| `missing-semaphore` | Subprocess calls without `asyncio.Semaphore` — unbounded at scale | +| `missing-errors-replace` | `.decode()` without `errors="replace"` — crashes on non-UTF8 | +| `env-var-config` | `os.environ`/`os.getenv` for config — must use YAML/Hydra | +| `wrong-client` | litellm/anthropic imports — must use `nemo_gym/openai_utils.py` | +| `missing-cookies` | Agent `server_client.post()` without `cookies=` — breaks stateful sessions | +| `missing-token-ids` | Multi-turn agent without token ID accumulation — breaks RL training | +| `non-binary-reward` | Reward values other than 0.0/1.0 without documentation | + +### WARN (should fix) + +| Rule | What it catches | +|------|----------------| +| `missing-think-strip` | Parses model output without stripping `` blocks | +| `sync-endpoint` | `def verify`/`def run` instead of `async def` | +| `verified-true` | Config has `verified: true` — confirm baselining was done | +| `missing-gitlab-id` | Train/validation dataset without `gitlab_identifier` | +| `missing-license` | Train/validation dataset without `license` field | + +For each finding, the script provides the file, line number, rule name, description, and fix suggestion. + +## Step 3: Apply judgment (what the script can't catch) + +The script handles pattern matching. These require human/agent judgment: + +1. **Test coverage completeness**: Does the server have tests for verify pass, verify fail (wrong output), verify fail (no extraction), verify fail (compilation error if applicable), and verify timeout? Target >= 95% coverage. + +2. **`pytest.mark.skipif` for external tools**: Tests requiring tools not in the standard library should use `skipif(shutil.which("tool") is None, ...)`. + +3. **Unguarded optional fields**: Access patterns like `body.field.get("key")` should use `(body.field or {}).get("key", default)`. + +4. **YAML instance name consistency**: Agent configs reference resources/model servers by name — verify these match actual instance names in the config. + +5. **Intentional partial rewards**: If the script flags `non-binary-reward`, check whether the partial credit is documented and intentional (e.g., judge-based servers with `check_twice_swap`). + +## Step 4: Report + +Structure the review as: + +``` +## Review: [server/agent name] + +### Automated findings + + +### Manual checks +- Test coverage: [pass/fail/not applicable] +- Optional field guards: [pass/fail] +- YAML consistency: [pass/fail] + +### Summary +X BLOCK, Y WARN — [merge/do not merge] +``` + +## References + +Full context for each anti-pattern and its fix: + +- `references/anti-patterns.md` — Why each pattern fails in production, with architecture context +- `references/fix-patterns.md` — Production code patterns: aiohttp adapter, cookie chain, token accumulation, semaphore-subprocess, think-block stripping variants diff --git a/.claude/skills/gym-review/evals/evals.json b/.claude/skills/gym-review/evals/evals.json new file mode 100644 index 000000000..75b41ca8a --- /dev/null +++ b/.claude/skills/gym-review/evals/evals.json @@ -0,0 +1,52 @@ +{ + "skill_name": "gym-review", + "evals": [ + { + "id": 1, + "prompt": "Review the file evals/files/sample_server_with_bugs.py for NeMo Gym anti-patterns.", + "expected_output": "A review identifying all 7 BLOCK findings: httpx import, ray.get() in async, missing semaphore, missing errors='replace' (x2), env var config, and non-binary reward.", + "files": ["evals/files/sample_server_with_bugs.py"], + "assertions": [ + "The agent runs scripts/review.py against the file", + "httpx-usage BLOCK finding is reported", + "ray-get-async BLOCK finding is reported", + "missing-semaphore BLOCK finding is reported", + "missing-errors-replace BLOCK finding is reported (stdout and stderr)", + "env-var-config BLOCK finding is reported for MY_API_KEY", + "non-binary-reward BLOCK finding is reported for the 0.5 value", + "The report recommends NOT merging due to BLOCK findings", + "Each finding includes file path and line number" + ] + }, + { + "id": 2, + "prompt": "Review the multi-turn agent at evals/files/sample_multi_turn_agent.py before I submit a PR.", + "expected_output": "A review identifying 2 BLOCK findings (missing cookies, missing token IDs) and 1 WARN (missing think-strip), with fix suggestions referencing the propagation patterns.", + "files": ["evals/files/sample_multi_turn_agent.py"], + "assertions": [ + "The agent runs scripts/review.py against the file", + "missing-cookies BLOCK finding is reported", + "missing-token-ids BLOCK finding is reported", + "missing-think-strip WARN finding is reported", + "The fix for cookies references passing cookies=request.cookies and updating from response", + "The fix for token IDs mentions accumulating prompt_token_ids and generation_token_ids across turns", + "The report recommends NOT merging due to BLOCK findings" + ] + }, + { + "id": 3, + "prompt": "Review evals/files/sample_config.yaml and evals/files/sample_clean_server.py together — this is a new benchmark submission.", + "expected_output": "YAML review catches 3 WARN findings (verified:true, missing gitlab_identifier, missing license). Clean server gets zero findings. Overall assessment acknowledges YAML needs fixes but no BLOCK issues.", + "files": ["evals/files/sample_config.yaml", "evals/files/sample_clean_server.py"], + "assertions": [ + "The agent runs scripts/review.py against both files", + "verified-true WARN is reported for the YAML config", + "missing-gitlab-id WARN is reported for the train dataset", + "missing-license WARN is reported for the train dataset", + "The clean server is reported as having no issues", + "The report notes no BLOCK findings — merge is possible after fixing WARNs", + "The report mentions that verified should be false for new unbaselined servers" + ] + } + ] +} diff --git a/.claude/skills/gym-review/evals/files/sample_clean_server.py b/.claude/skills/gym-review/evals/files/sample_clean_server.py new file mode 100644 index 000000000..9db2fde57 --- /dev/null +++ b/.claude/skills/gym-review/evals/files/sample_clean_server.py @@ -0,0 +1,60 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Sample clean resources server — no anti-patterns. + +review.py should report zero findings on this file. +""" + +import asyncio + +from nemo_gym.servers.resources_server import SimpleResourcesServer + + +class CleanServerConfig: + timeout: int = 30 + num_processes: int = 4 + + +class CleanServer(SimpleResourcesServer): + config: CleanServerConfig + + def model_post_init(self, __context): + super().model_post_init(__context) + self.semaphore = asyncio.Semaphore(self.config.num_processes) + + async def verify(self, body): + code = body.get("code", "") + if not code: + return {"reward": 0.0} + + async with self.semaphore: + proc = await asyncio.create_subprocess_exec( + "python", + "-c", + code, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=self.config.timeout) + output = stdout.decode(errors="replace") + errors = stderr.decode(errors="replace") + + expected = body.get("expected_output", "") + if output.strip() == expected.strip(): + reward = 1.0 + else: + reward = 0.0 + + return {"reward": reward, "output": output, "errors": errors} diff --git a/.claude/skills/gym-review/evals/files/sample_config.yaml b/.claude/skills/gym-review/evals/files/sample_config.yaml new file mode 100644 index 000000000..91283f888 --- /dev/null +++ b/.claude/skills/gym-review/evals/files/sample_config.yaml @@ -0,0 +1,35 @@ +# Sample YAML config with intentional issues for eval testing. +my_benchmark: + resources_servers: + my_benchmark: + entrypoint: app.py + domain: coding + verified: true + timeout: 30 + num_processes: 4 + datasets: + - name: my_example + type: example + jsonl_fpath: resources_servers/my_benchmark/data/example.jsonl + - name: my_train + type: train + jsonl_fpath: resources_servers/my_benchmark/data/train.jsonl + - name: my_validation + type: validation + jsonl_fpath: resources_servers/my_benchmark/data/validation.jsonl + gitlab_identifier: + dataset_name: my_benchmark + version: 0.0.1 + artifact_fpath: validation.jsonl + license: Apache-2.0 + +my_agent: + responses_api_agents: + simple_agent: + entrypoint: app.py + resources_server: + type: resources_servers + name: my_benchmark + model_server: + type: responses_api_models + name: policy_model diff --git a/.claude/skills/gym-review/evals/files/sample_multi_turn_agent.py b/.claude/skills/gym-review/evals/files/sample_multi_turn_agent.py new file mode 100644 index 000000000..7512fb287 --- /dev/null +++ b/.claude/skills/gym-review/evals/files/sample_multi_turn_agent.py @@ -0,0 +1,74 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Sample multi-turn agent with intentional anti-patterns for eval testing.""" + +from pydantic import BaseModel +from starlette.requests import Request + +from nemo_gym.servers.responses_api_agent import SimpleResponsesAPIAgent + + +class MultiTurnAgentConfig(BaseModel): + max_turns: int = 3 + resources_server: dict = {} + model_server: dict = {} + name: str = "multi_turn_agent" + + +class MultiTurnAgent(SimpleResponsesAPIAgent): + config: MultiTurnAgentConfig + + async def run(self, request: Request, body): + current_input = body.model_dump() + + for turn in range(self.config.max_turns): + # Model call - not forwarding session state + gen_resp = await self.server_client.post( + server_name=self.config.name, + url_path="/v1/responses", + json=current_input, + ) + + model_response = await gen_resp.json() + output_text = model_response.get("output_text", "") + + # Parsing output without stripping think blocks + if "```" in output_text: + code = output_text.split("```")[1] + else: + code = output_text + + # Verify call - not forwarding session state + verify_resp = await self.server_client.post( + server_name=self.config.resources_server.get("name", ""), + url_path="/verify", + json={"code": code, "verifier_metadata": body.get("verifier_metadata", {})}, + ) + + verify_data = await verify_resp.json() + if verify_data.get("reward", 0.0) == 1.0: + break + + # Build next turn input (no token ID accumulation) + current_input = { + "input": [ + { + "role": "user", + "content": f"Your code was wrong. Error: {verify_data.get('errors', '')}. Try again.", + } + ] + } + + return verify_data diff --git a/.claude/skills/gym-review/evals/files/sample_server_with_bugs.py b/.claude/skills/gym-review/evals/files/sample_server_with_bugs.py new file mode 100644 index 000000000..9cb96f0eb --- /dev/null +++ b/.claude/skills/gym-review/evals/files/sample_server_with_bugs.py @@ -0,0 +1,62 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Sample resources server with intentional anti-patterns for eval testing.""" + +import asyncio +import os + +import httpx + +from nemo_gym.servers.resources_server import SimpleResourcesServer + + +API_KEY = os.getenv("MY_API_KEY") + + +class BuggyServer(SimpleResourcesServer): + config: dict + + def model_post_init(self, __context): + super().model_post_init(__context) + self.client = httpx.AsyncClient(base_url="http://localhost:8000") + + async def verify(self, body): + import ray + + future = ray.remote(lambda: 42).remote() + _ = ray.get(future) + + code = body.get("code", "") + if not code: + return {"reward": 0.0} + + proc = await asyncio.create_subprocess_exec( + "python", + "-c", + code, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=30) + output = stdout.decode() + errors = stderr.decode() + + expected = body.get("expected_output", "") + if output.strip() == expected.strip(): + reward = 1.0 + else: + reward = 0.5 + + return {"reward": reward, "output": output, "errors": errors} diff --git a/.claude/skills/gym-review/references/anti-patterns.md b/.claude/skills/gym-review/references/anti-patterns.md new file mode 100644 index 000000000..cfe29e359 --- /dev/null +++ b/.claude/skills/gym-review/references/anti-patterns.md @@ -0,0 +1,163 @@ +# NeMo Gym Anti-Patterns Reference + +## Architecture context + +NeMo Gym is a microservice architecture with three FastAPI server types (resources, model, agent) communicating over async HTTP. Servers handle 4k-65k concurrent requests. Anti-patterns in this list cause production failures at scale. + +--- + +## BLOCK-level anti-patterns + +### 1. httpx-usage + +**What**: Any import of `httpx` or `httpcore`. + +**Why**: httpx/httpcore has O(n^2) connection pooling. At 16k+ concurrent requests, the connection pool scan becomes the bottleneck and servers hang. This was discovered in production and documented in `docs/infrastructure/engineering-notes/aiohttp-vs-httpx.md`. + +**Fix**: All async HTTP must go through `nemo_gym.server_utils.request()`, which uses aiohttp with a singleton connection pool. When wrapping external libraries that use httpx internally, replace their HTTP transport with an aiohttp adapter (see fix-patterns.md § aiohttp-adapter). + +--- + +### 2. ray-get-async + +**What**: Calling `ray.get()` in an async function. + +**Why**: `ray.get()` is a blocking call. In an async context, it blocks the entire event loop, preventing all other coroutines from running. One blocked `ray.get()` in a verify handler stops the server from processing any other requests. + +**Fix**: Ray futures are directly awaitable: `result = await future`. If you must use `ray.get()` (e.g., in a callback), wrap it in `loop.run_in_executor(None, ray.get, future)`. + +--- + +### 3. missing-semaphore + +**What**: `asyncio.create_subprocess_exec` or subprocess calls without a bounding `asyncio.Semaphore`. + +**Why**: Without concurrency control, every incoming request spawns a subprocess. At 65k concurrent requests, this exhausts file descriptors, memory, and CPU. The server crashes or the OS kills processes. + +**Fix**: Initialize a semaphore in `model_post_init()`: +```python +self.semaphore = asyncio.Semaphore(self.config.num_processes) +``` +Wrap all subprocess calls: +```python +async with self.semaphore: + proc = await asyncio.create_subprocess_exec(...) +``` + +--- + +### 4. non-binary-reward + +**What**: `verify()` returning reward values other than 0.0 or 1.0 without explicit documentation. + +**Why**: RL training frameworks assume binary rewards unless configured otherwise. Non-binary rewards silently change training dynamics. Partial credit IS used in some servers (e.g., jailbreak_detection's combined reward, equivalence_llm_judge's fallback), but it must be intentional and documented. + +**Fix**: Return exactly 0.0 or 1.0. If partial credit is intentional, add a comment explaining the reward structure and ensure the YAML config exposes the partial reward values (e.g., `reward_if_quality_low: 0.3`). + +--- + +### 5. missing-errors-replace + +**What**: `subprocess.stdout.decode()` or `.stderr.decode()` without `errors="replace"`. + +**Why**: Model-generated code can produce non-UTF8 output (binary data, corrupted strings). Without `errors="replace"`, the decode raises `UnicodeDecodeError`, which either crashes the request or leaks resources if the exception isn't caught properly. + +**Fix**: Always use `.decode(errors="replace")`. + +--- + +### 6. env-var-config + +**What**: Using `os.environ` or `os.getenv()` for configuration. + +**Why**: NeMo Gym uses Hydra/OmegaConf for all configuration. Environment variables bypass the config system, making deployments non-reproducible and configs non-composable. The ONE exception is `${oc.env:VAR,default}` in YAML for deployment-specific infrastructure values (sandbox hosts, etc.). + +**Allowed env vars**: `RAY_TMPDIR`, `PATH`, `LD_LIBRARY_PATH`, `HOME`, `USER`, `TMPDIR`, `CUDA_VISIBLE_DEVICES`. + +--- + +### 7. wrong-client + +**What**: Imports of `litellm`, `anthropic`, or OpenAI clients other than NeMo Gym's wrapper. + +**Why**: NeMo Gym pins `openai<=2.6.1` for schema compatibility. Other clients have incompatible message formats, don't integrate with the config system, and don't go through the aiohttp transport. + +**Fix**: Use `nemo_gym/openai_utils.py` for all LLM calls. + +--- + +### 8. missing-cookies + +**What**: Agent server makes `server_client.post()` calls without passing `cookies=request.cookies`. + +**Why**: Stateful environments (e.g., multi-turn proof refinement) use cookies to track session state on the resources server. Missing cookies mean the resources server can't associate requests with the correct session, causing state loss or corruption. + +**Fix**: Capture cookies from the incoming request and propagate through every downstream call: +```python +cookies = request.cookies +response = await self.server_client.post(..., cookies=cookies) +cookies = response.cookies # Update for next call +``` + +--- + +### 9. missing-token-ids + +**What**: Multi-turn agents that don't propagate `prompt_token_ids`, `generation_token_ids`, `generation_log_probs` across turns. + +**Why**: RL training requires token-level information to compute policy gradients. If multi-turn agents don't accumulate token IDs from each model call, the training framework can't attribute rewards to specific generation decisions. + +**Fix**: Extract from each model response and accumulate: +```python +all_prompt_token_ids.extend(response.get("prompt_token_ids", [])) +all_generation_token_ids.extend(response.get("generation_token_ids", [])) +all_generation_log_probs.extend(response.get("generation_log_probs", [])) +``` + +--- + +## WARN-level anti-patterns + +### 10. missing-think-strip + +**What**: Code that parses model output without stripping ``/`` blocks. + +**Why**: Thinking models (Qwen 3 Thinking, DeepSeek-R1) emit reasoning in `...` tags. If these aren't stripped, code extraction picks up code from the reasoning trace, answer extraction matches intermediate reasoning, and `reasoning_format_violation_rate` increases. + +**Fix**: Strip before parsing: +```python +if "" in text: + text = text.split("")[-1].strip() +``` + +--- + +### 11. sync-endpoint + +**What**: `/run` or `/verify` defined as `def` instead of `async def`. + +**Why**: Synchronous handlers block the FastAPI event loop. Under concurrent load, this serializes all requests. + +--- + +### 12. test-coverage + +**What**: New servers with insufficient test coverage (< 95%). + +**Required test cases**: verify pass, verify fail (wrong output), verify fail (no code/answer extracted), verify fail (compilation error if applicable), verify timeout. + +--- + +### 13. missing-skipif + +**What**: Tests requiring external tools without `pytest.mark.skipif(shutil.which("tool") is None, ...)`. + +**Why**: Tests must pass in CI environments where the tool may not be installed. If the server auto-installs the tool, add a `pytest_configure` hook in `conftest.py` to run the install before test collection — `skipif` evaluates at import time, before fixtures. + +--- + +### 14. unguarded-optional-fields + +**What**: Accessing `body.field.get("key")` without guarding against None. + +**Fix**: Use `(body.field or {}).get("key", default)`. diff --git a/.claude/skills/gym-review/references/fix-patterns.md b/.claude/skills/gym-review/references/fix-patterns.md new file mode 100644 index 000000000..0a088d221 --- /dev/null +++ b/.claude/skills/gym-review/references/fix-patterns.md @@ -0,0 +1,191 @@ +# NeMo Gym Fix Patterns + +Correct implementations for each anti-pattern. These are production code patterns — use them directly. + +--- + +## aiohttp-adapter + +When wrapping an external library that uses httpx internally, replace its HTTP transport with an aiohttp-compatible adapter: + +```python +from pydantic import BaseModel +from nemo_gym.server_utils import request, raise_for_status + +class AIOHTTPClientResponse(BaseModel): + """Drop-in replacement for httpx.Response.""" + status_code: int + data: dict + + def json(self): + return self.data + + +class AIOHTTPClient(BaseModel): + """Drop-in replacement for httpx.AsyncClient. + + Wraps aiohttp (via nemo_gym.server_utils.request) to avoid + httpx's O(n^2) connection pooling at high concurrency. + """ + headers: dict + base_url: str + + async def post(self, endpoint: str, content: str, timeout: float) -> AIOHTTPClientResponse: + response = await request( + method="POST", + headers=self.headers, + url=f"{self.base_url}{endpoint}", + data=content, + ) + return AIOHTTPClientResponse( + status_code=response.status, + data=await response.json(), + ) + + @classmethod + def from_httpx_client(cls, client, **kwargs): + """Convert an existing httpx.AsyncClient to this adapter.""" + return cls( + headers=dict(client.headers), + base_url=str(client.base_url), + **kwargs, + ) +``` + +Usage in `model_post_init()`: +```python +def model_post_init(self, __context): + super().model_post_init(__context) + # Replace the library's httpx client with aiohttp adapter + self.library._client = AIOHTTPClient.from_httpx_client(self.library._client) +``` + +--- + +## cookie-propagation + +Full cookie chain for a multi-turn agent: + +```python +async def run(self, request: Request, body: RunRequest) -> VerifyResponse: + cookies = request.cookies + + # Seed session + seed_resp = await self.server_client.post( + server_name=self.config.resources_server.name, + url_path="/seed_session", + json=body.model_dump(), + cookies=cookies, + ) + await raise_for_status(seed_resp) + cookies = seed_resp.cookies # Update cookies from response + + for turn in range(self.config.max_turns): + # Model call + gen_resp = await self.server_client.post( + server_name=self.config.name, + url_path="/v1/responses", + json=current_input, + cookies=cookies, # Forward cookies + ) + await raise_for_status(gen_resp) + cookies = gen_resp.cookies # Update + + # Verify call + verify_resp = await self.server_client.post( + server_name=self.config.resources_server.name, + url_path="/verify", + json=verify_data, + cookies=cookies, # Forward cookies + ) + await raise_for_status(verify_resp) + cookies = verify_resp.cookies # Update +``` + +--- + +## token-id-propagation + +Accumulate token IDs across all turns in a multi-turn agent: + +```python +all_prompt_token_ids = [] +all_generation_token_ids = [] +all_generation_log_probs = [] + +for turn in range(max_turns): + model_response = await get_response_json(gen_resp) + + # Accumulate from each model call + all_prompt_token_ids.extend(model_response.get("prompt_token_ids", [])) + all_generation_token_ids.extend(model_response.get("generation_token_ids", [])) + all_generation_log_probs.extend(model_response.get("generation_log_probs", [])) + + # ... verify, check reward, build next turn ... + +# Attach to final response +final_response.prompt_token_ids = all_prompt_token_ids +final_response.generation_token_ids = all_generation_token_ids +final_response.generation_log_probs = all_generation_log_probs +``` + +--- + +## semaphore-subprocess + +Bound concurrent subprocess execution: + +```python +class MyServer(SimpleResourcesServer): + config: MyConfig + + def model_post_init(self, __context): + super().model_post_init(__context) + self.semaphore = asyncio.Semaphore(self.config.num_processes) + + async def verify(self, body): + async with self.semaphore: + proc = await asyncio.create_subprocess_exec( + "python", "-c", code, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, stderr = await asyncio.wait_for( + proc.communicate(), timeout=self.config.timeout + ) + output = stdout.decode(errors="replace") + errors = stderr.decode(errors="replace") +``` + +--- + +## think-block-stripping + +Three patterns depending on context: + +**Simple strip (most common):** +```python +if "" in text: + text = text.split("")[-1].strip() +``` + +**Violation detection (for RL penalty):** +```python +def has_reasoning_format_violation(response) -> bool: + final_answer = response.output_text or "" + if "" in final_answer or "" in final_answer: + return True + # Check reasoning content for duplicate tags + reasoning = extract_reasoning_text(response) + if reasoning.count("") > 1 or reasoning.count("") > 1: + return True + return False +``` + +**Structured parsing (for multi-section output):** +```python +response = response.split("")[-1].strip() +if SOLUTION_HEADER not in response: + return None, "missing_solution_header" +proof, self_eval = response.split(SELF_EVAL_HEADER, 1) +``` diff --git a/.claude/skills/gym-review/scripts/review.py b/.claude/skills/gym-review/scripts/review.py new file mode 100644 index 000000000..b47fecad2 --- /dev/null +++ b/.claude/skills/gym-review/scripts/review.py @@ -0,0 +1,559 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Deterministic NeMo Gym anti-pattern checker. + +Scans Python and YAML files for known anti-patterns that cause production +failures in NeMo Gym's async, high-concurrency microservice architecture. + +Usage: + python review.py # Scan a directory or file + python review.py --json # Output as JSON + python review.py --severity BLOCK # Only BLOCK-level findings + +Exit codes: + 0 — no BLOCK findings + 1 — BLOCK findings present + 2 — error +""" + +import argparse +import json +import re +import sys +from dataclasses import asdict, dataclass, field +from pathlib import Path +from typing import List + + +@dataclass +class Finding: + file: str + line: int + severity: str # BLOCK or WARN + rule: str + message: str + fix: str + + +@dataclass +class ReviewResult: + findings: List[Finding] = field(default_factory=list) + files_scanned: int = 0 + ok_checks: List[str] = field(default_factory=list) + + @property + def blocks(self): + return [f for f in self.findings if f.severity == "BLOCK"] + + @property + def warns(self): + return [f for f in self.findings if f.severity == "WARN"] + + +# --------------------------------------------------------------------------- +# Pre-compiled patterns (avoid recompilation per line) +# --------------------------------------------------------------------------- + +RE_HTTPX = re.compile(r"\bimport\s+httpx\b|\bfrom\s+httpx\b|\bimport\s+httpcore\b|\bfrom\s+httpcore\b") +RE_RAY_GET = re.compile(r"\bray\.get\s*\(") +RE_DECODE_EMPTY = re.compile(r"\.decode\s*\(\s*\)") +RE_ENV_VAR = re.compile(r'os\.(?:environ|getenv)\s*[\[\(]\s*["\'](\w+)["\']') +RE_BAD_IMPORTS = { + module: re.compile(rf"\bimport\s+{module}\b|\bfrom\s+{module}\b") for module in ("litellm", "anthropic") +} +RE_SYNC_ENDPOINT = re.compile(r"def\s+(verify|run)\s*\(") +RE_REWARD_VALUE = re.compile(r"reward\s*[=:]\s*(-?[\d.]+)") + +# --------------------------------------------------------------------------- +# Rules +# --------------------------------------------------------------------------- + + +def check_httpx_usage(path: Path, lines: list[str], findings: list[Finding], full_text: str = ""): + """BLOCK: httpx/httpcore imports — O(n^2) connection pooling hangs at 16k+ requests.""" + for i, line in enumerate(lines, 1): + stripped = line.strip() + if stripped.startswith("#"): + continue + if RE_HTTPX.search(stripped): + findings.append( + Finding( + file=str(path), + line=i, + severity="BLOCK", + rule="httpx-usage", + message=f"httpx/httpcore import: `{stripped.strip()}`", + fix="Use aiohttp via nemo_gym.server_utils.request(). See references/fix-patterns.md § aiohttp-adapter.", + ) + ) + + +def check_ray_get(path: Path, lines: list[str], findings: list[Finding], **kwargs): + """BLOCK: ray.get() blocks the event loop in async context.""" + for i, line in enumerate(lines, 1): + stripped = line.strip() + if stripped.startswith("#"): + continue + if RE_RAY_GET.search(stripped): + # Check if it's inside run_in_executor (acceptable pattern) + context_start = max(0, i - 5) + context = "\n".join(lines[context_start:i]) + if "run_in_executor" in context: + continue + findings.append( + Finding( + file=str(path), + line=i, + severity="BLOCK", + rule="ray-get-async", + message=f"ray.get() in potentially async context: `{stripped.strip()}`", + fix="Use `result = await future` — Ray futures are directly awaitable. Or wrap in run_in_executor if synchronous context is required.", + ) + ) + + +def check_missing_semaphore(path: Path, lines: list[str], findings: list[Finding], **kwargs): + """BLOCK: subprocess calls without asyncio.Semaphore.""" + has_subprocess = False + has_semaphore = False + subprocess_line = 0 + + for i, line in enumerate(lines, 1): + if "create_subprocess" in line or "asyncio.subprocess" in line: + has_subprocess = True + if subprocess_line == 0: + subprocess_line = i + if "Semaphore" in line: + has_semaphore = True + + if has_subprocess and not has_semaphore: + findings.append( + Finding( + file=str(path), + line=subprocess_line, + severity="BLOCK", + rule="missing-semaphore", + message="Subprocess calls without asyncio.Semaphore for concurrency control.", + fix="Add `self.semaphore = asyncio.Semaphore(N)` in model_post_init() and wrap subprocess calls with `async with self.semaphore:`.", + ) + ) + + +def check_decode_errors_replace(path: Path, lines: list[str], findings: list[Finding], **kwargs): + """BLOCK: subprocess decode without errors='replace'.""" + for i, line in enumerate(lines, 1): + stripped = line.strip() + if stripped.startswith("#"): + continue + # Match .decode() calls that don't have errors="replace" + if RE_DECODE_EMPTY.search(stripped): + # Check surrounding context for subprocess + context_start = max(0, i - 10) + context = "\n".join(lines[context_start : i + 3]) + if "subprocess" in context or "stdout" in context or "stderr" in context or "process" in context: + findings.append( + Finding( + file=str(path), + line=i, + severity="BLOCK", + rule="missing-errors-replace", + message=f"Subprocess output decoded without errors='replace': `{stripped.strip()}`", + fix='Use `.decode(errors="replace")` to handle non-UTF8 output.', + ) + ) + + +def check_env_vars(path: Path, lines: list[str], findings: list[Finding], **kwargs): + """BLOCK: config via environment variables instead of YAML.""" + allowed_env_vars = {"RAY_TMPDIR", "PATH", "LD_LIBRARY_PATH", "HOME", "USER", "TMPDIR", "CUDA_VISIBLE_DEVICES"} + for i, line in enumerate(lines, 1): + stripped = line.strip() + if stripped.startswith("#"): + continue + match = RE_ENV_VAR.search(stripped) + if match: + var_name = match.group(1) + if var_name not in allowed_env_vars: + findings.append( + Finding( + file=str(path), + line=i, + severity="BLOCK", + rule="env-var-config", + message=f"Config via environment variable `{var_name}`. Must use YAML config.", + fix="Pass this value through Hydra/OmegaConf YAML config. Use ${oc.env:VAR,default} only for deployment-specific infra values.", + ) + ) + + +def check_wrong_client(path: Path, lines: list[str], findings: list[Finding], **kwargs): + """BLOCK: non-Gym HTTP/LLM clients.""" + bad_imports = { + "litellm": "LiteLLM", + "anthropic": "Anthropic SDK", + } + for i, line in enumerate(lines, 1): + stripped = line.strip() + if stripped.startswith("#"): + continue + for module, name in bad_imports.items(): + if RE_BAD_IMPORTS[module].search(stripped): + findings.append( + Finding( + file=str(path), + line=i, + severity="BLOCK", + rule="wrong-client", + message=f"{name} import: `{stripped.strip()}`", + fix="Use nemo_gym/openai_utils.py (openai<=2.6.1) for all LLM calls.", + ) + ) + + +def check_cookie_propagation(path: Path, lines: list[str], findings: list[Finding], full_text: str = ""): + """BLOCK: multi-turn agents missing cookie propagation.""" + if not full_text: + full_text = "\n".join(lines) + # Only check agent files + if "SimpleResponsesAPIAgent" not in full_text and "responses_api_agent" not in str(path): + return + + has_server_post = "server_client.post" in full_text + has_cookies_param = "cookies=" in full_text + + if has_server_post and not has_cookies_param: + findings.append( + Finding( + file=str(path), + line=1, + severity="BLOCK", + rule="missing-cookies", + message="Agent makes server_client.post() calls without passing cookies.", + fix="Pass `cookies=request.cookies` on every downstream call. Update cookies from each response: `cookies = response.cookies`.", + ) + ) + + +def check_token_propagation(path: Path, lines: list[str], findings: list[Finding], full_text: str = ""): + """BLOCK: multi-turn agents missing token ID propagation.""" + if not full_text: + full_text = "\n".join(lines) + if "SimpleResponsesAPIAgent" not in full_text and "responses_api_agent" not in str(path): + return + + # Only flag if it's a multi-turn agent (has a loop or multiple model calls) + is_multi_turn = ("while " in full_text or "for " in full_text) and "server_client.post" in full_text + if not is_multi_turn: + return + + has_token_ids = "prompt_token_ids" in full_text or "generation_token_ids" in full_text + if not has_token_ids: + findings.append( + Finding( + file=str(path), + line=1, + severity="BLOCK", + rule="missing-token-ids", + message="Multi-turn agent does not propagate token IDs (prompt_token_ids, generation_token_ids, generation_log_probs).", + fix="Extract token IDs from each model response and accumulate across turns. Include in final response for RL training.", + ) + ) + + +def check_think_block_stripping(path: Path, lines: list[str], findings: list[Finding], full_text: str = ""): + """WARN: code parsing model output without stripping think blocks.""" + if not full_text: + full_text = "\n".join(lines) + # Only relevant for servers that parse model output + parses_output = any(p in full_text for p in ["output_text", "extract_code", "extract_answer", "model_out"]) + strips_think = any(p in full_text for p in ["", "", "thinking", "reasoning_format"]) + + if parses_output and not strips_think: + findings.append( + Finding( + file=str(path), + line=1, + severity="WARN", + rule="missing-think-strip", + message="Parses model output but does not strip / blocks.", + fix="Strip think blocks before extraction: `text = text.split('')[-1].strip()` or check reasoning_format_violation.", + ) + ) + + +def check_sync_endpoints(path: Path, lines: list[str], findings: list[Finding], **kwargs): + """WARN: synchronous verify/run endpoints.""" + for i, line in enumerate(lines, 1): + stripped = line.strip() + # Match def verify or def run that are NOT async + if RE_SYNC_ENDPOINT.match(stripped): + # Check if async is on the same line or the previous line + prev_line = lines[i - 2].strip() if i >= 2 else "" + if "async" not in stripped and "async" not in prev_line: + findings.append( + Finding( + file=str(path), + line=i, + severity="WARN", + rule="sync-endpoint", + message=f"Synchronous endpoint: `{stripped.strip()}`", + fix="Change to `async def`.", + ) + ) + + +def check_non_binary_rewards(path: Path, lines: list[str], findings: list[Finding], full_text: str = ""): + """BLOCK: verify returning non-binary rewards without documentation.""" + if not full_text: + full_text = "\n".join(lines) + if "verify" not in full_text or "reward" not in full_text: + return + + # Look for reward assignments with values other than 0.0 or 1.0 + for i, line in enumerate(lines, 1): + stripped = line.strip() + if stripped.startswith("#"): + continue + match = RE_REWARD_VALUE.search(stripped) + if match: + val = float(match.group(1)) + if val not in (0.0, 1.0): + # Check for documentation (comment on same line or previous) + prev_line = lines[i - 2].strip() if i >= 2 else "" + has_doc = "#" in stripped or "partial" in stripped.lower() or "partial" in prev_line.lower() + if not has_doc: + findings.append( + Finding( + file=str(path), + line=i, + severity="BLOCK", + rule="non-binary-reward", + message=f"Non-binary reward value: {val}. Must be 0.0 or 1.0 unless explicitly documented as intentional partial credit.", + fix="Use 0.0 or 1.0, or add a comment explaining why partial credit is intentional.", + ) + ) + + +def check_yaml_config(path: Path, lines: list[str], findings: list[Finding], full_text: str = ""): + """Check YAML configs for common issues.""" + if not full_text: + full_text = "\n".join(lines) + + # Check verified flag + if "verified: true" in full_text: + # Only flag if it looks like a new/unbaselined server + if "verified:" in full_text: + for i, line in enumerate(lines, 1): + if "verified: true" in line: + findings.append( + Finding( + file=str(path), + line=i, + severity="WARN", + rule="verified-true", + message="verified: true — confirm this server has been baselined with reward profiling.", + fix="Set to `verified: false` for new servers. Only set `true` after successful baselining.", + ) + ) + + # Check for train/validation datasets missing gitlab_identifier + in_dataset = False + dataset_type = None + has_gitlab_id = False + has_license = False + dataset_start_line = 0 + + for i, line in enumerate(lines, 1): + stripped = line.strip() + if stripped.startswith("- name:"): + # Flush previous dataset + if in_dataset and dataset_type in ("train", "validation"): + if not has_gitlab_id: + findings.append( + Finding( + file=str(path), + line=dataset_start_line, + severity="WARN", + rule="missing-gitlab-id", + message=f"{dataset_type} dataset missing gitlab_identifier.", + fix="Add gitlab_identifier with dataset_name, version, and artifact_fpath.", + ) + ) + if not has_license: + findings.append( + Finding( + file=str(path), + line=dataset_start_line, + severity="WARN", + rule="missing-license", + message=f"{dataset_type} dataset missing license field.", + fix="Add `license: ` to the dataset entry.", + ) + ) + in_dataset = True + dataset_type = None + has_gitlab_id = False + has_license = False + dataset_start_line = i + elif in_dataset: + if "type:" in stripped: + dataset_type = stripped.split("type:")[-1].strip() + if "gitlab_identifier" in stripped: + has_gitlab_id = True + if "license:" in stripped: + has_license = True + + # Flush last dataset + if in_dataset and dataset_type in ("train", "validation"): + if not has_gitlab_id: + findings.append( + Finding( + file=str(path), + line=dataset_start_line, + severity="WARN", + rule="missing-gitlab-id", + message=f"{dataset_type} dataset missing gitlab_identifier.", + fix="Add gitlab_identifier with dataset_name, version, and artifact_fpath.", + ) + ) + if not has_license: + findings.append( + Finding( + file=str(path), + line=dataset_start_line, + severity="WARN", + rule="missing-license", + message=f"{dataset_type} dataset missing license field.", + fix="Add `license: ` to the dataset entry.", + ) + ) + + +# --------------------------------------------------------------------------- +# Runner +# --------------------------------------------------------------------------- + +PY_CHECKS = [ + check_httpx_usage, + check_ray_get, + check_missing_semaphore, + check_decode_errors_replace, + check_env_vars, + check_wrong_client, + check_cookie_propagation, + check_token_propagation, + check_think_block_stripping, + check_sync_endpoints, + check_non_binary_rewards, +] + +YAML_CHECKS = [ + check_yaml_config, +] + + +def scan_file(path: Path, result: ReviewResult): + try: + text = path.read_text(encoding="utf-8", errors="replace") + except Exception: + return + lines = text.splitlines() + full_text = "\n".join(lines) + result.files_scanned += 1 + + if path.suffix == ".py": + for check in PY_CHECKS: + check(path, lines, result.findings, full_text=full_text) + elif path.suffix in (".yaml", ".yml"): + for check in YAML_CHECKS: + check(path, lines, result.findings, full_text=full_text) + + +def scan_path(target: Path, result: ReviewResult): + if target.is_file(): + scan_file(target, result) + elif target.is_dir(): + for ext in ("*.py", "*.yaml", "*.yml"): + for f in sorted(target.rglob(ext)): + # Skip common non-source dirs + if any(p in f.parts for p in ("__pycache__", ".venv", "node_modules", ".git")): + continue + scan_file(f, result) + + +def format_text(result: ReviewResult) -> str: + lines = [] + lines.append(f"Scanned {result.files_scanned} files\n") + + if not result.findings: + lines.append("No issues found.\n") + return "\n".join(lines) + + blocks = result.blocks + warns = result.warns + + if blocks: + lines.append(f"### BLOCK ({len(blocks)})\n") + for f in blocks: + lines.append(f"- `{f.file}:{f.line}` [{f.rule}] — {f.message}") + lines.append(f" Fix: {f.fix}\n") + + if warns: + lines.append(f"### WARN ({len(warns)})\n") + for f in warns: + lines.append(f"- `{f.file}:{f.line}` [{f.rule}] — {f.message}") + lines.append(f" Fix: {f.fix}\n") + + lines.append(f"\nSummary: {len(blocks)} BLOCK, {len(warns)} WARN") + return "\n".join(lines) + + +def main(): + parser = argparse.ArgumentParser(description="NeMo Gym anti-pattern reviewer") + parser.add_argument("path", help="File or directory to scan") + parser.add_argument("--json", action="store_true", help="Output as JSON") + parser.add_argument("--severity", choices=["BLOCK", "WARN"], help="Filter by severity") + args = parser.parse_args() + + target = Path(args.path) + if not target.exists(): + print(f"Error: {target} does not exist", file=sys.stderr) + sys.exit(2) + + result = ReviewResult() + scan_path(target, result) + + if args.severity: + result.findings = [f for f in result.findings if f.severity == args.severity] + + if args.json: + output = { + "files_scanned": result.files_scanned, + "findings": [asdict(f) for f in result.findings], + "summary": { + "block": len(result.blocks), + "warn": len(result.warns), + "total": len(result.findings), + }, + } + print(json.dumps(output, indent=2)) + else: + print(format_text(result)) + + sys.exit(1 if result.blocks else 0) + + +if __name__ == "__main__": + main() diff --git a/.claude/skills/gym-run/SKILL.md b/.claude/skills/gym-run/SKILL.md new file mode 100644 index 000000000..8058a917a --- /dev/null +++ b/.claude/skills/gym-run/SKILL.md @@ -0,0 +1,205 @@ +--- +name: gym-run +description: > + Run NeMo Gym benchmarks end-to-end — set up env.yaml, validate config, launch servers, + collect rollouts, and hand off to profiling. Use when you have a configured benchmark + and need to actually execute it: first run, smoke test, full rollout collection, or + troubleshooting a failed launch. Covers ng_run, ng_status, ng_collect_rollouts, and + ng_dump_config in the correct sequence. +license: Apache-2.0 +compatibility: Requires Python 3.12+, NeMo Gym installed. Model endpoint must be reachable. +metadata: + author: nvidia-nemo-gym + version: "1.0" +allowed-tools: Bash(ng_*) Bash(curl:*) Bash(python:*) Bash(ps:*) Read Write Edit Grep Glob +--- + +# NeMo Gym Run + +Run a benchmark from zero to profiled results. Follow these steps in order — each depends on the previous. + +## Step 1: Set up env.yaml + +Create `env.yaml` at the project root. This file provides model endpoint credentials and is gitignored — never commit it. + +Minimal template (single model): + +```yaml +policy_base_url: http://localhost:8000/v1 +policy_api_key: your-key +policy_model_name: your-model +``` + +Extended template (with judge model for LLM-as-judge benchmarks): + +```yaml +policy_base_url: http://localhost:8000/v1 +policy_api_key: your-key +policy_model_name: your-model + +judge_base_url: http://localhost:8001/v1 +judge_api_key: your-key +judge_model_name: judge-model +``` + +These values are injected into config YAML via OmegaConf interpolation (`${policy_base_url}`, etc.). + +## Step 2: Choose config paths + +Every run needs at least two configs: one benchmark config + one model config. + +| Endpoint type | Model config to use | When | +|---|---|---| +| OpenAI-compatible `/v1/responses` | `responses_api_models/openai_model/configs/openai_model.yaml` | GPT, Claude, NIM endpoints | +| vLLM `/v1/chat/completions` | `responses_api_models/vllm_model/configs/vllm_model.yaml` | Self-hosted vLLM | +| Azure OpenAI | `responses_api_models/azure_openai_model/configs/azure_openai_model.yaml` | Azure deployments | + +The benchmark config lives in the resources server directory, e.g. `resources_servers/code_gen/configs/code_gen.yaml`. It defines the resources server, agent, and dataset entries. + +## Step 3: Validate before launching + +Always validate the merged config before starting servers: + +```bash +ng_dump_config "+config_paths=[resources_servers/my_benchmark/configs/my_benchmark.yaml,responses_api_models/openai_model/configs/openai_model.yaml]" +``` + +Check the output for: +- All `${...}` interpolations resolved (no OmegaConf errors) — if not, `env.yaml` is missing keys +- Agent `resources_server.name` and `model_server.name` match top-level instance names +- Dataset `jsonl_fpath` paths exist for example data +- No port conflicts between server instances + +If validation fails, hand off to **gym-config**. + +## Step 4: Launch servers + +```bash +ng_run "+config_paths=[resources_servers/my_benchmark/configs/my_benchmark.yaml,responses_api_models/openai_model/configs/openai_model.yaml]" +``` + +This starts the head server, which spawns each server instance as a subprocess with its own isolated venv. First run is slow (venv creation + dependency install). Subsequent runs reuse existing venvs. + +To install venvs without starting servers: + +```bash +ng_run "+config_paths=[...]" +dry_run=true +``` + +The command blocks and streams logs. Servers are ready when all health checks pass in the startup output. + +> **HPC note**: On systems with long working directory paths (e.g. Lustre mounts), Ray socket paths can exceed the 107-byte Linux limit. Fix: `export RAY_TMPDIR=/tmp` before running. + +## Step 5: Verify servers are healthy + +In a separate terminal: + +```bash +ng_status +``` + +All servers should show healthy status before collecting rollouts. If any server shows `connection_error` or `timeout`: + +1. Check server logs in the terminal running `ng_run` +2. Look for import errors, missing dependencies, or port conflicts +3. Try `ng_run "+config_paths=[...]" +dry_run=true` to verify venv setup +4. If the issue persists, hand off to **gym-debug** + +## Step 6: Smoke test + +Before a full run, do a quick smoke test with example data: + +```bash +ng_collect_rollouts \ + +agent_name=my_benchmark_simple_agent \ + +input_jsonl_fpath=resources_servers/my_benchmark/data/example.jsonl \ + +output_jsonl_fpath=results/smoke_test.jsonl \ + +limit=3 \ + +num_repeats=1 \ + "+responses_create_params={max_output_tokens: 4096, temperature: 1.0}" +``` + +Inspect the results: + +```python +import json +with open("results/smoke_test.jsonl") as f: + for line in f: + entry = json.loads(line) + print(f"task {entry.get('task_index')}: reward={entry.get('reward')}, " + f"failure={entry.get('failure_reason', 'none')}") +``` + +**If all rewards are 0.0, do NOT proceed to a full run.** Inspect the diagnostic fields (`extracted_model_code`, `failure_reason`, `result`, `extracted_sql`, etc.) to identify where the pipeline fails. Hand off to **gym-debug** for verification failures. + +## Step 7: Full rollout collection + +```bash +ng_collect_rollouts \ + +agent_name=my_benchmark_simple_agent \ + +input_jsonl_fpath=path/to/dataset.jsonl \ + +output_jsonl_fpath=results/rollouts.jsonl \ + +num_repeats=5 \ + "+responses_create_params={max_output_tokens: 16384, temperature: 1.0}" +``` + +Key parameters: + +| Parameter | Purpose | Typical value | +|---|---|---| +| `num_repeats` | Statistical significance | 5 for profiling, 1 for smoke test | +| `limit` | Cap dataset size | Omit for full run, 3-50 for debugging | +| `resume_from_cache` | Resume interrupted runs | `true` after a crash | +| `max_output_tokens` | Model output budget | 4096-16384 depending on task complexity | +| `temperature` | Sampling temperature | 1.0 for RL profiling, 0.0 for deterministic | +| `num_repeats_add_seed` | Add unique seed per repeat | `true` for reproducibility | +| `prompt_config` | Path to prompt YAML template | Builds `input` from template at rollout time; mutually exclusive with pre-populated input in JSONL | +| `upload_rollouts_to_wandb` | Upload results to W&B | `true` by default; set `false` for local-only runs | + +## Step 8: Hand off to gym-profile + +Once rollouts are collected, use **gym-profile** to analyze. The `ng_collect_rollouts` command auto-generates a materialized inputs file alongside the rollouts (with a `_materialized_inputs` suffix): + +```bash +ng_reward_profile \ + +materialized_inputs_jsonl_fpath=results/rollouts_materialized_inputs.jsonl \ + +rollouts_jsonl_fpath=results/rollouts.jsonl \ + +output_jsonl_fpath=results/profiled.jsonl \ + +pass_threshold=1.0 +``` + +Then aggregate: + +```bash +python scripts/print_aggregate_results.py +jsonl_fpath=results/profiled.jsonl +``` + +## Common failure modes + +| Symptom | Likely cause | Fix | +|---|---|---| +| `ng_dump_config` interpolation error | Missing `env.yaml` or missing keys | Create env.yaml with required fields (see Step 1) | +| Server exits immediately | Import error or missing dependency | Check logs; run `ng_run +dry_run=true` to install venvs first | +| `ng_status` shows connection_error | Server hasn't finished starting, or crashed | Wait for startup to complete; check logs for errors | +| Rollout collection hangs at 0% | Model endpoint unreachable | `curl $policy_base_url/models` to verify connectivity | +| All rewards 0.0 | Verification failing on all inputs | Inspect diagnostic fields in smoke test output (Step 6) | +| OOM during rollouts | Too many parallel samples or large outputs | Reduce `max_output_tokens`; reduce concurrent requests | +| `AF_UNIX path too long` | Ray socket path exceeds 107 bytes on HPC | `export RAY_TMPDIR=/tmp` before running | +| 429 errors from model endpoint | Rate limiting | Reduce request concurrency | +| Partial run lost after crash | No cache enabled | Re-run with `+resume_from_cache=true` | + +## Quick reference + +```bash +ng_dump_config "+config_paths=[...]" # Validate merged config +ng_run "+config_paths=[...]" # Launch all servers +ng_run "+config_paths=[...]" +dry_run=true # Install venvs only +ng_status # Check server health +ng_collect_rollouts +agent_name=... \ + +input_jsonl_fpath=... \ + +output_jsonl_fpath=... \ + +num_repeats=5 # Collect rollouts +ng_reward_profile +input_jsonl_fpath=... \ + +rollouts_jsonl_fpath=... \ + +output_jsonl_fpath=... # Profile results +``` diff --git a/.claude/skills/gym-run/evals/evals.json b/.claude/skills/gym-run/evals/evals.json new file mode 100644 index 000000000..fb65d032b --- /dev/null +++ b/.claude/skills/gym-run/evals/evals.json @@ -0,0 +1,46 @@ +{ + "skill_name": "gym-run", + "evals": [ + { + "id": 1, + "prompt": "I have a code_gen benchmark configured at evals/files/sample_run_config.yaml but no env.yaml. Walk me through running it with the OpenAI model against a GPT endpoint at https://api.openai.com/v1.", + "expected_output": "Step-by-step guide covering env.yaml creation, config validation with ng_dump_config, server launch with ng_run, health check with ng_status, smoke test with ng_collect_rollouts using example.jsonl, and handoff to gym-profile.", + "files": ["evals/files/sample_run_config.yaml"], + "assertions": [ + "env.yaml creation instructed with policy_base_url, policy_api_key, policy_model_name", + "ng_dump_config command shown with both the benchmark config and openai_model config", + "ng_run command shown with correct +config_paths syntax including both configs", + "ng_status mentioned for verifying server health before collecting rollouts", + "Smoke test with ng_collect_rollouts using example.jsonl and small limit recommended before full run", + "Handoff to gym-profile mentioned for analyzing results after rollout collection" + ] + }, + { + "id": 2, + "prompt": "I ran ng_run with the config at evals/files/sample_run_config.yaml but ng_status shows my resources server as connection_error and the model server as success. What do I do?", + "expected_output": "Triage steps for a partially healthy cluster: check resource server logs, verify the entrypoint, check port conflicts, and hand off to gym-debug if the issue persists.", + "files": ["evals/files/sample_run_config.yaml"], + "assertions": [ + "Server logs recommended as first diagnostic step", + "Port conflict between servers identified as a possible cause", + "Missing dependency or import error mentioned as common startup failure", + "ng_run +dry_run=true suggested to verify venv setup without launching", + "Handoff to gym-debug recommended if issue persists after basic triage" + ] + }, + { + "id": 3, + "prompt": "My smoke test at evals/files/sample_smoke_test_output.jsonl returned all 0.0 rewards for 3 tasks. I'm using the config at evals/files/sample_run_config.yaml. Should I proceed to a full rollout collection?", + "expected_output": "Do NOT proceed. Diagnose the 0.0 rewards by inspecting the rollout output fields, then hand off to gym-debug.", + "files": ["evals/files/sample_smoke_test_output.jsonl", "evals/files/sample_run_config.yaml"], + "assertions": [ + "Response explicitly says do NOT proceed to full rollout collection", + "Inspection of output_text and diagnostic fields from the rollout JSONL recommended", + "The empty extracted_model_code on task 0 identified as code extraction failure", + "The TIMEOUT failure_reason on task 1 identified as subprocess timeout", + "The WRONG_RESULT on task 2 identified as logic error in model output", + "Handoff to gym-debug recommended for diagnosing verification failures" + ] + } + ] +} diff --git a/.claude/skills/gym-run/evals/files/sample_run_config.yaml b/.claude/skills/gym-run/evals/files/sample_run_config.yaml new file mode 100644 index 000000000..6c4e8f868 --- /dev/null +++ b/.claude/skills/gym-run/evals/files/sample_run_config.yaml @@ -0,0 +1,32 @@ +my_code_benchmark: + resources_servers: + code_gen: + entrypoint: app.py + domain: coding + verified: false + description: Code generation benchmark with unit test verification + num_processes: 8 + unit_test_timeout_secs: 10 + +my_code_benchmark_simple_agent: + responses_api_agents: + simple_agent: + entrypoint: app.py + resources_server: + type: resources_servers + name: my_code_benchmark + model_server: + type: responses_api_models + name: policy_model + datasets: + - name: example + type: example + jsonl_fpath: resources_servers/code_gen/data/example.jsonl + - name: train + type: train + jsonl_fpath: resources_servers/code_gen/data/train.jsonl + gitlab_identifier: + dataset_name: code_gen + version: 0.0.1 + artifact_fpath: train.jsonl + license: Apache-2.0 diff --git a/.claude/skills/gym-run/evals/files/sample_smoke_test_output.jsonl b/.claude/skills/gym-run/evals/files/sample_smoke_test_output.jsonl new file mode 100644 index 000000000..2e82aefa5 --- /dev/null +++ b/.claude/skills/gym-run/evals/files/sample_smoke_test_output.jsonl @@ -0,0 +1,3 @@ +{"task_index": 0, "reward": 0.0, "response": {"output_text": "Let me solve this step by step...\n\nHere is my solution:\n```python\ndef solve():\n return 42\n```"}, "extracted_model_code": "", "result": "", "failure_reason": "NO_CODE_EXTRACTED"} +{"task_index": 1, "reward": 0.0, "response": {"output_text": "```python\nimport time\nwhile True: time.sleep(1)\n```"}, "extracted_model_code": "import time\nwhile True: time.sleep(1)", "result": "TIMEOUT after 10s", "failure_reason": "TIMEOUT"} +{"task_index": 2, "reward": 0.0, "response": {"output_text": "```python\ndef solve(n):\n return n + 1\n```"}, "extracted_model_code": "def solve(n):\n return n + 1", "result": "Expected: 4, Got: 3", "failure_reason": "WRONG_RESULT"} diff --git a/.claude/skills/gym-scaffold-agent/SKILL.md b/.claude/skills/gym-scaffold-agent/SKILL.md new file mode 100644 index 000000000..4a8ecfd04 --- /dev/null +++ b/.claude/skills/gym-scaffold-agent/SKILL.md @@ -0,0 +1,179 @@ +--- +name: gym-scaffold-agent +description: > + Create a custom agent server for NeMo Gym. Use when the default simple_agent is + insufficient — for multi-turn interaction, external library wrapping, custom tool + orchestration, or non-standard interaction patterns (model assimilation). Covers + agent server scaffolding, cookie/token propagation, httpx replacement, and async + patterns for high-concurrency operation. +license: Apache-2.0 +compatibility: Requires Python 3.12+, NeMo Gym installed. +metadata: + author: nvidia-nemo-gym + version: "1.0" +allowed-tools: Bash(python:*) Bash(ng_*) Bash(git:*) Read Write Edit Grep Glob +--- + +# Scaffold a Custom Agent Server + +## When you need a custom agent + +The built-in agents cover most cases: +- **`simple_agent`** — single-turn: sends prompt to model, gets response, calls verify. Works for most benchmarks. +- **`proof_refinement_agent`** — multi-turn correction: model gets error feedback and retries. + +Build a custom agent when: +- The interaction pattern doesn't fit single-turn or simple correction loops +- You're wrapping an external library that has its own orchestration +- The benchmark requires custom tool-call sequencing or state management +- You need to teach the model a specific interaction protocol (assimilation) + +## Step 1: Create the directory + +``` +responses_api_agents/my_agent/ +├── app.py # Server class extending SimpleResponsesAPIAgent +├── configs/my_agent.yaml +├── tests/__init__.py +├── tests/test_app.py +└── requirements.txt # just: -e nemo-gym[dev] @ ../../ +``` + +## Step 2: Implement the agent + +Your agent extends `SimpleResponsesAPIAgent` and implements `responses()` and `run()`. + +```python +from nemo_gym.server import SimpleResponsesAPIAgent + +class MyAgent(SimpleResponsesAPIAgent): + async def responses(self, request): + # Single response from model + ... + + async def run(self, request): + # Full orchestration loop + ... +``` + +### Key inherited attributes + +Your agent inherits from `SimpleServer`, which provides: + +- **`self.server_client`** — a `ServerClient` instance for making async HTTP calls to model and resources servers. Wraps aiohttp with retry logic (3 tries, exponential backoff) and connection pooling. Use it for all downstream calls. +- **`self.config`** — the agent's Hydra config (resources_server, model_server, datasets, etc.) + +You can also override `aggregate_metrics()` to compute custom metrics after rollout collection. + +### The `/run` endpoint + +This is where orchestration happens. The general pattern: + +1. Receive the input (system prompt + user message + verifier_metadata) +2. Call the model server (`/v1/responses` or `/v1/chat/completions`) +3. If the model returns tool calls, execute them against the resources server +4. Optionally loop (multi-turn) +5. Call `/verify` on the resources server +6. Return the verify response (includes reward) + +The `/run` endpoint **must be async**. + +## Step 3: Cookie propagation (critical for stateful environments) + +Every downstream request must forward cookies from the incoming request: + +```python +async def run(self, request): + cookies = request.cookies # Capture from incoming request + + # Every downstream call passes cookies + model_response = await self.server_client.post( + model_url, json=payload, cookies=cookies + ) + verify_response = await self.server_client.post( + verify_url, json=payload, cookies=cookies + ) +``` + +Missing cookies break stateful environments where the resources server tracks session state. + +## Step 4: Token ID propagation (critical for RL training) + +Multi-turn agents must propagate token IDs from model responses into subsequent turns: + +```python +# After receiving model response +prompt_token_ids = model_response.get("prompt_token_ids", []) +generation_token_ids = model_response.get("generation_token_ids", []) +generation_log_probs = model_response.get("generation_log_probs", []) + +# Accumulate across turns and include in final response +``` + +Without these, the RL training framework can't compute policy gradients for multi-turn interactions. + +## Step 5: Wrapping external libraries + +When integrating a 3rd-party benchmark library: + +1. **Replace httpx transport**: If the library uses httpx internally, replace its HTTP transport with an aiohttp adapter. See `resources_servers/tavily_search/app.py` (`TavilySearchAIOHTTPClient`) for the pattern. + +2. **Pre-process input**: Convert from Gym schema (`responses_create_params.input` + `verifier_metadata`) to the library's expected input format. + +3. **Post-process output**: Convert the library's results back to `BaseVerifyResponse` (must include `reward` field). + +4. **Reproduce published numbers**: Run the original library standalone first and record scores. Then run through your Gym wrapper and verify scores match. + +```python +async def run(self, request): + # Pre-process: Gym schema -> library input + lib_input = self.convert_to_library_format(request) + + # Run library (may need asyncio.Semaphore for concurrency control) + async with self.semaphore: + lib_result = await self.run_library(lib_input) + + # Post-process: library output -> Gym response + return self.convert_to_gym_response(lib_result) +``` + +## Step 6: Concurrency control + +The agent must handle 4k-65k concurrent requests. Use `asyncio.Semaphore` for any blocking or resource-intensive operations: + +```python +class MyAgent(SimpleResponsesAPIAgent): + def model_post_init(self, __context): + super().model_post_init(__context) + self.semaphore = asyncio.Semaphore(self.max_concurrent) +``` + +## Step 7: Wire YAML config + +```yaml +my_agent_instance: + responses_api_agents: + my_agent: + entrypoint: app.py + resources_server: + type: resources_servers + name: my_resources_server # Must match resources server instance name + model_server: + type: responses_api_models + name: policy_model # Must match model server instance name + datasets: + - name: example + type: example + jsonl_fpath: resources_servers/my_benchmark/data/example.jsonl +``` + +## Step 8: Test + +Write tests covering: +- Happy path (model produces correct output, gets reward 1.0) +- Model failure (bad output, gets reward 0.0) +- Multi-turn logic (if applicable — verify correct number of turns, proper accumulation) +- Cookie propagation (verify cookies are forwarded) +- Concurrency (verify semaphore bounds are respected) + +Coverage must be >= 95%. diff --git a/.claude/skills/gym-scaffold-agent/evals/evals.json b/.claude/skills/gym-scaffold-agent/evals/evals.json new file mode 100644 index 000000000..85fe1a47a --- /dev/null +++ b/.claude/skills/gym-scaffold-agent/evals/evals.json @@ -0,0 +1,44 @@ +{ + "skill_name": "gym-scaffold-agent", + "evals": [ + { + "id": 1, + "prompt": "Review the multi-turn correction agent at evals/files/sample_correction_agent.py. It gives models 3 attempts to solve problems.", + "expected_output": "Review identifying missing cookie propagation and token ID accumulation, while noting the agent structure is otherwise correct.", + "files": ["evals/files/sample_correction_agent.py"], + "assertions": [ + "Missing cookie propagation identified (no cookies=request.cookies on downstream calls)", + "Missing token ID accumulation identified (prompt_token_ids, generation_token_ids not collected across turns)", + "The fix for cookies references passing cookies=request.cookies and updating from each response", + "The fix for token IDs mentions accumulating across all turns and attaching to final response", + "Response notes the agent structure (loop, error feedback, think-strip, semaphore) is otherwise correct" + ] + }, + { + "id": 2, + "prompt": "Review the external library wrapper at evals/files/sample_library_wrapper.py. It wraps a 3rd-party benchmark.", + "expected_output": "Review identifying httpx usage and missing Semaphore, while confirming pre/post-processing is correct.", + "files": ["evals/files/sample_library_wrapper.py"], + "assertions": [ + "httpx usage identified as BLOCK issue", + "aiohttp adapter pattern recommended as replacement", + "Missing Semaphore for concurrent library calls identified", + "The fix references the AIOHTTPClient adapter pattern", + "Response notes pre/post-processing logic is correct" + ] + }, + { + "id": 3, + "prompt": "Review the tool-call loop agent at evals/files/sample_tool_loop_agent.py for correctness.", + "expected_output": "Clean review confirming all patterns are correct: cookies, tokens, bounds, semaphore.", + "files": ["evals/files/sample_tool_loop_agent.py"], + "assertions": [ + "Cookie propagation confirmed as correct", + "Token ID accumulation confirmed as correct", + "Max iteration bound confirmed as present", + "Semaphore usage confirmed", + "Response confirms the agent is ready for use (no BLOCK findings)" + ] + } + ] +} diff --git a/.claude/skills/gym-scaffold-agent/evals/files/sample_correction_agent.py b/.claude/skills/gym-scaffold-agent/evals/files/sample_correction_agent.py new file mode 100644 index 000000000..80d8689d0 --- /dev/null +++ b/.claude/skills/gym-scaffold-agent/evals/files/sample_correction_agent.py @@ -0,0 +1,91 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Multi-turn correction agent — gives model 3 attempts to solve a problem. + +Intentional bugs: +- Missing cookie propagation on server_client.post calls +- Missing token ID accumulation across turns +""" + +import asyncio + +from pydantic import BaseModel +from starlette.requests import Request + +from nemo_gym.server_utils import raise_for_status +from nemo_gym.servers.responses_api_agent import SimpleResponsesAPIAgent + + +class CorrectionAgentConfig(BaseModel): + max_turns: int = 3 + resources_server: dict = {} + model_server: dict = {} + name: str = "correction_agent" + + +class CorrectionAgent(SimpleResponsesAPIAgent): + config: CorrectionAgentConfig + + def model_post_init(self, __context): + super().model_post_init(__context) + self.semaphore = asyncio.Semaphore(32) + + async def run(self, request: Request, body): + current_input = body.model_dump() + + for turn in range(self.config.max_turns): + # Model call — not forwarding session state + gen_resp = await self.server_client.post( + server_name=self.config.name, + url_path="/v1/responses", + json=current_input, + ) + await raise_for_status(gen_resp) + + model_response = await gen_resp.json() + output_text = model_response.get("output_text", "") + + # Strip think blocks before extraction + if "" in output_text: + output_text = output_text.split("")[-1].strip() + + # Verify — not forwarding session state + async with self.semaphore: + verify_resp = await self.server_client.post( + server_name=self.config.resources_server.get("name", ""), + url_path="/verify", + json={ + "output_text": output_text, + "verifier_metadata": body.get("verifier_metadata", {}), + }, + ) + await raise_for_status(verify_resp) + + verify_data = await verify_resp.json() + + if verify_data.get("reward", 0.0) == 1.0: + return verify_data + + # Build error feedback for next turn + error_msg = verify_data.get("errors", "Incorrect.") + current_input = { + "input": current_input.get("input", []) + + [ + {"role": "assistant", "content": output_text}, + {"role": "user", "content": f"That was wrong. {error_msg} Try again."}, + ] + } + + return verify_data diff --git a/.claude/skills/gym-scaffold-agent/evals/files/sample_library_wrapper.py b/.claude/skills/gym-scaffold-agent/evals/files/sample_library_wrapper.py new file mode 100644 index 000000000..8d38c1e25 --- /dev/null +++ b/.claude/skills/gym-scaffold-agent/evals/files/sample_library_wrapper.py @@ -0,0 +1,78 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""External library wrapper agent. + +Intentional bugs: +- Uses httpx directly instead of aiohttp adapter +- Missing Semaphore for concurrent library calls +""" + +import httpx +from pydantic import BaseModel +from starlette.requests import Request + +from nemo_gym.servers.responses_api_agent import SimpleResponsesAPIAgent + + +class ExternalLibConfig(BaseModel): + api_url: str = "http://localhost:9000" + resources_server: dict = {} + model_server: dict = {} + name: str = "external_wrapper" + + +class ExternalLibraryWrapper(SimpleResponsesAPIAgent): + config: ExternalLibConfig + + def model_post_init(self, __context): + super().model_post_init(__context) + self.client = httpx.AsyncClient(base_url=self.config.api_url) + + async def run(self, request: Request, body): + cookies = request.cookies + + # Model call + gen_resp = await self.server_client.post( + server_name=self.config.name, + url_path="/v1/responses", + json=body.model_dump(), + cookies=cookies, + ) + cookies = gen_resp.cookies + + model_response = await gen_resp.json() + output_text = model_response.get("output_text", "") + + # Pre-process: Gym schema to library format + library_input = { + "code": output_text, + "task_id": body.get("verifier_metadata", {}).get("task_id", ""), + "test_cases": body.get("verifier_metadata", {}).get("test_cases", []), + } + + # Call external library — no concurrency control + response = await self.client.post( + "/evaluate", + content=str(library_input), + timeout=60.0, + ) + result = response.json() + + # Post-process: library output to Gym response + return { + "reward": 1.0 if result.get("passed", False) else 0.0, + "output_text": output_text, + "response": {"output_text": output_text}, + } diff --git a/.claude/skills/gym-scaffold-agent/evals/files/sample_tool_loop_agent.py b/.claude/skills/gym-scaffold-agent/evals/files/sample_tool_loop_agent.py new file mode 100644 index 000000000..7480f3cb9 --- /dev/null +++ b/.claude/skills/gym-scaffold-agent/evals/files/sample_tool_loop_agent.py @@ -0,0 +1,123 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Tool-call loop agent — correct implementation with no bugs. + +Model calls tools iteratively until producing a final answer. Includes: +- Cookie propagation +- Token ID accumulation +- Max iteration bound +- Semaphore for concurrency control +""" + +import asyncio +import json + +from pydantic import BaseModel +from starlette.requests import Request + +from nemo_gym.server_utils import raise_for_status +from nemo_gym.servers.responses_api_agent import SimpleResponsesAPIAgent + + +class ToolLoopConfig(BaseModel): + max_tool_calls: int = 10 + resources_server: dict = {} + model_server: dict = {} + name: str = "tool_loop_agent" + + +class ToolLoopAgent(SimpleResponsesAPIAgent): + config: ToolLoopConfig + + def model_post_init(self, __context): + super().model_post_init(__context) + self.semaphore = asyncio.Semaphore(32) + + async def run(self, request: Request, body): + cookies = request.cookies + current_input = body.model_dump() + + all_prompt_token_ids = [] + all_generation_token_ids = [] + all_generation_log_probs = [] + + for iteration in range(self.config.max_tool_calls): + # Model call with cookie propagation + gen_resp = await self.server_client.post( + server_name=self.config.name, + url_path="/v1/responses", + json=current_input, + cookies=cookies, + ) + await raise_for_status(gen_resp) + cookies = gen_resp.cookies + + model_response = await gen_resp.json() + + # Accumulate token IDs + all_prompt_token_ids.extend(model_response.get("prompt_token_ids", [])) + all_generation_token_ids.extend(model_response.get("generation_token_ids", [])) + all_generation_log_probs.extend(model_response.get("generation_log_probs", [])) + + # Check for tool calls + tool_calls = model_response.get("tool_calls", []) + if not tool_calls: + break + + # Execute each tool call with concurrency control + tool_results = [] + for tool_call in tool_calls: + async with self.semaphore: + tool_resp = await self.server_client.post( + server_name=self.config.resources_server.get("name", ""), + url_path=f"/tools/{tool_call['function']['name']}", + json=tool_call["function"]["arguments"], + cookies=cookies, + ) + cookies = tool_resp.cookies + tool_result = await tool_resp.json() + tool_results.append( + { + "role": "tool", + "tool_call_id": tool_call["id"], + "content": json.dumps(tool_result), + } + ) + + # Build next turn with tool results + messages = current_input.get("input", []) + messages.append({"role": "assistant", "content": "", "tool_calls": tool_calls}) + messages.extend(tool_results) + current_input = {"input": messages} + + # Final verification + output_text = model_response.get("output_text", "") + verify_resp = await self.server_client.post( + server_name=self.config.resources_server.get("name", ""), + url_path="/verify", + json={ + "output_text": output_text, + "verifier_metadata": body.get("verifier_metadata", {}), + }, + cookies=cookies, + ) + cookies = verify_resp.cookies + verify_data = await verify_resp.json() + + verify_data["prompt_token_ids"] = all_prompt_token_ids + verify_data["generation_token_ids"] = all_generation_token_ids + verify_data["generation_log_probs"] = all_generation_log_probs + + return verify_data diff --git a/.claude/skills/gym-scaffold-agent/references/agent-patterns.md b/.claude/skills/gym-scaffold-agent/references/agent-patterns.md new file mode 100644 index 000000000..f02dd399a --- /dev/null +++ b/.claude/skills/gym-scaffold-agent/references/agent-patterns.md @@ -0,0 +1,300 @@ +# NeMo Gym Agent Patterns + +Production code patterns for custom agent servers. Each pattern is self-contained. + +--- + +## Base agent structure + +```python +from pydantic import BaseModel +from starlette.requests import Request + +from nemo_gym.servers.responses_api_agent import SimpleResponsesAPIAgent + + +class MyAgentConfig(BaseModel): + max_turns: int = 3 + resources_server: dict = {} + model_server: dict = {} + name: str = "my_agent" + + +class MyAgent(SimpleResponsesAPIAgent): + config: MyAgentConfig + + async def run(self, request: Request, body) -> dict: + # All agent logic goes here + ... +``` + +Key requirements: +- Extend `SimpleResponsesAPIAgent` +- `run()` must be `async def` +- Accept `request: Request` (for cookies) and `body` (the run request) + +Inherited attributes: +- `self.server_client` — `ServerClient` instance for async HTTP calls to model/resources servers. Wraps aiohttp with retry logic (3 tries, exponential backoff) and connection pooling. +- `self.config` — the agent's Hydra config (resources_server, model_server, datasets, etc.) +- Override `aggregate_metrics()` for custom metric computation after rollout collection. + +--- + +## Multi-turn correction loop + +Model gets multiple attempts to solve a problem. Error feedback is sent back on failure. + +```python +async def run(self, request: Request, body) -> dict: + cookies = request.cookies + current_input = body.model_dump() + + all_prompt_token_ids = [] + all_generation_token_ids = [] + all_generation_log_probs = [] + + for turn in range(self.config.max_turns): + # Model call + gen_resp = await self.server_client.post( + server_name=self.config.name, + url_path="/v1/responses", + json=current_input, + cookies=cookies, + ) + await raise_for_status(gen_resp) + cookies = gen_resp.cookies + + model_response = await gen_resp.json() + + # Accumulate token IDs + all_prompt_token_ids.extend(model_response.get("prompt_token_ids", [])) + all_generation_token_ids.extend(model_response.get("generation_token_ids", [])) + all_generation_log_probs.extend(model_response.get("generation_log_probs", [])) + + output_text = model_response.get("output_text", "") + + # Verify + verify_resp = await self.server_client.post( + server_name=self.config.resources_server.get("name", ""), + url_path="/verify", + json={"output_text": output_text, "verifier_metadata": body.get("verifier_metadata", {})}, + cookies=cookies, + ) + await raise_for_status(verify_resp) + cookies = verify_resp.cookies + + verify_data = await verify_resp.json() + + if verify_data.get("reward", 0.0) == 1.0: + break + + # Build error feedback for next turn + error_msg = verify_data.get("errors", verify_data.get("feedback", "Incorrect. Try again.")) + current_input = { + "input": current_input.get("input", []) + [ + {"role": "assistant", "content": output_text}, + {"role": "user", "content": f"That was incorrect. {error_msg} Please try again."}, + ] + } + + # Attach accumulated token IDs + verify_data["prompt_token_ids"] = all_prompt_token_ids + verify_data["generation_token_ids"] = all_generation_token_ids + verify_data["generation_log_probs"] = all_generation_log_probs + + return verify_data +``` + +**Critical requirements:** +- `cookies=cookies` on every `server_client.post()` call +- `cookies = response.cookies` after every response +- Token IDs accumulated with `.extend()` across all turns +- Max turns guard to prevent infinite loops + +--- + +## External library wrapper + +When wrapping a 3rd-party library that uses httpx internally. + +### aiohttp adapter (replaces httpx transport) + +```python +from pydantic import BaseModel +from nemo_gym.server_utils import request + + +class AIOHTTPClientResponse(BaseModel): + """Drop-in replacement for httpx.Response.""" + status_code: int + data: dict + + def json(self): + return self.data + + +class AIOHTTPClient(BaseModel): + """Drop-in replacement for httpx.AsyncClient.""" + headers: dict + base_url: str + + async def post(self, endpoint: str, content: str, timeout: float) -> AIOHTTPClientResponse: + response = await request( + method="POST", + headers=self.headers, + url=f"{self.base_url}{endpoint}", + data=content, + ) + return AIOHTTPClientResponse( + status_code=response.status, + data=await response.json(), + ) + + @classmethod + def from_httpx_client(cls, client, **kwargs): + return cls( + headers=dict(client.headers), + base_url=str(client.base_url), + **kwargs, + ) +``` + +### Agent wrapper + +```python +import asyncio + +from nemo_gym.servers.responses_api_agent import SimpleResponsesAPIAgent + + +class ExternalBenchmarkAgent(SimpleResponsesAPIAgent): + config: ExternalBenchmarkConfig + + def model_post_init(self, __context): + super().model_post_init(__context) + self.library = ExternalLibrary() + # Replace httpx with aiohttp adapter + self.library._client = AIOHTTPClient.from_httpx_client(self.library._client) + self.semaphore = asyncio.Semaphore(self.config.max_concurrent) + + async def run(self, request, body) -> dict: + # Pre-process: Gym schema → library format + library_input = { + "prompt": body["responses_create_params"]["input"][-1]["content"], + "task_id": body["verifier_metadata"]["task_id"], + } + + # Execute with concurrency control + async with self.semaphore: + result = await self.library.evaluate(library_input) + + # Post-process: library output → Gym response + return { + "reward": 1.0 if result["passed"] else 0.0, + "output_text": result.get("output", ""), + "response": {"output_text": result.get("output", "")}, + } +``` + +--- + +## Tool-call loop + +Model calls tools iteratively until it produces a final answer. + +```python +async def run(self, request: Request, body) -> dict: + cookies = request.cookies + current_input = body.model_dump() + max_iterations = self.config.max_tool_calls + + all_prompt_token_ids = [] + all_generation_token_ids = [] + all_generation_log_probs = [] + + for iteration in range(max_iterations): + # Model call + gen_resp = await self.server_client.post( + server_name=self.config.name, + url_path="/v1/responses", + json=current_input, + cookies=cookies, + ) + await raise_for_status(gen_resp) + cookies = gen_resp.cookies + + model_response = await gen_resp.json() + + # Accumulate token IDs + all_prompt_token_ids.extend(model_response.get("prompt_token_ids", [])) + all_generation_token_ids.extend(model_response.get("generation_token_ids", [])) + all_generation_log_probs.extend(model_response.get("generation_log_probs", [])) + + # Check if model wants to call tools + tool_calls = model_response.get("tool_calls", []) + if not tool_calls: + break # No more tool calls — model is done + + # Execute each tool call + tool_results = [] + for tool_call in tool_calls: + tool_resp = await self.server_client.post( + server_name=self.config.resources_server.get("name", ""), + url_path=f"/tools/{tool_call['function']['name']}", + json=tool_call["function"]["arguments"], + cookies=cookies, + ) + cookies = tool_resp.cookies + tool_result = await tool_resp.json() + tool_results.append({ + "role": "tool", + "tool_call_id": tool_call["id"], + "content": json.dumps(tool_result), + }) + + # Build next turn with tool results + messages = current_input.get("input", []) + messages.append({"role": "assistant", "content": "", "tool_calls": tool_calls}) + messages.extend(tool_results) + current_input = {"input": messages} + + # Final verification + output_text = model_response.get("output_text", "") + verify_resp = await self.server_client.post( + server_name=self.config.resources_server.get("name", ""), + url_path="/verify", + json={"output_text": output_text, "verifier_metadata": body.get("verifier_metadata", {})}, + cookies=cookies, + ) + cookies = verify_resp.cookies + verify_data = await verify_resp.json() + + verify_data["prompt_token_ids"] = all_prompt_token_ids + verify_data["generation_token_ids"] = all_generation_token_ids + verify_data["generation_log_probs"] = all_generation_log_probs + + return verify_data +``` + +--- + +## YAML config for custom agents + +```yaml +my_custom_agent: + responses_api_agents: + my_agent: # Must match the directory name + entrypoint: app.py + max_turns: 3 + max_tool_calls: 10 + resources_server: + type: resources_servers + name: my_benchmark # Must match instance name + model_server: + type: responses_api_models + name: policy_model # Must match instance name + datasets: + - name: my_example + type: example + jsonl_fpath: resources_servers/my_benchmark/data/example.jsonl +```