Skip to content

feat(stirrup): align the GDPVal sandbox with AA v2 and harden the agent runtime - #3685

Merged
yuhezhang-ai merged 1 commit into
mainfrom
spanev/stirrup-aa-v2-runtime
Oct 1, 2026
Merged

yuhezhang-ai merged 1 commit into
mainfrom
spanev/stirrup-aa-v2-runtime

Conversation

@Kh4L

@Kh4L Kh4L commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

What does this PR do?

This PR moves the Stirrup sandbox and runtime work from gdpval-devel to main. On devel it landed as #2338, #2395, #2588, #3086, #3121 and #3333 (sandbox part). It is one of a series of PRs cut by file from a single tested devel snapshot. It touches responses_api_agents/stirrup_agent/ and the GDPVal sandbox build script in nemotron_recipes/. The GDPVal prompt rewrite that describes this sandbox is in #3686, not here.

Sandbox (#3333)

  • containers/gdpval.def now builds the published GDPval-AA v2 environment:
    • debian:trixie with Debian's Python 3.13.5. The build fails on any other interpreter version.
    • The 419 published Python pins, installed verbatim and checked for exact versions afterwards.
    • Carlito/Caladea (metric-compatible Calibri/Cambria) and the Noto fonts.
  • Both published manifests are vendored as plain text: 419 Python pins and the 762-package apt closure. On arm64 the build drops exactly the 5 pins in gdpval_aa_v2_arm64_exclusions.txt. Any other pin that fails to install fails the build.
  • verify_gdpval_sandbox.py is staged into the image. It checks a built .sif against both manifests, then runs the toolchain end to end.
  • The definition stages its manifests with relative %files paths, so it has to be built from containers/. The README command is updated for that. nemotron_recipes/.../build-gdpval-sif.sh now builds from the definition's directory, where before it built from the repo root, which fails with this definition.
  • _build_command_script is pulled out of ApptainerCodeExecToolProvider._exec with no behaviour change. A test runs two provider-built commands in one bash and checks that only files persist between calls.

Model client and agent (#2338, #3121)

  • DynamicMaxTokensChatCompletionsClient:
    • When the tokenizer renders the complete prompt, the remaining context also caps max_completion_tokens. A prompt with no room left raises Stirrup's ContextOverflowError before the request is sent.
    • For token counting only, tool-call arguments are JSON-decoded before template rendering, as vLLM does.
    • Without a tokenizer, or when tokenization fails, the character estimate counts the whole JSON-serialized history, tool calls included.
    • New arguments:
      • min_completion_tokens, default 1024 (the old constant).
      • prompt_estimator_truncate_history_thinking, default None. It only affects the estimate.
      • truncation_recovery, default False. When on, a turn that uses its whole budget without a schema-valid tool call is followed by one turn that requests thinking off and adds a one-time instruction. The instruction is not recorded in the trajectory.
  • NeMoAgent.summarize_messages:
    • Strips a leading <think> block from compaction summaries.
    • Makes up to 3 summary attempts. Retries add a stricter text-only instruction, and the last attempt runs without tools. If all 3 fail it raises instead of wiping the history.
    • New min_compaction_summary_words argument, default 1.

Provider payload and trajectories (#2395, #2588, #3086)

  • to_provider_openai_messages sends only the standard OpenAI message keys:
    • Stirrup's serializer also emits reasoning_content, thinking_blocks and metadata on assistant messages, flat tool_call_id/name/arguments/signature fields on tool calls, and name on tool messages.
    • Main's strict chat schema rejects those fields. So any multi-turn request whose history has an assistant turn with tool calls or reasoning currently fails against a Gym model server.
    • Unparsed <tool_call> / <function=...> blocks are removed from the provider copy of assistant text. This applies only to assistant turns with no parsed tool_calls, and an unclosed block is stripped to the end of the message. The recorded history keeps them.
  • convert_stirrup_history_to_output_items makes call_id unique within a response when the server reuses ids across turns (e.g. code_exec:0). Call/output pairing is preserved, and trajectories without repeated ids are unchanged.
  • Tavily web_fetch:
    • If markdown extraction is empty, it tries a second plain-text pass that includes comments. If that is also empty it returns an explicit error.
    • An extraction under 1% of a page of 20,000+ characters gets a "likely a fragment" note at the start.

The new client and agent options are documented in the Stirrup README as constructor arguments. The agent config keys that set them come with #3686, which owns stirrup_agent/app.py.

Issue: none. This promotes work that was already reviewed and merged on gdpval-devel.

Dependencies:

Validation

Main 1c8261080 + only this PR's 17 files, macOS, Python 3.13, per-server venvs:

  • cd responses_api_agents/stirrup_agent && python -m pytest tests -q: 224 passed, 2 skipped. The skips need GNU timeout or a real Apptainer image (GDPVAL_CONTAINER_PATH).

  • cd resources_servers/gdpval && python -m pytest tests -q (gdpval imports Stirrup): 156 passed.

  • With the rest of the series applied: Stirrup 319 passed / 2 skipped, GDPVal 564 passed. The 12 prompt-content tests that moved to feat(gdpval): harden judging with local judges and media inputsΒ #3686 fail against main's current prompt, which is why they live there.

  • pre-commit run --all-files: all hooks pass, no files modified.

  • Schema probe against main's NeMoGymChatCompletionCreateParamsNonStreaming:

    History Stirrup's to_openai_messages to_provider_openai_messages
    Assistant tool call + tool result rejected (44 errors) validates
    Assistant reasoning, no tool calls rejected (14 errors) validates
    Text only validates validates

    Adding chat_template_kwargs is still rejected until fix(openai-utils): accept provider extension fields in strict chat requestsΒ #3682.

  • Build script: I ran it with a stub apptainer that checks every %files source resolves from the build directory.

    • The new script passes for the default, a relative and an absolute output path.
    • Main's script fails: gdpval_aa_v2_python_requirements.txt is not found from the repo root.
  • Container: built and verified with verify_gdpval_sandbox.py in [GDPVal] Align the sandbox and prompt to GDPval-AA v2Β #3333. The image was not rebuilt for this PR, and only comment lines under containers/ have changed since.

    Arch Interpreter Pins apt-closure entries missing Verifier failures
    x86_64 3.13.5 419/419 0 0
    aarch64 3.13.5 414/419 (the 5 listed exclusions) 3 0
  • Core unit tests: not run. This PR changes nothing under nemo_gym/, scripts/ or tests/.

Rollouts

Live GDPVal smoke (final code): all seven PRs of the series applied together on main 1c8261080, run with the devel-only SLURM launcher on a 4-task AA-v2 comparison smoke (one 4-GPU policy node, CPU judging): rollout 4/4 tasks saved and finished (1h04m, 0 failure rows), Office preparation 7/7 converted, comparison judging 4/4 tasks judged with 0 imputed and 0 excluded, 3 wins / 1 loss. Per-task outcomes match an earlier gdpval-devel run on the same checkpoint, tasks and references. No rate limiting occurred, so the judge retry budget was not exercised live.

Compatibility and benchmark impact

  • Sandbox image (takes effect only after a rebuild):
    • Existing .sif images keep working.
    • Build from containers/ (cd responses_api_agents/stirrup_agent/containers && apptainer build gdpval.sif gdpval.def) or with the recipes script. A build started from the repo root cannot find the %files inputs.
    • Python packages:
      • The base moves from python:3.13-bookworm to debian:trixie, with Python fixed at 3.13.5.
      • Python packages become exact pins. Every Python package the old definition installed is still in the pin set.
    • apt: unar is dropped (it isn't in the AA v2 closure), and three packages move to their trixie names.
    • Deliberate differences from AA v2: we keep a compiler toolchain, we add espeak-ng, and the per-command limit is 5 minutes (AA uses 10).
  • GDPVal results: runs on a rebuilt image are not comparable with runs on an image built from the old definition. Package versions, fonts and LibreOffice all change. The prompt change in feat(gdpval): harden judging with local judges and media inputsΒ #3686 moves scores further.
  • Token budgeting (all Stirrup runs):
    • With model_id set and a template that renders, max_completion_tokens can drop below 1024 near the context limit, and a full context raises ContextOverflowError before the request is sent.
    • Without model_id, or when tokenization fails, the estimate is larger for every history, most for tool-heavy ones. Budgets shrink only near the context limit, and the 1024 floor still applies.
  • Constructor validation: max_completion_tokens_cap, min_completion_tokens and min_compaction_summary_words below 1 now raise ValueError.
  • Logging: a WARNING is logged every time a call uses its whole completion budget without a usable tool call, even when truncation_recovery is off.
  • Compaction: empty or think-only summaries now get up to 3 attempts and then fail the rollout. Before, the run continued with an empty summary.
    • The override uses private Stirrup internals (_unwind_context_overflow, _active_tools, _logger). They are stable under the existing stirrup>=0.1.12,<0.2 pin, but a Stirrup minor release may need a follow-up.
  • Provider requests:
    • The non-standard message keys listed above are no longer sent, so earlier-turn reasoning is not replayed.
    • Unparsed tool-call blocks are removed from the provider copy of assistant text.
  • Trajectories: a repeated tool call_id gets a #n suffix from its second occurrence onward.
  • web_fetch:
    • A page with no extractable text now returns success=False with an error. Before, it returned an empty body.
    • A fragmentary extraction gets a note at the start.
  • Opt-in behaviour: truncation_recovery is off by default. When on, the recovery instruction is appended as a user message, so the provider can see two user messages in a row.

Checklist

  • I have read the contributing guidelines.
  • The change is focused; unrelated "drive-by" edits are tracked as separate issues/PRs.
  • Tests added or updated and pass locally, or N/A for docs-only / non-code changes (so CI unit/server checks pass when applicable).
  • Documentation added or updated, or N/A with justification.
  • Pre-commit checks pass locally (pre-commit run --all-files) (so CI lint/format checks pass).
  • New source files include the required Apache-2.0 SPDX header.
  • All commits have DCO sign-off (git commit -s) (so the DCO check passes).

…nt runtime

Signed-off-by: Serge Panev <spanev@nvidia.com>
@copy-pr-bot

copy-pr-bot Bot commented Sep 24, 2026

Copy link
Copy Markdown

This pull request requires additional validation before any workflows can run on NVIDIA's runners.

Pull request vetters can view their responsibilities here.

Contributors can view more details about this message here.

@yaoyu-33 yaoyu-33 added area:agent Agent harnesses and Responses API agent behavior complexity:high Cross-component or shared-contract change with a large review surface feature New capabilities, enhancements, or enablement work needs-review PR is ready for code review and waiting on a reviewer labels Sep 24, 2026
@github-actions github-actions Bot added the sla:triage-overdue Review assignment is over the one-business-day SLA label Sep 25, 2026
@github-actions github-actions Bot removed the sla:triage-overdue Review assignment is over the one-business-day SLA label Sep 30, 2026
@yuhezhang-ai

Copy link
Copy Markdown
Contributor

/ok to test 18c6678

@nemo-automation-bot
nemo-automation-bot Bot requested review from a team September 30, 2026 19:41
muskank7 pushed a commit to muskank7/Gym that referenced this pull request Sep 30, 2026
…quests (NVIDIA-NeMo#3682)

## What does this PR do?

Since NVIDIA-NeMo#2456 made `NeMoGymChatCompletionCreateParamsNonStreaming` strict
(`extra="forbid"`), every model server's non-streaming
`/v1/chat/completions` returns 422 for fields that Gym's own callers
send:

- `chat_template_kwargs`. Stirrup (`nemo_client.py`), hermes_agent and
mini_swe_agent_2 put it in `extra_body`, and the OpenAI SDK sends
`extra_body` keys as top-level request fields. hermes_agent sends it on
every call by default.
- `thinking` and `output_config`. These are Anthropic's reasoning
request fields (`{"type": "adaptive"}`, `{"effort": "high"}`), accepted
by OpenAI-compatible gateways that front Claude. Any OpenAI-SDK client
that passes them in `extra_body` hits the same 422. GDPVal's Claude
judge does (NVIDIA-NeMo#3686).

Separately, NVIDIA-NeMo#2491 typed the `input_audio` content part with the SDK's
`Literal["wav", "mp3"]`, so other formats also return 422. GDPVal's
media conversion (NVIDIA-NeMo#3686) sends m4a, flac, ogg, aac and aiff to
self-hosted audio judges.

Changes:

- `nemo_gym/openai_utils.py`
- Adds `chat_template_kwargs`, `thinking` and `output_config` to the
strict request model as `Optional[Dict[str, Any]]`. The set is exported
as `CHAT_REQUEST_PROVIDER_EXTENSION_FIELDS`. Other unknown keys, such as
a `thinkng` typo, still return 422.
- Adds `NeMoGymInputAudio`, where `format` is any string but still
required. The audio content part no longer subclasses the SDK's
`ChatCompletionContentPartInputAudioParam`, because a TypedDict subclass
can't widen the SDK's `Literal`.
- `nemo_gym/chat_streaming.py`: the `stream: true` sanitizer excludes
`CHAT_REQUEST_PROVIDER_EXTENSION_FIELDS`, so streaming requests keep
main's behaviour (the three fields are dropped, and 200 is returned).
Without this, adding the fields to the params model would have started
forwarding them on the streaming path.
- `responses_api_models/vllm_model/app.py`: adds
`forward_request_chat_template_kwargs: bool = False`. vllm_model always
removes the request's top-level `chat_template_kwargs` from the body. It
merges them only when this flag is on, in this order: configured
`chat_template_kwargs`, then the request field, then
`metadata.chat_template_kwargs`, with later sources overriding earlier
ones. When a non-empty value is dropped, a warning is logged once per
server. The flag applies to the chat path only. With
`use_completions_api: true` the request field is always dropped (with
the same one-time warning).
- `fern/versions/latest/pages/model-server/vllm.mdx`: adds the
`forward_request_chat_template_kwargs` row and documents the precedence,
the completions-path limit and the streaming drop under "Advanced:
`chat_template_kwargs`".
- Tests:
- The chat field-set pin is now SDK fields plus
`CHAT_REQUEST_PROVIDER_EXTENSION_FIELDS`, in `test_openai_utils.py` and
in vllm_model's `OPENAI_2_44_OPTIONAL_CHAT_FIELDS`.
  - Open audio formats are accepted, and `format` is still required.
  - Each extension field rejects values that aren't JSON objects.
- The streaming sanitizer drops all three fields, including a non-object
`thinking: true`.
- openai_model passes `thinking` and `output_config` through, and a typo
still returns 422.
- vllm_model: the precedence order with forwarding on (no warning); the
default drop, with exactly one warning over two requests; no warning for
an empty `{}`; `thinking` and `output_config` reach vLLM unchanged; the
completions path drops the field and warns.

Origin: this work reached the gdpval-devel branch through NVIDIA-NeMo#2103 (open
audio part) and NVIDIA-NeMo#3086 (extension fields and the forwarding flag). This
PR adds the streaming exclusion, the warning, and the missing tests and
docs. The `reasoning_field` config that shared these files on
gdpval-devel is split out into NVIDIA-NeMo#3683.

Issue: none filed. The 422 regression was found while moving
gdpval-devel into main.

Dependencies: none. This is NVIDIA-NeMo#3682 of the gdpval-devel promotion series.
NVIDIA-NeMo#3683 edits the same three vllm_model and docs files in separate hunks;
the two merge cleanly in either order. NVIDIA-NeMo#3686 needs this PR for its judge
requests and for five of its tests: with this PR's source files
reverted, 4 `test_judge_panel.py` cases and 1 `test_media_conversion.py`
audio case fail. NVIDIA-NeMo#3685 needs it at runtime for Stirrup's
`chat_template_kwargs`.

Open decision for review: the default of
`forward_request_chat_template_kwargs`. This PR keeps gdpval-devel's
behaviour: the field is accepted and dropped (with a one-time warning)
unless forwarding is enabled. The alternative is to forward by default,
which changes generation for every caller that sends the field.

## Validation

Branch `spanev/gym-chat-provider-fields-v2` (one commit on `1c8261080`),
macOS, Python 3.13, `uv venv --python 3.13 .venv && uv sync --extra
dev`.

- `pytest tests/unit_tests/test_openai_utils.py
tests/unit_tests/test_chat_completions_streaming.py
tests/unit_tests/test_public_api.py
responses_api_models/vllm_model/tests
responses_api_models/openai_model/tests -q`: 625 passed (246 + 38 + 61 +
270 + 10).
- `pytest responses_api_models/<server>/tests -q`, one server at a time:

  | Server | Result |
  |---|---|
  | azure_openai_model | 3 passed |
  | inference_provider | 23 passed |
  | litellm_model | 24 passed |
  | local_vllm_model_proxy | 2 passed |
  | openai_model | 10 passed |
  | switchyard_model | 42 passed, 6 skipped |
  | vllm_model | 270 passed |
  | vllm_model_with_compaction | 6 passed |

genrm_model and local_vllm_model were not run because they need the
`vllm` package.
- `pytest tests/unit_tests -q`: 5626 passed, 161 skipped, 60 failed (20
tests and 40 subtests). All failures are in six files that also fail on
unmodified main on macOS: `telemetry/test_memory.py`,
`test_ci_environment.py`, the `test_cli.py` memory profiler,
`test_enroot_provider.py`, `test_opensandbox_compose.py` and
`test_super_vllm_launcher.py`. The subtest count varies between runs
because `test_super_vllm_launcher.py` subtests are flaky on macOS.
- `resources_servers/gdpval` suite: 156 passed.
`responses_api_agents/stirrup_agent` suite: 151 passed. Both ran against
main plus this PR.
- `pre-commit run --files <the 8 changed files>`: all hooks pass.
- Ingress probe: direct `model_validate` calls, plus `POST
/v1/chat/completions` on vllm_model with a mocked upstream, default
config plus `chat_template_kwargs: {"enable_thinking": true}`:

  | Request | main `1c8261080` | This PR |
  |---|---|---|
| `chat_template_kwargs: {"enable_thinking": false}` | 422 | 200.
Upstream receives only the configured `{"enable_thinking": true}` |
  | `thinking: {"type": "adaptive"}` | 422 | 200 |
  | `output_config: {"effort": "high"}` | 422 | 200 |
  | `input_audio` with `format: "m4a"` | 422 | 200 |
  | `thinkng` (typo, control) | 422 | 422 |

- Streaming probe: `stream: true` with all three fields, on openai_model
and vllm_model with a mocked upstream. Both return 200 and the upstream
receives none of the three fields, the same as main. A non-object value
(`thinking: true`) on the streaming path also returns 200, as on main.

## Rollouts

**Live GDPVal smoke (final code):** all seven PRs of the series applied
together on main `1c8261080`, run with the devel-only SLURM launcher on
a 4-task AA-v2 comparison smoke (one 4-GPU policy node, CPU judging):
rollout 4/4 tasks saved and finished (1h04m, 0 failure rows), Office
preparation 7/7 converted, comparison judging 4/4 tasks judged with 0
imputed and 0 excluded, 3 wins / 1 loss. Per-task outcomes match an
earlier gdpval-devel run on the same checkpoint, tasks and references.
No rate limiting occurred, so the judge retry budget was not exercised
live.

## Compatibility and benchmark impact

- Non-streaming requests: the strict chat request model accepts three
new optional top-level fields, `chat_template_kwargs`, `thinking` and
`output_config`, each a JSON object. Requests that main rejects with 422
because of these fields now pass ingress.
- Streaming (`stream: true`) requests: no change. The sanitizer still
drops the three fields before validation, so PinchBench's OpenClaw
config (which sends `chat_template_kwargs` over streaming) behaves as on
main behind every model server.
- vllm_model (and the servers that inherit its config: local_vllm_model,
genrm_model, local_vllm_model_proxy, vllm_model_with_compaction): drops
a request's top-level `chat_template_kwargs` by default and logs one
warning per server. Configured `chat_template_kwargs` and
`metadata.chat_template_kwargs` behave as before. Turning
`forward_request_chat_template_kwargs` on changes generation for callers
that send the field, such as Stirrup's `enable_thinking` or
hermes_agent's defaults, so re-run baselines if you enable it.
- vllm_model forwards `thinking` and `output_config` to the vLLM
endpoint unchanged on the chat path (`use_completions_api: false`, the
default) and doesn't interpret them. With `use_completions_api: true`,
none of the three fields reaches the `/v1/completions` body.
- openai_model, inference_provider, switchyard_model and litellm_model
forward all three fields to their upstream unchanged on non-streaming
requests. An upstream that doesn't know a field now returns its own
error instead of Gym's 422.
- azure_openai_model passes the body to the OpenAI SDK's
`chat.completions.create`, which rejects unknown keyword arguments, so
these requests now fail with a 500 before reaching upstream instead of
Gym's 422.
- `input_audio.format` accepts any string instead of only `wav` or
`mp3`, and is still required.
`NeMoGymChatCompletionContentPartInputAudioParam` is no longer a
subclass of the SDK TypedDict; this affects type checking only.
- New module-level names in `nemo_gym.openai_utils` (not re-exported
from `nemo_gym`): `NeMoGymInputAudio` and
`CHAT_REQUEST_PROVIDER_EXTENSION_FIELDS`.
- Benchmark impact: none with default config. Every request main accepts
today, streaming or not, sends the same body upstream. Requests main
rejects today now succeed; behind vllm_model their
`chat_template_kwargs` is ignored.

## Checklist

- [ ] I have read the [contributing
guidelines](https://github.com/NVIDIA-NeMo/Gym/blob/main/CONTRIBUTING.md).
- [x] The change is focused; unrelated "drive-by" edits are tracked as
separate issues/PRs.
- [x] Tests added or updated and pass locally, or N/A for docs-only /
non-code changes (so CI unit/server checks pass when applicable).
- [x] Documentation added or updated, or N/A with justification.
- [x] Pre-commit checks pass locally (`pre-commit run --all-files`) (so
CI lint/format checks pass). Run scoped to the changed files.
- [x] New source files include the required Apache-2.0 SPDX header. N/A,
no new files.
- [x] All commits have DCO sign-off (`git commit -s`) (so the DCO check
passes).

Signed-off-by: Serge Panev <spanev@nvidia.com>
@yuhezhang-ai
yuhezhang-ai added this pull request to the merge queue Oct 1, 2026
@github-actions

github-actions Bot commented Oct 1, 2026

Copy link
Copy Markdown
Contributor

πŸ”„ Merge queue validation started! You can track the progress here: https://github.com/NVIDIA-NeMo/Gym/actions/runs/36800414919

Merged via the queue into main with commit 3066614 Oct 1, 2026
47 checks passed
@yuhezhang-ai
yuhezhang-ai deleted the spanev/stirrup-aa-v2-runtime branch October 1, 2026 01:31
khazic pushed a commit to khazic/Gym-lao that referenced this pull request Oct 1, 2026
…IA-NeMo#3686)

## What does this PR do?

This is NVIDIA-NeMo#3686 of the series that moves `gdpval-devel` to main, cut by
file. It covers GDPVal judging in the resources server, the GDPVal
benchmark config and README, the local-judge overlays, and the Stirrup
agent's GDPVal task code (`app.py`, `file_reader.py`, `prompts/`). Every
file is copied whole from one snapshot: `gdpval-devel` merged with main,
plus NVIDIA-NeMo#2046's review fixes and pre-promotion fixes, including the fixes
listed at the end of this section. The work comes from devel PRs NVIDIA-NeMo#2103,
NVIDIA-NeMo#2331, NVIDIA-NeMo#2338, NVIDIA-NeMo#2588, NVIDIA-NeMo#3086, NVIDIA-NeMo#3121, NVIDIA-NeMo#3131, NVIDIA-NeMo#3184, NVIDIA-NeMo#3335 and NVIDIA-NeMo#3648, and
from NVIDIA-NeMo#2046.

**Judge transport per provider (comparison mode; NVIDIA-NeMo#2588, NVIDIA-NeMo#3131)**
- Each panel member (GPT-5.5, Gemini 3.1 Pro, Claude Opus 4.8) has its
own fixed-model `openai_model` proxy. All three default to the same
endpoint (`JUDGE_BASE_URL`).
- Each member declares how it receives PDFs and rendered Office files
(`media_mode`: `native_pdf`, `images_and_text` or
`native_pdf_overflow_images`), plus provider limits: native-PDF pages,
documents and bytes, image bytes, video count, raster DPI tiers, and a
serialized-request cap.
- A deterministic preflight removes any member that can't carry a
matchup within its limits. If every member is removed for every matchup,
the row fails as terminal `transport_ineligible`.
- These per-member settings apply to comparison mode only. Rubric mode
samples from the same panel but sends every member the server-level
`judge_media_mode` (native PDFs by default), with no per-member limits
or preflight.
- Audio and video capability are separate flags (`handles_audio`,
`handles_video`, `on_missing_av_judge`). Video goes to video-capable
members. Audio that the routed member can't read is dropped with a
warning.
- Attachments are bounded per file, per section and per request (raw and
encoded bytes), with ZIP member limits and text caps. An attachment left
out for size is replaced by a marker the judge sees.
- Legacy `.doc`, `.ppt` and `.xls` files are preconverted. When two
Office files share a stem, each gets its own `<name>.<ext>.pdf` (NVIDIA-NeMo#2331).
A failed OOXML conversion is retried once from a
python-docx/python-pptx/openpyxl re-save.

**Validity of judge results (NVIDIA-NeMo#2588, NVIDIA-NeMo#3648)**
- Rubric: a truncated, scoreless or unparseable reply is flagged
`invalid_judge_response` with a `scoring_error` instead of scoring 0 or
a partial score.
- Structured rubric only (`rubric_scoring_mode: structured`): negative
criterion points no longer count toward `max_possible` (NVIDIA-NeMo#3648).
- Comparison: the verdict is the last `BOXED[...]` token, a reply with
no verdict is dropped instead of counted as a tie, and the prompt ends
with a final-verdict reminder.
- A request that is too large or overflows the context window fails as
permanent. Throttling is retried; rubric retries now also match 502,
"bad gateway" and "too many requests".
- `strict_comparison_trials` (opt-in) rejects a row unless every planned
vote is valid.
- Stirrup turns an invalid judge response into a retryable
`judge_invalid` failure that keeps the finished deliverable
(`reuse_cached_deliverable`), so the retry re-judges it without
re-running the policy.
- Deliverable conversion runs off the event loop.

**Local judges (NVIDIA-NeMo#2046, NVIDIA-NeMo#2103)**
- `judge_media_mode: images_and_text` rasterizes PDF and Office pages
with PyMuPDF and attaches the extracted text (`media_conversion.py`).
`python -m resources_servers.gdpval.inspect_conversion` writes out what
an `images_and_text` judge receives.
- Overlays for a Kimi-K2.6 judge that gym spawns
(`gdpval_kimi_local_judge.yaml`,
`local_vllm_model/configs/moonshotai/Kimi-K2.6.yaml`) and for a
self-hosted MiniMax-M3 endpoint
(`gdpval_minimax_selfhosted_judge.yaml`).

**Inputs (NVIDIA-NeMo#3131, NVIDIA-NeMo#3335)**
- `judge_reference_files_recursive` (opt-in) includes nested files under
a task's `reference_files/`. `judge_reference_files_from_eval` (opt-in)
reads `reference_files/` from the eval task directory instead of each
reference model's directory.
- `count_eval_missing_as_loss` with `missing_eval_task_ids` (opt-in,
multi-stage `stage_index` 1 only) scores a listed task with no eval
deliverable as a loss. The resources server and the Stirrup agent must
both set them.
- `setup_libreoffice.py` skips the reinstall when LibreOffice's
`javaldx` works, and the apt timeout is 1800 s.

**Stirrup GDPVal task code (NVIDIA-NeMo#2338, NVIDIA-NeMo#2588, NVIDIA-NeMo#3121, NVIDIA-NeMo#3131, NVIDIA-NeMo#3184)**
- New agent config fields, defaulting to the old behaviour:
`context_window_tokens` (passed as the client's `max_tokens`),
`min_completion_tokens`, `prompt_estimator_truncate_history_thinking`
and `truncation_recovery` (passed to NVIDIA-NeMo#3685's client), and
`min_compaction_summary_words` (passed to NVIDIA-NeMo#3685's `NeMoAgent`).
- The GDPVal prompts (`prompts/`, moved here from NVIDIA-NeMo#3685 so they land
with the code that builds their reference paths) name the registered
`code_exec` tool instead of `run_shell`, list only packages the AA v2
sandbox installs, and state the `/root` working directory, the 5-minute
command limit and that shell state does not persist. Reference files are
listed relative to the sandbox working directory, without the
`gdpval_ref_files_<id>/` prefix.
- The verify cache is keyed by the row's namespace. The file reader
bounds text extraction, sniffs unknown extensions for text, lists
archive contents, and degrades without LibreOffice.

**Benchmark config**
- Starts the three member proxies alongside `gdpval_judge_model`, each
with `max_http_attempts: 1800`, and sets each member's media limits.
- Claude judge: `thinking: {type: adaptive}` with `output_config.effort:
high` and no temperature.
- `judge_pdf_max_pages: 1000`.
- Stirrup: `min_completion_tokens: 8192`, `context_window_tokens:
262144`, `min_compaction_summary_words: 50`, `truncation_recovery:
true`.
- NVIDIA-NeMo#3684's `retry_terminal_timeouts` and `retry_invalid_judge_responses`,
both `true`.

**Fixes made for this PR**
- The removed `handles_audio_video` member key is now migrated to both
new flags with a deprecation warning; combining it with either new flag
is an error. Before, it was silently ignored.
- `inspect_conversion.py` crashed at start (`ImportError` on
`IGNORE_FILES`). It and `comparison.py` now read `IGNORE_FILES` from
`nemo_gym.deliverables`.
- README and config comments corrected: per-member proxies, Claude's
adaptive thinking, the comparison-only scope of per-member transport,
how the page cap works in comparison mode, the judge timeout and retry
layers, and the Kimi overlay. The README now also documents the judge
input options, attachment environment variables and aggregate metrics.
- New tests for the overlays, the comparison retry loop, truncated
rubric JSON, the inspector, and the Stirrup client and agent arguments.
- The NVIDIA-NeMo#3666 media-preparation helpers (`media.py`, and
`check_reference_inputs` and `office` moved into it from
`preconvert.py`) have no caller in this series and stay on
`gdpval-devel`.

Issue: none. Most of this was reviewed in the devel PRs above; the fixes
in the last group are new here. Related: NVIDIA-NeMo#2046, whose changes and review
fixes are included.

**Depends on NVIDIA-NeMo#3682, NVIDIA-NeMo#3685 and NVIDIA-NeMo#3684; merge after them.** It does not
need NVIDIA-NeMo#3681 or NVIDIA-NeMo#3683.
- NVIDIA-NeMo#3684: `comparison.py` and Stirrup's `file_reader.py` import
`nemo_gym/deliverables.py` at module scope, so both suites fail to
collect without it. The benchmark config sets C3's two retry flags,
which main's config silently ignores. C3's rollout collection also
carries `reuse_cached_deliverable` onto the retried row; without it a
`judge_invalid` retry re-runs the policy.
- NVIDIA-NeMo#3682: the Claude judge sends `thinking` and `output_config`, which
main's strict chat schema rejects with a 422. Main's audio part also
accepts only wav and mp3.
- NVIDIA-NeMo#3685: Stirrup's `app.py` passes the new arguments to NVIDIA-NeMo#3685's client
and agent; main's client raises `TypeError: ... unexpected keyword
argument 'min_completion_tokens'`. The prompt tests read NVIDIA-NeMo#3685's
vendored sandbox manifests.
- Open on NVIDIA-NeMo#3682: under its current default
(`forward_request_chat_template_kwargs: false`), `vllm_model` drops
request `chat_template_kwargs`. The thinking-off half of
`truncation_recovery` then does not reach a policy served by
`vllm_model`; only the one-turn instruction applies. This config does
not set the flag.

## Validation

macOS, Python 3.13. Each server suite ran in a venv built from that
server's `requirements.txt`, with `PYTHONPATH` set to the tree under
test.

```bash
cd resources_servers/gdpval && python -m pytest tests -q
cd responses_api_agents/stirrup_agent && python -m pytest tests -q
python -m pytest tests/unit_tests/test_rollout_collection.py tests/unit_tests/test_openai_utils.py tests/unit_tests/test_deliverables.py -q
```

| Tree | GDPVal | Stirrup |
|---|---|---|
| main `1c8261080` + NVIDIA-NeMo#3681, C2, C3, S and this PR | 414 passed | 321
passed, 2 skipped |
| Same, with `cryptography` not importable | 411 passed, 3 skipped
(`test_media_conversion` text extraction) | β€” |
| Full series, including NVIDIA-NeMo#3687 | 603 passed | 321 passed, 2 skipped |

The three core unit files: 507 passed on the first tree.

Each dependency was confirmed by removing one PR's files:

| Removed | GDPVal | Stirrup |
|---|---|---|
| NVIDIA-NeMo#3681 | no change | no change |
| NVIDIA-NeMo#3682 | 5 failed: 4 cases of
`test_benchmark_panel_reaches_intended_upstream_models_and_credentials`,
and `test_every_supported_audio_extension_passes_proxy_ingress` | no
change |
| NVIDIA-NeMo#3684 | collection errors (`No module named 'nemo_gym.deliverables'`)
| collection errors, same cause |
| NVIDIA-NeMo#3685 | no change | 5 failed: 4 prompt tests that read NVIDIA-NeMo#3685's
manifests, and `test_runtime_knobs_reach_the_client_and_agent` (the
`TypeError` above) |
| This PR alone | collection errors, same cause | collection errors,
same cause |

`pre-commit run --files` on this PR's files passed with no changes; the
checklist item below is scoped the same way. `uvx ruff@0.9.9 format
--check` and `check` are clean on all 44 files, and new files carry the
SPDX header.

## Rollouts

**Live GDPVal smoke (final code):** all seven PRs of the series applied
together on main `1c8261080`, run with the devel-only SLURM launcher on
a 4-task AA-v2 comparison smoke (one 4-GPU policy node, CPU judging):
rollout 4/4 tasks saved and finished (1h04m, 0 failure rows), Office
preparation 7/7 converted, comparison judging 4/4 tasks judged with 0
imputed and 0 excluded, 3 wins / 1 loss. Per-task outcomes match an
earlier gdpval-devel run on the same checkpoint, tasks and references.
No rate limiting occurred, so the judge retry budget was not exercised
live.

This PR stays a draft until NVIDIA-NeMo#3682, NVIDIA-NeMo#3685 and NVIDIA-NeMo#3684 merge.

## Compatibility and benchmark impact

GDPVal scores after this PR are not comparable with main's. Re-baseline
rubric runs and comparison/ELO runs.

**Configuration**
- Panel members: `handles_audio_video` is replaced by `handles_audio`
and `handles_video` (the old key is migrated with a warning). Member
names must resolve uniquely, or verify raises `ValueError`. A
`native_pdf_overflow_images` member needs `max_native_pdf_pages` and
`max_native_pdf_bytes_per_document`.
- New resources-server fields (defaults): `judge_media_mode`
(`native_pdf`), `judge_pdf_render_dpi` (144), `judge_pdf_max_pages` (50;
the benchmark sets 1000), `judge_pdf_include_text` (true),
`judge_max_images_per_request` (450), `judge_handles_audio` /
`judge_handles_video` (false), `on_missing_av_judge` (`warn`),
`judge_reference_files_recursive` / `judge_reference_files_from_eval`
(false), `strict_comparison_trials` (false),
`count_eval_missing_as_loss` (false), `missing_eval_task_ids` (`[]`),
and the per-member transport fields (unset means no limit).
- In comparison mode a raster judge renders up to
`max(judge_pdf_max_pages, judge_max_images_per_request)` pages per file,
and is not used for a matchup whose pages and images exceed
`judge_max_images_per_request`. The overlays' `judge_pdf_max_pages: 30`
therefore bounds rubric mode only.
- New environment variables: `GDPVAL_JUDGE_REQUEST_TIMEOUT_SECONDS`
(comparison default goes from 120 s to 300 s; rubric is 1800 s, where
the SDK's 600 s with two retries applied before),
`GDPVAL_MAX_FILE_BYTES_FOR_JUDGE` (250 MiB, as before, now overridable),
the five section/total attachment and request limits, `JUDGE_GPT_MODEL`,
`JUDGE_GEMINI_MODEL`, `JUDGE_CLAUDE_MODEL`, `JUDGE_GEMINI_API_KEY`
(falls back to `JUDGE_API_KEY`) and
`GDPVAL_GEMINI_MAX_CONCURRENT_REQUESTS` (2).
- Judge SDK clients use `max_retries=0`; GDPVal's own loops and each
proxy's `max_http_attempts` retry instead. `max_http_attempts: 1800`
gives about 15 minutes of retries at 0.5 s per request. Main's retry set
also covers 404, 408 and 500, so a persistent 404 (such as an unknown
model route) takes the whole window to surface. A comparison call times
out after 300 s, is not retried, and time queued behind the Gemini
proxy's 2-request limit counts toward it; the proxy can keep retrying an
abandoned call until its window closes. The README documents how to
lower the window per proxy.
- The Claude proxy's `extra_body` sends `timeout: 900` and
`request_timeout: 900` in every request, as gateway-side keys for the
default endpoint. Endpoints that reject unknown fields need them
removed.
- The benchmark config starts `gdpval_gpt55_judge_model`,
`gdpval_gemini31_judge_model` and `gdpval_claude48_judge_model`
alongside `gdpval_judge_model`. With `retry_terminal_timeouts: true`,
`reference_missing`, `eval_missing` and `transport_ineligible` are
terminal within a run and re-checked on `--resume` (NVIDIA-NeMo#3684).

**Changes that move GDPVal scores**
- Rubric: invalid judge replies no longer score 0 or a partial score.
The resources server's rubric `aggregate_metrics` leaves
`invalid_judge_response` rows out of the mean for any agent, and adds
`rubric/aggregate_rows_total`, `rubric/aggregate_rows_included`,
`rubric/legacy_invalid_rows_excluded` and
`rubric/aggregate_rows_included_fraction`.
- Structured rubric: negative points are excluded from `max_possible`.
Binary mode is unchanged.
- Comparison verdicts: last `BOXED[...]` token, no-verdict replies
dropped, final-verdict reminder in the prompt. `eval_missing` (no
`finish_params.json`) was scored as a loss; it is now a terminal failure
in the sidecar, like `reference_missing` and `transport_ineligible`.
- Comparison payloads: an Office file's rendered PDF is attached once
(main attached it under both names). Office files with no render and
file types the judge can't read are left out instead of sent as filename
stubs. Every rendered `.xlsx` also carries a structured-cells text block
(formulas and cached values, up to 120k characters). Each member sees
PDFs in its own representation and within its own limits.
- Comparison aggregates: when rows carry `expected_final_stage_index`
(written by NVIDIA-NeMo#3687), the headline `comparison/eval_elo` comes from that
stage. If it is missing, incomplete or unfit, the headline is withheld
(`comparison/final_stage_degraded=1`) rather than taken from another
stage; rows without the field keep the last-stage headline. New keys:
`comparison/stage_<k>/{judged_tasks,judged_votes,imputed_loss_tasks,imputed_loss_votes}`,
`comparison/headline_stage_index`, `comparison/final_stage_*` and
`comparison/expected_final_stage_*`. `comparison/win_rate` is omitted
when no vote was judged. `judge_response` gains `total_invalid` and a
per-judge `invalid_count`.
- Claude judge: adaptive thinking with high effort and no temperature
(was `thinking: enabled`).
- Policy (set in the GDPVal config): `min_completion_tokens: 8192` (the
code default stays 1024), `min_compaction_summary_words: 50` and
`truncation_recovery: true`. The rewritten prompt names the real tool,
package set and working directory, and its reference-file paths exist in
the sandbox.

**Every `stirrup_agent` config**
- `timeout_exceeded` is no longer stamped terminal; on `--resume` it is
retried up to `NEMO_GYM_MAX_ROLLOUT_ATTEMPTS`.
- A verify result with `invalid_judge_response` becomes a failure
instead of a scored row: retryable `judge_invalid`, or terminal
`permanent` when the server sets `invalid_judge_retryable: false`.
- A `/verify` error whose text matches GDPVal's permanent markers (413,
"maximum context length", "too many tokens", "input is too long" and
similar) is stamped terminal `permanent` and not retried, unless a
throttle marker is also present.
- The request's `max_output_tokens` now caps completion tokens; the
context window comes from `context_window_tokens` (default 262144).
- Failure rows after `/verify` carry the policy response plus
`deliverables_dir`, `elapsed_seconds` and `_ng_timeout_inflight`.
`judge_only` and `rerun_incomplete` fall back to a flat `task_<id>/`
directory.
- Stirrup now imports `resources_servers.gdpval` (`scoring`,
`judge_panel`, `preconvert`, `media_conversion`), while the GDPVal
server already imports Stirrup's file reader.

**Known limitations**
- `requirements.txt` lists `cryptography>=42.0`, but the root
`pyproject.toml` `exclude-dependencies` drops it from server venvs, and
an `overrides.txt` does not bring it back. Without it, pdfminer can't
load: the `images_and_text` text copy is empty (a warning is logged), 3
`test_media_conversion` tests skip, and rubric `read_deliverable_files`
returns an error string for PDFs (also true on main). The fix is a
change to the root `pyproject.toml`.
- Rubric judge sampling is seeded per task, so a `judge_invalid` retry
draws the same member. A provider that rejects a task's payload in
rubric mode fails every attempt until the attempt limit.
- ZIP members skipped by the archive limits are logged but not marked in
the judge request.
- The README's multi-stage sections describe `partial_completion` and
other behaviour that lands with NVIDIA-NeMo#3687.

## Checklist

- [x] I have read the [contributing
guidelines](https://github.com/NVIDIA-NeMo/Gym/blob/main/CONTRIBUTING.md).
- [x] The change is focused; unrelated "drive-by" edits are tracked as
separate issues/PRs.
- [x] Tests added or updated and pass locally, or N/A for docs-only /
non-code changes (so CI unit/server checks pass when applicable).
- [x] Documentation added or updated, or N/A with justification.
- [x] Pre-commit checks pass locally (scoped: `pre-commit run --files`
on this PR's files).
- [x] New source files include the required Apache-2.0 SPDX header.
- [x] All commits have DCO sign-off (`git commit -s`) (so the DCO check
passes).

Signed-off-by: Serge Panev <spanev@nvidia.com>
Co-authored-by: Marta Stepniewska-Dziubinska <marta-sd@users.noreply.github.com>

This branch was previously deployed

1 inactive deployment
public β€” 18c66789 Deployed Sep 30, 2026 by copy-pr-bot[bot] via release / finalize / notify #4061
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:agent Agent harnesses and Responses API agent behavior complexity:high Cross-component or shared-contract change with a large review surface feature New capabilities, enhancements, or enablement work needs-review PR is ready for code review and waiting on a reviewer

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants