Skip to content

feat: declare failure_reason on BaseVerifyResponse - #2552

Merged
tdene merged 3 commits into
NVIDIA-NeMo:mainfrom
tdene:tde/base_failure_reason
Aug 25, 2026
Merged

tdene merged 3 commits into
NVIDIA-NeMo:mainfrom
tdene:tde/base_failure_reason

Conversation

@tdene

@tdene tdene commented Aug 14, 2026 •

Copy link
Copy Markdown
Contributor

What does this PR do?

Declares failure_reason field on BaseVerifyResponse to standardize a way to report why a returned reward may not reflect the policy quality. failure_reason is meant to be human-readable; see #2750 for the whole design document.

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).
  • Pre-commit checks pass locally (pre-commit run --all-files) (so CI lint/format/copyright pass).
  • All commits have DCO sign-off (git commit -s) (so the DCO check passes).

@copy-pr-bot

copy-pr-bot Bot commented Aug 14, 2026

Copy link
Copy Markdown

Auto-sync is disabled for draft pull requests in this repository. Workflows must be run manually.

Contributors can view more details about this message here.

@tdene
tdene force-pushed the tde/base_failure_reason branch from a9aac53 to 59208ab Compare August 18, 2026 10:59
@waple0820

Copy link
Copy Markdown
Contributor

Hi — we hit exactly the gap this PR addresses, from the RL-training side, and we'd like to land our half with this PR rather than propose a competing one.

Context: we run browser-based RL on Gym (one isolated cloud browser per rollout, verified by an LLM judge). On a GB200 run at 64-way concurrency, orphaned environment sessions caused 1172 of 1280 rollouts across steps 61–80 (91.6%) to fail in session reset. Every one of those entered GRPO as a legitimate reward=0.0 sample. They didn't just contribute no gradient — they depressed the group baseline, inflating the advantage of the few survivors, so the run trained away from correct behavior. Mean reward went 15.45% → 0.39%.

failure_reason is exactly the right field for triage, and we'd adopt it verbatim. The one thing it can't do on its own is drive a training decision: a framework can't branch on free-form text. What we need alongside it is a machine-readable boolean.

The good news is that the boolean already exists in this repo — it's just not on the contract:

  • responses_api_agents/swe_agents/app.py:256 — mask_sample: bool = False, on SWEBenchWrapperInstanceConfig. Also in anyterminal_agent/app.py:106 and anyswe_agent/app.py:169, with semantics documented in swe_agents/README.md:653.
  • NeMo-RL already consumes it end-to-end: nemo_rl/experience/rollouts.py:229-241 _extract_mask_sample_flags() reads full_result["instance_config"]["mask_sample"], which reaches final_batch["mask_sample"] and zeroes loss_multiplier in nemo_rl/algorithms/grpo.py:2104.

So the semantics are settled and have a first-party consumer — but because the field hangs off one agent family's private config model, it only works for three SWE-shaped agents, and NeMo-RL has to hard-code that private schema path. Every other environment in the repo gets nothing, and verl gets nothing at all (verl-recipe nemo_gym/agent_loop.py:460 reads result["full_result"]["reward"]; _empty_result() at :368-380 hard-codes {"reward": 0.0}).

Would you be open to landing both fields together on BaseVerifyResponse?

class BaseVerifyResponse(BaseVerifyRequest):
    reward: float
    mask_sample: bool = False              # machine-readable: reward doesn't reflect policy quality
    failure_reason: Optional[str] = None   # human-readable cause (this PR)

Both default-inert, so existing environments are unchanged. Gym would state the fact and stop there — whether to mask the loss, resample, exclude from group statistics, or drop the group stays the training framework's decision, and the three frameworks we checked do genuinely differ there.

We're happy to do the work: contribute the mask_sample half as a PR on top of this one, migrate the three SWE agents to set the top-level field (keeping instance_config.mask_sample as a deprecated alias for a release), and add the round-trip tests. We're opening a tracking issue for the boolean shortly and will link it here — but we wanted to check with you first, since we'd rather this land once than twice.

@tdene

tdene commented Aug 19, 2026

Copy link
Copy Markdown
Contributor Author

@waple0820 thank you for the comment!

Could you please also take a look at #2383 #2384 #2385 #2386 #2387 #2388 #2410 as well? Those are all related PRs, in the spirit of this one, that handle resiliency issues I personally found to be critical. In fact this PR was opened to address reviewer comments on #2384.

It would be very useful to have your feedback on whether those fixes also look important to you.

@tdene
tdene force-pushed the tde/base_failure_reason branch from 59208ab to 4b4993f Compare August 21, 2026 15:16
Comment thread nemo_gym/base_resources_server.py Outdated
Comment on lines +99 to +103
# Elaborates on why `reward` may not be fully genuine, allowing the upstream
# training loop to decide on how to treat the response.
# For example, timeouts may result in reward=0, and we should be honest about the reason.
failure_reason: Optional[str] = None

@ananthsub ananthsub Aug 24, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

echoing @waple0820 's comment, I believe it'd be useful to have these 3 fields:

  • mask_sample: signoff from the verifier/environment around using this reward downstream
  • failure_kind: a diagnosis/category of failures that we can use for grouping in telemetry
  • failure_reason: freeform error message

mask_sample=False but failure_kind+failure_reason being set can indicate a degraded but scored state as legitimate

@ananthsub ananthsub mentioned this pull request Aug 24, 2026
2 tasks done
waple0820 added a commit to waple0820/Gym that referenced this pull request Aug 25, 2026
An environment that knows it failed - lost session, unavailable judge,
OOM-killed container, reset timeout - can only return reward=0.0 today, which is
indistinguishable from a policy that genuinely scored zero.

The field already exists in this repo, just not on the contract: `mask_sample`
lives on SWEBenchWrapperInstanceConfig (responses_api_agents/swe_agents/app.py),
anyterminal_agent and anyswe_agent, and NeMo-RL already consumes it end to end
through full_result["instance_config"]["mask_sample"]. Because it hangs off one
agent family's private config model it covers three SWE-shaped agents only,
NeMo-RL has to hard-code that private path, and verl gets nothing.

Promote it to BaseVerifyResponse, alongside the human-readable failure_reason
proposed in NVIDIA-NeMo#2552:

* mask_sample: bool = False - machine-readable, for training frameworks
* failure_reason: Optional[str] = None - for triage and logs

Gym states the fact and stops there. Whether to mask the loss, resample the
episode, exclude the sample from group statistics or drop the group stays the
training framework's decision; the frameworks we checked genuinely differ.

Defaults are inert, so existing environments are unchanged. swe_agents and
anyswe_agent now mirror their instance-config flag onto the contract field and
keep the old location for one release; anyterminal_agent already surfaces it via
TerminalBenchMetrics.

Closes NVIDIA-NeMo#2608

Signed-off-by: waple0820 <232305951+waple0820@users.noreply.github.com>
@tdene
tdene force-pushed the tde/base_failure_reason branch from ebd6c32 to 989be05 Compare August 25, 2026 08:56
@tdene
tdene marked this pull request as ready for review August 25, 2026 14:16
@tdene

tdene commented Aug 25, 2026

Copy link
Copy Markdown
Contributor Author

/ok to test 989be05

ananthsub
ananthsub previously approved these changes Aug 25, 2026
@tdene

tdene commented Aug 25, 2026

Copy link
Copy Markdown
Contributor Author

/ok to test 700bf92

@copy-pr-bot

copy-pr-bot Bot commented Aug 25, 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.

tdene added 3 commits August 25, 2026 13:36
Signed-off-by: Teodor-Dumitru Ene <teodord.ene@gmail.com>
Signed-off-by: Teodor-Dumitru Ene <teodord.ene@gmail.com>
Signed-off-by: Teodor-Dumitru Ene <teodord.ene@gmail.com>
@tdene
tdene force-pushed the tde/base_failure_reason branch from 02ad6f8 to 935e673 Compare August 25, 2026 18:37
@tdene
tdene merged commit 438570a into NVIDIA-NeMo:main Aug 25, 2026
36 checks passed
waple0820 added a commit to waple0820/Gym that referenced this pull request Aug 27, 2026
An environment that knows it failed - lost session, unavailable judge,
OOM-killed container, reset timeout - can only return reward=0.0 today, which is
indistinguishable from a policy that genuinely scored zero.

The field already exists in this repo, just not on the contract: `mask_sample`
lives on SWEBenchWrapperInstanceConfig (responses_api_agents/swe_agents/app.py),
anyterminal_agent and anyswe_agent, and NeMo-RL already consumes it end to end
through full_result["instance_config"]["mask_sample"]. Because it hangs off one
agent family's private config model it covers three SWE-shaped agents only,
NeMo-RL has to hard-code that private path, and verl gets nothing.

Promote it to BaseVerifyResponse, alongside the human-readable failure_reason
proposed in NVIDIA-NeMo#2552:

* mask_sample: bool = False - machine-readable, for training frameworks
* failure_reason: Optional[str] = None - for triage and logs

Gym states the fact and stops there. Whether to mask the loss, resample the
episode, exclude the sample from group statistics or drop the group stays the
training framework's decision; the frameworks we checked genuinely differ.

Defaults are inert, so existing environments are unchanged. swe_agents and
anyswe_agent now mirror their instance-config flag onto the contract field and
keep the old location for one release; anyterminal_agent already surfaces it via
TerminalBenchMetrics.

Closes NVIDIA-NeMo#2608

Signed-off-by: waple0820 <232305951+waple0820@users.noreply.github.com>
waple0820 added a commit to waple0820/Gym that referenced this pull request Aug 28, 2026
An environment that knows it failed - lost session, unavailable judge,
OOM-killed container, reset timeout - can only return reward=0.0 today, which is
indistinguishable from a policy that genuinely scored zero.

The field already exists in this repo, just not on the contract: `mask_sample`
lives on SWEBenchWrapperInstanceConfig (responses_api_agents/swe_agents/app.py),
anyterminal_agent and anyswe_agent, and NeMo-RL already consumes it end to end
through full_result["instance_config"]["mask_sample"]. Because it hangs off one
agent family's private config model it covers three SWE-shaped agents only,
NeMo-RL has to hard-code that private path, and verl gets nothing.

Promote it to BaseVerifyResponse, alongside the human-readable failure_reason
proposed in NVIDIA-NeMo#2552:

* mask_sample: bool = False - machine-readable, for training frameworks
* failure_reason: Optional[str] = None - for triage and logs

Gym states the fact and stops there. Whether to mask the loss, resample the
episode, exclude the sample from group statistics or drop the group stays the
training framework's decision; the frameworks we checked genuinely differ.

Defaults are inert, so existing environments are unchanged. swe_agents and
anyswe_agent now mirror their instance-config flag onto the contract field and
keep the old location for one release; anyterminal_agent already surfaces it via
TerminalBenchMetrics.

Closes NVIDIA-NeMo#2608

Signed-off-by: waple0820 <232305951+waple0820@users.noreply.github.com>
waple0820 added a commit to waple0820/Gym that referenced this pull request Aug 28, 2026
…coring it zero

`verify` returned `reward=0.0` whenever the browser session was missing, which is
the conflation this environment exists to demonstrate: a rollout whose session was
lost is not a policy that solved nothing, and downstream the two are identical.
It now reports `failure_reason` on that path, using the field NVIDIA-NeMo#2552 added to
`BaseVerifyResponse`.

A browser that died mid-episode was worse than mis-scored. `_score` reads the live
page, so a dead browser raised out of `verify`; only `JudgeError` is converted to a
routed row, so that exception ended the whole collection run rather than the one
rollout it belonged to. Browser reads are now wrapped and reported the same way.
An unsupported scoring key still raises, because a dataset typo is a configuration
error rather than an infrastructure failure, and failing on the first rollout is
the intended behaviour.

Building the response by spreading the request and also passing `reward` as a
keyword was a latent `TypeError: got multiple values`: `BrowserVerifyRequest`
allows extra fields, so a caller putting `reward` or `failure_reason` in the body
crashed verify. Response-owned fields are now dropped from the spread. The same
shape was found and fixed in `anyswe_agent` on NVIDIA-NeMo#2611.

Closing the previous browser on a re-seeded session swallowed every exception. A
browser we could not close is a resource the run still holds, so it is logged.

Tests build and serialize the real response, since the collision only appears at
construction time. Reverting any one of the three fixes fails a test.

Signed-off-by: waple0820 <feng.wang@lexmount.com>
waple0820 added a commit to waple0820/Gym that referenced this pull request Aug 31, 2026
An environment that knows it failed - lost session, unavailable judge,
OOM-killed container, reset timeout - can only return reward=0.0 today, which is
indistinguishable from a policy that genuinely scored zero.

The field already exists in this repo, just not on the contract: `mask_sample`
lives on SWEBenchWrapperInstanceConfig (responses_api_agents/swe_agents/app.py),
anyterminal_agent and anyswe_agent, and NeMo-RL already consumes it end to end
through full_result["instance_config"]["mask_sample"]. Because it hangs off one
agent family's private config model it covers three SWE-shaped agents only,
NeMo-RL has to hard-code that private path, and verl gets nothing.

Promote it to BaseVerifyResponse, alongside the human-readable failure_reason
proposed in NVIDIA-NeMo#2552:

* mask_sample: bool = False - machine-readable, for training frameworks
* failure_reason: Optional[str] = None - for triage and logs

Gym states the fact and stops there. Whether to mask the loss, resample the
episode, exclude the sample from group statistics or drop the group stays the
training framework's decision; the frameworks we checked genuinely differ.

Defaults are inert, so existing environments are unchanged. swe_agents and
anyswe_agent now mirror their instance-config flag onto the contract field and
keep the old location for one release; anyterminal_agent already surfaces it via
TerminalBenchMetrics.

Closes NVIDIA-NeMo#2608

Signed-off-by: waple0820 <232305951+waple0820@users.noreply.github.com>
waple0820 added a commit to waple0820/Gym that referenced this pull request Aug 31, 2026
…coring it zero

`verify` returned `reward=0.0` whenever the browser session was missing, which is
the conflation this environment exists to demonstrate: a rollout whose session was
lost is not a policy that solved nothing, and downstream the two are identical.
It now reports `failure_reason` on that path, using the field NVIDIA-NeMo#2552 added to
`BaseVerifyResponse`.

A browser that died mid-episode was worse than mis-scored. `_score` reads the live
page, so a dead browser raised out of `verify`; only `JudgeError` is converted to a
routed row, so that exception ended the whole collection run rather than the one
rollout it belonged to. Browser reads are now wrapped and reported the same way.
An unsupported scoring key still raises, because a dataset typo is a configuration
error rather than an infrastructure failure, and failing on the first rollout is
the intended behaviour.

Building the response by spreading the request and also passing `reward` as a
keyword was a latent `TypeError: got multiple values`: `BrowserVerifyRequest`
allows extra fields, so a caller putting `reward` or `failure_reason` in the body
crashed verify. Response-owned fields are now dropped from the spread. The same
shape was found and fixed in `anyswe_agent` on NVIDIA-NeMo#2611.

Closing the previous browser on a re-seeded session swallowed every exception. A
browser we could not close is a resource the run still holds, so it is logged.

Tests build and serialize the real response, since the collision only appears at
construction time. Reverting any one of the three fixes fails a test.

Signed-off-by: waple0820 <feng.wang@lexmount.com>
waple0820 added a commit to waple0820/Gym that referenced this pull request Sep 1, 2026
An environment that knows it failed - lost session, unavailable judge,
OOM-killed container, reset timeout - can only return reward=0.0 today, which is
indistinguishable from a policy that genuinely scored zero.

The field already exists in this repo, just not on the contract: `mask_sample`
lives on SWEBenchWrapperInstanceConfig (responses_api_agents/swe_agents/app.py),
anyterminal_agent and anyswe_agent, and NeMo-RL already consumes it end to end
through full_result["instance_config"]["mask_sample"]. Because it hangs off one
agent family's private config model it covers three SWE-shaped agents only,
NeMo-RL has to hard-code that private path, and verl gets nothing.

Promote it to BaseVerifyResponse, alongside the human-readable failure_reason
proposed in NVIDIA-NeMo#2552:

* mask_sample: bool = False - machine-readable, for training frameworks
* failure_reason: Optional[str] = None - for triage and logs

Gym states the fact and stops there. Whether to mask the loss, resample the
episode, exclude the sample from group statistics or drop the group stays the
training framework's decision; the frameworks we checked genuinely differ.

Defaults are inert, so existing environments are unchanged. swe_agents and
anyswe_agent now mirror their instance-config flag onto the contract field and
keep the old location for one release; anyterminal_agent already surfaces it via
TerminalBenchMetrics.

Closes NVIDIA-NeMo#2608

Signed-off-by: waple0820 <232305951+waple0820@users.noreply.github.com>
waple0820 added a commit to waple0820/Gym that referenced this pull request Sep 6, 2026
An environment that knows it failed - lost session, unavailable judge,
OOM-killed container, reset timeout - can only return reward=0.0 today, which is
indistinguishable from a policy that genuinely scored zero.

The field already exists in this repo, just not on the contract: `mask_sample`
lives on SWEBenchWrapperInstanceConfig (responses_api_agents/swe_agents/app.py),
anyterminal_agent and anyswe_agent, and NeMo-RL already consumes it end to end
through full_result["instance_config"]["mask_sample"]. Because it hangs off one
agent family's private config model it covers three SWE-shaped agents only,
NeMo-RL has to hard-code that private path, and verl gets nothing.

Promote it to BaseVerifyResponse, alongside the human-readable failure_reason
proposed in NVIDIA-NeMo#2552:

* mask_sample: bool = False - machine-readable, for training frameworks
* failure_reason: Optional[str] = None - for triage and logs

Gym states the fact and stops there. Whether to mask the loss, resample the
episode, exclude the sample from group statistics or drop the group stays the
training framework's decision; the frameworks we checked genuinely differ.

Defaults are inert, so existing environments are unchanged. swe_agents and
anyswe_agent now mirror their instance-config flag onto the contract field and
keep the old location for one release; anyterminal_agent already surfaces it via
TerminalBenchMetrics.

Closes NVIDIA-NeMo#2608

Signed-off-by: waple0820 <232305951+waple0820@users.noreply.github.com>
waple0820 added a commit to waple0820/Gym that referenced this pull request Sep 6, 2026
…coring it zero

`verify` returned `reward=0.0` whenever the browser session was missing, which is
the conflation this environment exists to demonstrate: a rollout whose session was
lost is not a policy that solved nothing, and downstream the two are identical.
It now reports `failure_reason` on that path, using the field NVIDIA-NeMo#2552 added to
`BaseVerifyResponse`.

A browser that died mid-episode was worse than mis-scored. `_score` reads the live
page, so a dead browser raised out of `verify`; only `JudgeError` is converted to a
routed row, so that exception ended the whole collection run rather than the one
rollout it belonged to. Browser reads are now wrapped and reported the same way.
An unsupported scoring key still raises, because a dataset typo is a configuration
error rather than an infrastructure failure, and failing on the first rollout is
the intended behaviour.

Building the response by spreading the request and also passing `reward` as a
keyword was a latent `TypeError: got multiple values`: `BrowserVerifyRequest`
allows extra fields, so a caller putting `reward` or `failure_reason` in the body
crashed verify. Response-owned fields are now dropped from the spread. The same
shape was found and fixed in `anyswe_agent` on NVIDIA-NeMo#2611.

Closing the previous browser on a re-seeded session swallowed every exception. A
browser we could not close is a resource the run still holds, so it is logged.

Tests build and serialize the real response, since the collision only appears at
construction time. Reverting any one of the three fixes fails a test.

Signed-off-by: waple0820 <feng.wang@lexmount.com>
waple0820 added a commit to waple0820/Gym that referenced this pull request Sep 12, 2026
An environment that knows it failed - lost session, unavailable judge,
OOM-killed container, reset timeout - can only return reward=0.0 today, which is
indistinguishable from a policy that genuinely scored zero.

The field already exists in this repo, just not on the contract: `mask_sample`
lives on SWEBenchWrapperInstanceConfig (responses_api_agents/swe_agents/app.py),
anyterminal_agent and anyswe_agent, and NeMo-RL already consumes it end to end
through full_result["instance_config"]["mask_sample"]. Because it hangs off one
agent family's private config model it covers three SWE-shaped agents only,
NeMo-RL has to hard-code that private path, and verl gets nothing.

Promote it to BaseVerifyResponse, alongside the human-readable failure_reason
proposed in NVIDIA-NeMo#2552:

* mask_sample: bool = False - machine-readable, for training frameworks
* failure_reason: Optional[str] = None - for triage and logs

Gym states the fact and stops there. Whether to mask the loss, resample the
episode, exclude the sample from group statistics or drop the group stays the
training framework's decision; the frameworks we checked genuinely differ.

Defaults are inert, so existing environments are unchanged. swe_agents and
anyswe_agent now mirror their instance-config flag onto the contract field and
keep the old location for one release; anyterminal_agent already surfaces it via
TerminalBenchMetrics.

Closes NVIDIA-NeMo#2608

Signed-off-by: waple0820 <232305951+waple0820@users.noreply.github.com>
waple0820 added a commit to waple0820/Gym that referenced this pull request Sep 14, 2026
An environment that knows it failed - lost session, unavailable judge,
OOM-killed container, reset timeout - can only return reward=0.0 today, which is
indistinguishable from a policy that genuinely scored zero.

The field already exists in this repo, just not on the contract: `mask_sample`
lives on SWEBenchWrapperInstanceConfig (responses_api_agents/swe_agents/app.py),
anyterminal_agent and anyswe_agent, and NeMo-RL already consumes it end to end
through full_result["instance_config"]["mask_sample"]. Because it hangs off one
agent family's private config model it covers three SWE-shaped agents only,
NeMo-RL has to hard-code that private path, and verl gets nothing.

Promote it to BaseVerifyResponse, alongside the human-readable failure_reason
proposed in NVIDIA-NeMo#2552:

* mask_sample: bool = False - machine-readable, for training frameworks
* failure_reason: Optional[str] = None - for triage and logs

Gym states the fact and stops there. Whether to mask the loss, resample the
episode, exclude the sample from group statistics or drop the group stays the
training framework's decision; the frameworks we checked genuinely differ.

Defaults are inert, so existing environments are unchanged. swe_agents and
anyswe_agent now mirror their instance-config flag onto the contract field and
keep the old location for one release; anyterminal_agent already surfaces it via
TerminalBenchMetrics.

Closes NVIDIA-NeMo#2608

Signed-off-by: waple0820 <232305951+waple0820@users.noreply.github.com>
waple0820 added a commit to waple0820/Gym that referenced this pull request Sep 14, 2026
…coring it zero

`verify` returned `reward=0.0` whenever the browser session was missing, which is
the conflation this environment exists to demonstrate: a rollout whose session was
lost is not a policy that solved nothing, and downstream the two are identical.
It now reports `failure_reason` on that path, using the field NVIDIA-NeMo#2552 added to
`BaseVerifyResponse`.

A browser that died mid-episode was worse than mis-scored. `_score` reads the live
page, so a dead browser raised out of `verify`; only `JudgeError` is converted to a
routed row, so that exception ended the whole collection run rather than the one
rollout it belonged to. Browser reads are now wrapped and reported the same way.
An unsupported scoring key still raises, because a dataset typo is a configuration
error rather than an infrastructure failure, and failing on the first rollout is
the intended behaviour.

Building the response by spreading the request and also passing `reward` as a
keyword was a latent `TypeError: got multiple values`: `BrowserVerifyRequest`
allows extra fields, so a caller putting `reward` or `failure_reason` in the body
crashed verify. Response-owned fields are now dropped from the spread. The same
shape was found and fixed in `anyswe_agent` on NVIDIA-NeMo#2611.

Closing the previous browser on a re-seeded session swallowed every exception. A
browser we could not close is a resource the run still holds, so it is logged.

Tests build and serialize the real response, since the collision only appears at
construction time. Reverting any one of the three fixes fails a test.

Signed-off-by: waple0820 <feng.wang@lexmount.com>
waple0820 added a commit to waple0820/Gym that referenced this pull request Sep 15, 2026
An environment that knows it failed - lost session, unavailable judge,
OOM-killed container, reset timeout - can only return reward=0.0 today, which is
indistinguishable from a policy that genuinely scored zero.

The field already exists in this repo, just not on the contract: `mask_sample`
lives on SWEBenchWrapperInstanceConfig (responses_api_agents/swe_agents/app.py),
anyterminal_agent and anyswe_agent, and NeMo-RL already consumes it end to end
through full_result["instance_config"]["mask_sample"]. Because it hangs off one
agent family's private config model it covers three SWE-shaped agents only,
NeMo-RL has to hard-code that private path, and verl gets nothing.

Promote it to BaseVerifyResponse, alongside the human-readable failure_reason
proposed in NVIDIA-NeMo#2552:

* mask_sample: bool = False - machine-readable, for training frameworks
* failure_reason: Optional[str] = None - for triage and logs

Gym states the fact and stops there. Whether to mask the loss, resample the
episode, exclude the sample from group statistics or drop the group stays the
training framework's decision; the frameworks we checked genuinely differ.

Defaults are inert, so existing environments are unchanged. swe_agents and
anyswe_agent now mirror their instance-config flag onto the contract field and
keep the old location for one release; anyterminal_agent already surfaces it via
TerminalBenchMetrics.

Closes NVIDIA-NeMo#2608

Signed-off-by: waple0820 <232305951+waple0820@users.noreply.github.com>
waple0820 added a commit to waple0820/Gym that referenced this pull request Sep 15, 2026
…coring it zero

`verify` returned `reward=0.0` whenever the browser session was missing, which is
the conflation this environment exists to demonstrate: a rollout whose session was
lost is not a policy that solved nothing, and downstream the two are identical.
It now reports `failure_reason` on that path, using the field NVIDIA-NeMo#2552 added to
`BaseVerifyResponse`.

A browser that died mid-episode was worse than mis-scored. `_score` reads the live
page, so a dead browser raised out of `verify`; only `JudgeError` is converted to a
routed row, so that exception ended the whole collection run rather than the one
rollout it belonged to. Browser reads are now wrapped and reported the same way.
An unsupported scoring key still raises, because a dataset typo is a configuration
error rather than an infrastructure failure, and failing on the first rollout is
the intended behaviour.

Building the response by spreading the request and also passing `reward` as a
keyword was a latent `TypeError: got multiple values`: `BrowserVerifyRequest`
allows extra fields, so a caller putting `reward` or `failure_reason` in the body
crashed verify. Response-owned fields are now dropped from the spread. The same
shape was found and fixed in `anyswe_agent` on NVIDIA-NeMo#2611.

Closing the previous browser on a re-seeded session swallowed every exception. A
browser we could not close is a resource the run still holds, so it is logged.

Tests build and serialize the real response, since the collision only appears at
construction time. Reverting any one of the three fixes fails a test.

Signed-off-by: waple0820 <feng.wang@lexmount.com>
waple0820 added a commit to waple0820/Gym that referenced this pull request Sep 15, 2026
An environment that knows it failed - lost session, unavailable judge,
OOM-killed container, reset timeout - can only return reward=0.0 today, which is
indistinguishable from a policy that genuinely scored zero.

The field already exists in this repo, just not on the contract: `mask_sample`
lives on SWEBenchWrapperInstanceConfig (responses_api_agents/swe_agents/app.py),
anyterminal_agent and anyswe_agent, and NeMo-RL already consumes it end to end
through full_result["instance_config"]["mask_sample"]. Because it hangs off one
agent family's private config model it covers three SWE-shaped agents only,
NeMo-RL has to hard-code that private path, and verl gets nothing.

Promote it to BaseVerifyResponse, alongside the human-readable failure_reason
proposed in NVIDIA-NeMo#2552:

* mask_sample: bool = False - machine-readable, for training frameworks
* failure_reason: Optional[str] = None - for triage and logs

Gym states the fact and stops there. Whether to mask the loss, resample the
episode, exclude the sample from group statistics or drop the group stays the
training framework's decision; the frameworks we checked genuinely differ.

Defaults are inert, so existing environments are unchanged. swe_agents and
anyswe_agent now mirror their instance-config flag onto the contract field and
keep the old location for one release; anyterminal_agent already surfaces it via
TerminalBenchMetrics.

Closes NVIDIA-NeMo#2608

Signed-off-by: waple0820 <232305951+waple0820@users.noreply.github.com>
waple0820 added a commit to waple0820/Gym that referenced this pull request Sep 15, 2026
…coring it zero

`verify` returned `reward=0.0` whenever the browser session was missing, which is
the conflation this environment exists to demonstrate: a rollout whose session was
lost is not a policy that solved nothing, and downstream the two are identical.
It now reports `failure_reason` on that path, using the field NVIDIA-NeMo#2552 added to
`BaseVerifyResponse`.

A browser that died mid-episode was worse than mis-scored. `_score` reads the live
page, so a dead browser raised out of `verify`; only `JudgeError` is converted to a
routed row, so that exception ended the whole collection run rather than the one
rollout it belonged to. Browser reads are now wrapped and reported the same way.
An unsupported scoring key still raises, because a dataset typo is a configuration
error rather than an infrastructure failure, and failing on the first rollout is
the intended behaviour.

Building the response by spreading the request and also passing `reward` as a
keyword was a latent `TypeError: got multiple values`: `BrowserVerifyRequest`
allows extra fields, so a caller putting `reward` or `failure_reason` in the body
crashed verify. Response-owned fields are now dropped from the spread. The same
shape was found and fixed in `anyswe_agent` on NVIDIA-NeMo#2611.

Closing the previous browser on a re-seeded session swallowed every exception. A
browser we could not close is a resource the run still holds, so it is logged.

Tests build and serialize the real response, since the collision only appears at
construction time. Reverting any one of the three fixes fails a test.

Signed-off-by: waple0820 <feng.wang@lexmount.com>
shy982 pushed a commit to shy982/Gym that referenced this pull request Sep 18, 2026
…ard (NVIDIA-NeMo#2611)

Closes NVIDIA-NeMo#2608

`BaseVerifyResponse` carries a single scalar for the outcome, so an
environment that *knows* it failed — lost session, unavailable judge,
OOM-killed container, reset timeout — can only return `0.0`, which is
indistinguishable from a policy that genuinely scored zero.

The concept is not new to this repo. It exists in four private
locations:

- `mask_sample: bool = False` on `SWEBenchWrapperInstanceConfig`
(`responses_api_agents/swe_agents/app.py:272`), also
`anyterminal_agent/app.py:108` and `anyswe_agent/app.py:180`, documented
at `swe_agents/README.md:670`.
- `conversational_tool_use/simulation/app.py:112` keeps its own
`{"mask_sample": ...}` dict.
- `nemo_gym/token_id_capture/delivery.py:49` already defines
`MASK_SAMPLE_KEY = "mask_sample"` as a **top-level** rollout field,
described there as what "a consumer reads to exclude a rollout from the
loss".
- NeMo-RL consumes only the first one, through
`full_result["instance_config"]["mask_sample"]`
(`nemo_rl/experience/rollouts.py:229-241`), which zeroes
`loss_multiplier` in `nemo_rl/algorithms/grpo.py:2104`.

So core already agrees what the key is called and what it means; it is
reachable only if your environment happens to be SWE-shaped or if token
capture produced it. verl gets nothing.

This is the same objection raised on NVIDIA-NeMo#2384 — that a fix "should be
applied uniformly, to other benchmarks as well" rather than to one
harness. Putting the field on the contract is what makes it uniform.

## What this changes

**1. The contract** (`nemo_gym/base_resources_server.py`)

```python
class BaseVerifyResponse(BaseVerifyRequest):
    reward: float
    mask_sample: bool = False              # is this reward a valid measurement
    failure_kind: Optional[str] = None     # which kind of failure, from NVIDIA-NeMo#3179's vocabulary
    failure_reason: Optional[str] = None   # what happened, for a person
```

The three are orthogonal. `mask_sample` decides usability,
`failure_kind` groups in logs and metrics, `failure_reason` explains one
occurrence. A sample that degraded but was still measured validly names
a kind and stays unmasked.

`failure_kind` validates against `nemo_gym.failure_kinds` and warns
rather than rejects — an unregistered name is a migration signal, and
failing the response over it would replace a visible wrong label with an
invisible lost failure. An environment that needs something the shared
set should not grow uses `<server>:<kind>`.

Gym states the fact and stops there. Masking, resampling, excluding from
group statistics or dropping the group stays the training framework's
decision — the three frameworks we checked genuinely differ.

`swe_agents` and `anyswe_agent` mirror their instance-config flag onto
the contract field and keep the old location for one release.
`anyterminal_agent` already surfaces it through `TerminalBenchMetrics`.

**2. Something that reads it** (`nemo_gym/reward_profile.py`,
`nemo_gym/rollout_collection.py`)

A declaration nothing consumes is not worth much, and the consumer that
matters is the one that publishes the score.

`compute_aggregate_metrics()` computes quality from the scored subset:
the profiler input, a custom `compute_metrics()`, and key-metric
selection all skip masked samples. Masked rows stay in the input and are
reported as coverage instead —
`coverage/measured_rollouts|masked_rollouts` and
`coverage/measured_tasks|fully_masked_tasks`. A run where every sample
is masked publishes coverage rather than inventing a zero, and a fully
masked task is counted without contributing a group. `mask_sample` is
stripped before profiling, so it is no longer averaged as though the
flag were a measurement.

`perf_summary` deliberately keeps using every response, masked included:
a masked sample still executed and still reported its latency, so
observability coverage should not shrink because a rollout was excluded
from scoring.

Progress telemetry gets the same split while a run is still going, over
the results that reach the main rollout output:

- `progress/<agent>/reward` is unchanged — it still averages over every
rollout, so an existing dashboard reads the number it read before.
- `progress/<agent>/reward_unmasked` is that same average with the
masked rollouts removed.
- `progress/<agent>/masked_pct` is how many were removed.
- `progress/<agent>/failed` and `/omitted` count the attempts that never
reach the main output at all, so a sidecar failure is never folded into
a quality average as a reward of zero.

The gap between the two reward series is the score a run lost to its own
infrastructure rather than to the policy. On the run that motivated
this, 1172 of 1280 rollouts across steps 61–80 failed in session reset
and entered GRPO as legitimate zeros; nothing in the metrics said so.

Every new key appears only once something is actually masked, failed or
omitted, so a healthy run publishes exactly what it published before.

## Scope

Run-level reconciliation is no longer in this PR. NVIDIA-NeMo#3302 implements it
for NVIDIA-NeMo#2135, using the materialized inventory and attempt journal to
account for omitted and unknown work offline — neither of which this
contract can see. What remains here is the per-sample half.

## Relationship to the other failure-handling work

- **NVIDIA-NeMo#2552** (merged) landed `failure_reason` and its comment already
points here: *"Machine-readable handling belongs to
`mask_sample`/`failure_kind`."* This PR is that half.
- **NVIDIA-NeMo#2728** owns `failure_kind` and the shared registry, and already
reserves `session_lost` with `source="NVIDIA-NeMo#2611"`. No overlap — this PR does
not touch `failure_kind`.
- **NVIDIA-NeMo#2750** lists this under *Failure fields and artifact integrity*.
The design document's rule — *"the producer returns the real result with
`mask_sample=True`, a stable `failure_kind`, and a human-readable
`failure_reason`"* — is exactly what the three fields together express
once this lands.

## Compatibility

Defaults are inert; an unmodified environment behaves identically, and a
run that masks nothing publishes exactly the keys it published before.

One inheritance detail worth naming: `AnySweVerifyResponse` inherits
`mask_sample` from both `SWEBenchMetrics` and `BaseVerifyResponse`, and
the metrics model wins the MRO, so the contract field resolved to
`Optional[bool] = None` with no description on that response. It is
restated on the subclass so the schema keeps the contract's type,
default and description.

## Tests

- `tests/unit_tests/test_base_resources_server.py` — defaults, round
trip, that a plain `reward=0.0` is *not* implicitly masked, and that a
degraded-but-scored rollout stays unmasked.
- `tests/unit_tests/test_aggregate_metrics.py` — a masked zero does not
halve the score, the flag is not published as a quality metric, a run
that masks nothing is byte-identical, a fully masked run reports
coverage instead of a zero, a fully masked task is counted but not
scored, custom `compute_metrics()` sees only scored samples, and a fully
masked run still reports observability coverage.
- `tests/unit_tests/test_rollout_collection.py` — the disposition
account closes for every combination and the residual never goes
negative; a healthy run adds no keys; a partially masked agent reports
its share and the score without it; a fully masked agent publishes no
average; failed and omitted attempts are counted, never averaged.
- `responses_api_agents/anyswe_agent/tests/test_app.py` — an end-to-end
`/run` that constructs and serializes the response. Reverting the fix
makes it fail with `TypeError: got multiple values for keyword argument
'mask_sample'`.

`uv run --extra dev pytest tests/unit_tests -q` → 3383 passed. Two
failures on this branch, `test_e2b_provider` and `test_enroot_provider`,
reproduce on a clean `upstream/main` and are unrelated. `pre-commit`
clean.

---------

Signed-off-by: waple0820 <232305951+waple0820@users.noreply.github.com>
Co-authored-by: waple0820 <232305951+waple0820@users.noreply.github.com>
waple0820 added a commit to waple0820/Gym that referenced this pull request Sep 18, 2026
…coring it zero

`verify` returned `reward=0.0` whenever the browser session was missing, which is
the conflation this environment exists to demonstrate: a rollout whose session was
lost is not a policy that solved nothing, and downstream the two are identical.
It now reports `failure_reason` on that path, using the field NVIDIA-NeMo#2552 added to
`BaseVerifyResponse`.

A browser that died mid-episode was worse than mis-scored. `_score` reads the live
page, so a dead browser raised out of `verify`; only `JudgeError` is converted to a
routed row, so that exception ended the whole collection run rather than the one
rollout it belonged to. Browser reads are now wrapped and reported the same way.
An unsupported scoring key still raises, because a dataset typo is a configuration
error rather than an infrastructure failure, and failing on the first rollout is
the intended behaviour.

Building the response by spreading the request and also passing `reward` as a
keyword was a latent `TypeError: got multiple values`: `BrowserVerifyRequest`
allows extra fields, so a caller putting `reward` or `failure_reason` in the body
crashed verify. Response-owned fields are now dropped from the spread. The same
shape was found and fixed in `anyswe_agent` on NVIDIA-NeMo#2611.

Closing the previous browser on a re-seeded session swallowed every exception. A
browser we could not close is a resource the run still holds, so it is logged.

Tests build and serialize the real response, since the collision only appears at
construction time. Reverting any one of the three fixes fails a test.

Signed-off-by: waple0820 <feng.wang@lexmount.com>
waple0820 added a commit to waple0820/Gym that referenced this pull request Sep 22, 2026
…coring it zero

`verify` returned `reward=0.0` whenever the browser session was missing, which is
the conflation this environment exists to demonstrate: a rollout whose session was
lost is not a policy that solved nothing, and downstream the two are identical.
It now reports `failure_reason` on that path, using the field NVIDIA-NeMo#2552 added to
`BaseVerifyResponse`.

A browser that died mid-episode was worse than mis-scored. `_score` reads the live
page, so a dead browser raised out of `verify`; only `JudgeError` is converted to a
routed row, so that exception ended the whole collection run rather than the one
rollout it belonged to. Browser reads are now wrapped and reported the same way.
An unsupported scoring key still raises, because a dataset typo is a configuration
error rather than an infrastructure failure, and failing on the first rollout is
the intended behaviour.

Building the response by spreading the request and also passing `reward` as a
keyword was a latent `TypeError: got multiple values`: `BrowserVerifyRequest`
allows extra fields, so a caller putting `reward` or `failure_reason` in the body
crashed verify. Response-owned fields are now dropped from the spread. The same
shape was found and fixed in `anyswe_agent` on NVIDIA-NeMo#2611.

Closing the previous browser on a re-seeded session swallowed every exception. A
browser we could not close is a resource the run still holds, so it is logged.

Tests build and serialize the real response, since the collision only appears at
construction time. Reverting any one of the three fixes fails a test.

Signed-off-by: waple0820 <feng.wang@lexmount.com>
waple0820 added a commit to waple0820/Gym that referenced this pull request Sep 22, 2026
…coring it zero

`verify` returned `reward=0.0` whenever the browser session was missing, which is
the conflation this environment exists to demonstrate: a rollout whose session was
lost is not a policy that solved nothing, and downstream the two are identical.
It now reports `failure_reason` on that path, using the field NVIDIA-NeMo#2552 added to
`BaseVerifyResponse`.

A browser that died mid-episode was worse than mis-scored. `_score` reads the live
page, so a dead browser raised out of `verify`; only `JudgeError` is converted to a
routed row, so that exception ended the whole collection run rather than the one
rollout it belonged to. Browser reads are now wrapped and reported the same way.
An unsupported scoring key still raises, because a dataset typo is a configuration
error rather than an infrastructure failure, and failing on the first rollout is
the intended behaviour.

Building the response by spreading the request and also passing `reward` as a
keyword was a latent `TypeError: got multiple values`: `BrowserVerifyRequest`
allows extra fields, so a caller putting `reward` or `failure_reason` in the body
crashed verify. Response-owned fields are now dropped from the spread. The same
shape was found and fixed in `anyswe_agent` on NVIDIA-NeMo#2611.

Closing the previous browser on a re-seeded session swallowed every exception. A
browser we could not close is a resource the run still holds, so it is logged.

Tests build and serialize the real response, since the collision only appears at
construction time. Reverting any one of the three fixes fails a test.

Signed-off-by: waple0820 <feng.wang@lexmount.com>

This branch was previously deployed

1 inactive deployment
public — 935e6732 Deployed Aug 25, 2026 by copy-pr-bot[bot] via release / finalize / notify #1794
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants