Skip to content

fix(resources): tighten the session contract for close, refusals and lost sessions - #4679

Merged
yaoyu-33 merged 10 commits into
NVIDIA-NeMo:mainfrom
yaoyu-33:fix/resources-session-close-contract
Oct 24, 2026
Merged

yaoyu-33 merged 10 commits into
NVIDIA-NeMo:mainfrom
yaoyu-33:fix/resources-session-close-contract

Conversation

@yaoyu-33

@yaoyu-33 yaoyu-33 commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

What changed

This PR tightens the resources sessions contract: the rules every stateful resources server must follow so single_agent_turn can drive it. It covers four foundational gaps in the environment server migration.

  1. Closed-session records are bounded. ResourcesSessionFence in nemo_gym/base_resources_server.py gives each resources_session_id its own lock and remembers a closed session for the server's session_close_retry_window_seconds (300 s by default) instead of until the process exits. The records live in main's ClosedResourcesSessionRecords (fix(server): route a session's requests to the worker that holds it #3905), so with several workers a record never outlives the session's routing claim, and a retried close keeps the first record. Each server used to keep its own closed set, which grows by one entry per episode.
  2. A close releases state even without the cookie. That case happens when the seed's reply is lost. The close must find the state by resources_session_id.
  3. A refusal that no retry can fix is final. A seed or close used to raise a plain ValueError, which becomes a 500, and single_agent_turn dispatches the whole episode again. Now:
    • ResourcesSeedRejectedError (422) covers input that fails validation, such as task_data or a TaskId that does not match the task row;
    • ResourcesSessionConflictError (409) covers an ID already bound to another episode or task, a close that names another episode, and a seed for a closed session.
  4. A lost session is masked, not scored 0.
    • /verify for a session the server no longer holds returns a masked sample built by lost_session_verification: reward 0, mask_sample=True, failure_kind session_lost (or session_expired). Before, servers returned an unmasked 0, which reads as a policy that solved nothing, or raised an error.
    • A tool call for such a session raises ResourcesSessionLostError (404) with a failure_kind, instead of an ordinary tool observation. What a harness does with it varies: simple_agent shows the body to the model and keeps going, Stirrup fails the run so the episode is dispatched again, and an MCP client reports a tool error. The masked verify is what keeps the episode out of the score.
    • Each masked verify logs a warning, so a wiring fault (for example a session cookie that never reaches /verify) does not show up only in coverage metrics.

check_resources_session_contract enforces the new rules, and every server that already ran it on main moves onto them in this PR, so none of their tests break:

  • example_single_tool_call (the reference server);
  • gdpval;
  • partner_if mock tools (in-process and sandboxed): closed records retain the episode identity, so a close naming another episode or attempt returns 409 after the session is gone. Retried closes keep the first record and expiry; retirement of an unknown session keeps an ownerless fence;
  • nemo_user_sim. It also gets an ID-to-state map, because the stricter check showed that a second episode could bind the same resources_session_id and that a cookie-less close missed its seed.

swebench_pro and interactive_browser move onto the contract in the stacked #4692.

Fixes found in review, also in this PR:

  • GDPVal multistage: a masked lost-session row carries no failure class, so the orchestrator counted it as a judged success: a final stage with half its tasks judged reported complete with a headline ELO. tag_results now records it as the terminal verification failure the old 409 produced, and aggregate_metrics does not count it as an observed stage row.
  • GDPVal seed cookie: the session cookie was bound before the 409/422 checks, so a refused seed's reply handed the caller the live session's cookie. It is now bound only after the checks pass.
  • nemo_user_sim: a close of a closed session that names another episode gets 409, and a seed dropped after session_ttl_seconds verifies as session_expired instead of session_lost.

The contract

Where each rule applies in an episode

Teal steps are the resources sessions API. The notes show what this PR adds at each step.

sequenceDiagram
    autonumber
    participant A as Agent server
    participant E as Environment server (single_agent_turn)
    participant R as Resources server
    rect rgba(14,116,105,0.12)
    E->>R: POST /seed_session {resources_session_id, episode_id, task_id, task_data}
    R-->>E: 200 + session cookie (+ MCP tools, sandbox_access)
    Note over R,E: or 422 bad input / 409 conflict or closed: final, no retry
    end
    E->>A: POST /v1/agent_sessions (tool grants carry the cookie)
    E->>A: POST /v1/responses
    A->>R: tool calls (with cookie)
    Note over A,R: session gone: 404 failure_kind=session_lost
    E->>A: POST /v1/agent_sessions/close
    E->>R: POST /verify (with cookie)
    Note over E,R: session gone: 200, reward 0, mask_sample=true, failure_kind=session_lost
    rect rgba(14,116,105,0.12)
    E->>R: POST /close_session {resources_session_id, episode_id}
    Note over R: release by ID (cookie optional), fence the ID for session_close_retry_window_seconds
    end
Loading

The lost-seed-reply path

single_agent_turn registers the resources close before it sends the seed, so a seed whose reply is lost is still closed. But the environment server never received the cookie, so that close carries an empty cookie jar.

sequenceDiagram
    participant E as Environment server
    participant R as Resources server
    Note over E: register close(resources_session_id) first
    E->>R: POST /seed_session {resources_session_id: "s1"}
    Note over R: starts sandbox, sets cookie
    R--xE: reply lost (timeout, disconnect)
    E->>R: POST /close_session {resources_session_id: "s1"} with no cookie
    Note over R: find s1's state by ID, release it, fence s1
    E->>R: (late retry) POST /seed_session {resources_session_id: "s1"}
    R-->>E: 409 already closed (not retried)
Loading

How a resources server answers

The dividing line is whether a retry can change the result. single_agent_turn retries 5xx, 408, 425 and 429 by dispatching the episode again, and treats every other 4xx as final.

flowchart TD
    Seed["/seed_session"] --> V{"input valid?"}
    V -->|no: task_data, TaskId| R422["422 ResourcesSeedRejectedError<br/>final"]
    V -->|yes| C{"ID bound to another episode,<br/>or already closed?"}
    C -->|yes| R409["409 ResourcesSessionConflictError<br/>final"]
    C -->|no| P{"provider or dependency failed?"}
    P -->|yes| R5xx["5xx<br/>episode retried"]
    P -->|no| OK["200"]
    Tool["tool call"] --> G1{"session held?"}
    G1 -->|no| R404["404 ResourcesSessionLostError<br/>failure_kind=session_lost"]
    Verify["/verify"] --> G2{"session held?"}
    G2 -->|no| Masked["200 lost_session_verification<br/>reward 0, mask_sample=true"]
    G2 -->|yes| Score["score the episode"]
Loading

The rules

Call Rule Status
/seed_session The same resources_session_id with the same episode and task returns the existing session (idempotent). unchanged
/seed_session Input that fails validation is refused with 422 (ResourcesSeedRejectedError). new
/seed_session An ID bound to another episode or task, or a session closed within the retention window, is refused with 409 (ResourcesSessionConflictError; ResourcesSessionFence.raise_if_closed raises it). new (was any 4xx, in practice 500)
/seed_session A failure a retry may fix, such as an unavailable sandbox provider, stays a 5xx. unchanged
tool call A call for a session the server no longer holds gets 404 with failure_kind (ResourcesSessionLostError). new
/verify A verify for a session the server no longer holds returns a masked sample (lost_session_verification), never a plain 0 and never an error. new
/close_session Succeeds when the session is already closed or was never seeded, and fences the ID either way. A close that names another episode gets 409. 409 is new
/close_session Releases the session's state by resources_session_id, even with no cookie. new
/close_session If releasing fails, raises instead of fencing, so the caller can retry. unchanged
retention A closed record lasts the server's session_close_retry_window_seconds (300 s by default, the same setting agent sessions use), so callers must finish seed retries within that window. A retried close keeps the first close's record and expiry. new (was "until shutdown")

A server whose score does not depend on per-rollout state, such as example_single_tool_call, needs no lost-session handling at verify.

Using the helpers

_fence: ResourcesSessionFence = PrivateAttr(default_factory=ResourcesSessionFence)

async def seed_session(self, body):
    async with self._fence.locked(body.resources_session_id):
        self._fence.raise_if_closed(body.resources_session_id)  # 409
        try:
            task = MyTask.model_validate(body.task_data)
        except ValidationError as error:
            raise ResourcesSeedRejectedError(f"task_data is not a task: {error}") from error  # 422
        ...  # create or reuse state keyed by resources_session_id; 409 on an episode mismatch

async def close_resources_session(self, body):
    async with self._fence.locked(body.resources_session_id):
        ...  # release state by resources_session_id; raise on failure
        self._fence.mark_closed(body.resources_session_id, body.episode_id)

async def verify(self, request, body):
    if (state := self._find(request)) is None:
        return BaseVerifyResponse(**body.model_dump(), **lost_session_verification("sandbox gone"))
    ...

Checking a server

check_resources_session_contract(
    app,
    seed,
    keeps_state=True,
    holds_state=lambda resources_session_id: resources_session_id in server._sessions,  # close released it
    verify_body=verify_request_json,  # verify after close is a masked lost session
)

With keeps_state, the check now also requires 409 for a seed that binds the open ID to another episode, for a close of the closed session that names another episode, and for every late seed.

TODO: sessions that are never closed (not in this PR)

When the environment server dies before its close, the session stays open. Nothing in the shared code expires it, and provider TTLs don't cover every case: apptainer ignores ttl_s. The agreed rule, to implement in ResourcesSessionFence:

  • each open session gets a fixed lifetime from its seed: the sandbox ttl_s when set, otherwise 24 hours, configurable per server;
  • the lifetime is checked on the next request, and open sandboxes are stopped at shutdown;
  • an expired session is released, fenced like a close, and verifies as a masked session_expired sample.

Only resources sessions are in scope; agent sessions have the same gap and are tracked separately. The TODO in the ResourcesSessionFence docstring records this.

Relation to other PRs

Linked issues

  • Bound closed-session records with one shared helper
  • Release resources state when the seed response is lost

Follow-ups (not in this PR)

  • The TODO above.
  • openenv returns an unmasked 0 at verify when its session is gone. It is still on the legacy /run path and will get the rule when it migrates (feat(openenv): serve the typed resources sessions API #5483).
  • Servers that only serve legacy /run seeds (deepswe, deepswe_external1, job_bench, workspace_bench, toolsandbox) get the refusal rules when they migrate to typed sessions.
  • terminal_bench_2_1 and example_session_state_mgmt/nooa_app.py serve typed sessions but keep their own closed records, and terminal_bench_2_1 answers a verify for a lost session with 409 instead of a masked sample. Both should move onto ResourcesSessionFence and lost_session_verification.
  • A close cancelled while it waits for the fence lock never fences the ID, so a slow seed's state can outlive it. Each server's own lock had the same gap before this PR; the fix belongs in ResourcesSessionFence (record the close before taking the lock, as terminal_bench_2_1 does).
  • The resources checkpoint participant ends a session by the close request's cookie, so with checkpointing on, a cookie-less close leaves the session tracked. This predates the PR.

Validation

  • Partner IF follow-up at 7ebecb5f759d20c5b00d0413fe525e9a411c078c: pytest resources_servers/partner_if/tests tests/unit_tests/test_base_resources_server.py tests/unit_tests/test_resources_sessions.py --import-mode=importlib -q -o addopts= -o log_cli=false --tb=short: 514 passed. Five additional lifecycle controls passed (failed-stop retry, wrong attempt, first-close expiry, known/unknown retirement). The three added regression cases fail against the original mock_tools_app.py. pre-commit run --all-files passed with Python 3.13.14; the private guardword catalog was unavailable, so that hook skipped its private scan.

  • pytest tests/unit_tests/test_base_resources_server.py tests/unit_tests/test_session_contract_repo.py tests/unit_tests/test_resources_sessions.py tests/unit_tests/test_session_routing.py: 120 passed (after merging main). New tests:

    • closed records expire after the window and stay bounded over 1,000 closes; a retried close keeps the first close's episode and expiry;

    • the lock serializes one session and is dropped when unused, including for a cancelled waiter;

    • the refusal errors are 422 and 409 and still ValueErrors; ResourcesSessionLostError is 404 and names its failure_kind; lost_session_verification builds a valid masked response;

    • the contract check fails for a server that:

      • releases only by cookie (close left state for ...-lost-seed-reply);
      • answers a conflicting seed with 500;
      • scores a lost session 0 without masking.

      It passes for one that follows every rule. It also fails for a server that refuses a late seed with 500, or accepts a close of a closed session that names another episode.

    • one session's lock does not block another; clear() forgets closed records;

    • 422 and 404 refusals reach the wire through the SimpleServer middleware;

    • a masked sample built with the documented merge pattern survives an extra="allow" request carrying reward, logs a warning, and is kept for coverage and dropped from quality by select_measured.

  • pytest resources_servers/example_single_tool_call/tests: 6 passed.

  • pytest resources_servers/nullable_tool_args/tests: 4 passed (stateless caller of the check).

  • pytest resources_servers/gdpval/tests in a venv built from its requirements.txt: 865 passed, 1 skipped. Includes the contract check with holds_state and verify_body; tests that pinned 400 or ValueError now assert 404, 409 or 422, and a verify with no session and no deliverables is a masked session_lost sample instead of a 409. New tests: a lost-session row is a terminal verification failure in the multistage orchestrator and does not complete the final stage; a gone session with an explicit deliverables_dir is still judged (not masked); task_data that is not a task gets 422; a refused seed does not hand out the live session's cookie.

  • pytest resources_servers/nemo_user_sim/tests in a venv with nemo-usersim installed: 38 passed. Includes the contract check with holds_state and verify_body; a closed or unknown session now verifies as a masked session_lost sample and an expired one as session_expired, instead of a 404; a close finds the session by ID before the cookie.

  • pytest tests/unit_tests -n 8: 8623 passed, 36 failed, 2 collection errors. All of the failures are in files this PR does not touch (opensandbox compose/cleanup/snapshots, the super35 batch launcher, CI environment, exporters, reward profile, telemetry). The same files fail the same way on unmodified origin/main in this local environment.

  • pre-commit run --files <changed files>: passed.

Rollout evidence

Partner IF follow-up at 7ebecb5f759d20c5b00d0413fe525e9a411c078c: two real SingleAgentTurn → simple_agent → NIM nvidia/nemotron-3-ultra-550b-a55b episodes, one in-process and one in a real Docker sandbox, on the shipped greeting task. Each made three model calls, executed lookup and append, produced HELLO, and received unmasked reward 1 from the stock fixed-rule verifier. Both modes returned 200 for repeated same-episode close and 409 for a different episode or attempt. A second real seed in each mode had its reply replaced with 504 by a test wrapper: the environment closed it without a cookie, left no episode, and late seed returned 409. The Docker containers were removed. This is a bounded lifecycle smoke, not benchmark qualification.

The original three server smoke rollouts below have not been run yet; their results remain to be added before merge.

  • example_single_tool_call under single_agent_turn with simple_agent: episodes verify and closes return 200.
  • gdpval under single_agent_turn: episodes verify. A verify after a restart is masked session_lost, is not judged, and leaves the stage degraded rather than complete.
  • nemo_user_sim under its environment server: closes return 200 and a healthy run has no masked samples.

Compatibility

  • Status codes change for the servers in this PR. A conflicting or late seed and a mismatched close return 409, and bad task_data returns 422, where they used to return 500. A 500 was retried; these are now final.
  • Lost sessions change for gdpval and nemo_user_sim. A verify for a lost session is a masked sample, so it counts toward coverage but not quality metrics. gdpval used to return a 409, and nemo_user_sim a 404, which produced no sample. A tool call for a lost gdpval session returns 404 instead of 400.
  • The refusal errors are still ValueErrors, so direct callers that catch ValueError keep working.
  • A refused gdpval seed no longer sets the session cookie. Before, a caller refused with 409 could act on another episode's live session.
  • nemo_user_sim state: closed_resources_session_ids now holds (EpisodeId, expires_at), and an expired seed verifies as session_expired.
  • Logging: each masked lost-session verify logs a warning.
  • check_resources_session_contract is stricter: 409 for conflicting and late seeds where it accepted any 4xx, and 409 for a close of a closed session that names another episode. holds_state and verify_body are optional keyword arguments. Every caller in the repo passes.

🤖 Generated with Claude Code

@copy-pr-bot

copy-pr-bot Bot commented Oct 16, 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 marked this pull request as ready for review October 16, 2026 17:23
@yaoyu-33 yaoyu-33 added area:env-infra Shared environment framework, lifecycle, registry, scaffolding, and validation bug Something isn't working complexity:high Cross-component or shared-contract change with a large review surface needs-review PR is ready for code review and waiting on a reviewer labels Oct 16, 2026
@github-actions github-actions Bot added the sla:triage-overdue Review assignment is over the one-business-day SLA label Oct 19, 2026
yaoyu-33 and others added 2 commits October 19, 2026 16:17
…ess close

Add ResourcesSessionFence, which serializes seed and close per
resources_session_id and remembers closed sessions for
CLOSED_SESSION_RETENTION_S instead of until shutdown. A seed for a closed
session gets 409, which single_agent_turn does not retry.

State that close must release state by resources_session_id when the
request carries no cookie, as after a lost seed reply, and check it in
check_resources_session_contract with an optional holds_state probe.
Move example_single_tool_call onto the fence.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>
…s at verify

Seed and close refusals that no retry can fix were plain ValueErrors, so
they became a 500 and single_agent_turn dispatched the whole episode again.
Add ResourcesSeedRejectedError (422) for input that fails validation and
ResourcesSessionConflictError (409) for an ID bound to another episode, a
close for another episode, or a closed session.

A verify for a session the server no longer holds returned reward 0 (a
policy failure) or an error. Add lost_session_verification, which builds
a masked sample with failure_kind session_lost or session_expired, and
ResourcesSessionLostError (404) for tool calls on such a session.

check_resources_session_contract now requires 409 for a conflicting or
late seed and, with verify_body, a masked verify after close. Move
gdpval and nemo_user_sim, which already run the check, onto these rules;
nemo_user_sim also maps resources_session_id to its cookie session so a
cookie-less close releases it and a conflicting seed is caught.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>
@yaoyu-33
yaoyu-33 force-pushed the fix/resources-session-close-contract branch from 1275512 to 9fab94c Compare October 19, 2026 23:35
yaoyu-33 added a commit to yaoyu-33/Gym that referenced this pull request Oct 19, 2026
…rn failures

Resources servers now refuse unfixable seeds and closes with 422 or 409 and
a reason in the body (NVIDIA-NeMo#4679), but single_agent_turn built
failure_reason from str(ClientResponseError) for every stage except the
agent seed, so the reason was lost. Use describe_dependency_error for the
resources seed, agent turn, agent close and verify failures too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>
@yaoyu-33 yaoyu-33 changed the title fix(resources): bound closed sessions and release state on a cookie-less close fix(resources): tighten the session contract for close, refusals and lost sessions Oct 19, 2026
yaoyu-33 and others added 2 commits October 19, 2026 19:48
…completion

A verify for a session gdpval no longer holds now returns a masked
session_lost sample instead of a 409. The multistage orchestrator decides
success by the failure class alone, so it counted that sample as a judged
success: a final stage with half its tasks judged reported complete with a
headline ELO, and a partial earlier stage was coverage-blocked and never
retried.

tag_results now turns a session_lost or session_expired result into the
terminal verification failure the 409 used to produce, so routing, success,
resume gating and partial-stage acceptance behave as before. aggregate_metrics
also leaves such a row out of its stage's observed rows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>
…view

- gdpval: bind the session cookie only after a seed's checks pass, so a
  refused 409 or 422 reply no longer hands the caller another episode's
  session.
- example_single_tool_call and nemo_user_sim: a close of a closed session
  that names another episode gets 409, and check_resources_session_contract
  now checks it (keeps_state only). nemo_user_sim records the closing episode.
- nemo_user_sim: a seed dropped after session_ttl_seconds verifies as a
  masked session_expired sample instead of session_lost.
- lost_session_verification logs a warning, since a masked sample is
  otherwise visible only in coverage metrics, and documents the dict merge
  that avoids a duplicate-keyword TypeError with extra="allow" requests.
- ResourcesSessionLostError says what each harness does with the 404 and
  validates its failure_kind.
- Tests: fence locks are per session, clear() forgets records, the contract
  check rejects a 500 late seed, refusals reach the wire as 422/404, a masked
  sample is kept for coverage and dropped from quality, gdpval TaskData 422
  and judged-not-masked gone session, nemo_user_sim close by ID first.
- Docs: the resources server tutorial and the nemo_user_sim README.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>
@yaoyu-33

Copy link
Copy Markdown
Contributor Author

/ok to test 6a1ac48

@github-actions

github-actions Bot commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

main added ClosedResourcesSessionRecords and session routing (NVIDIA-NeMo#3905): closed
resources sessions are remembered for the configurable
session_close_retry_window_seconds, a retried close keeps the first record,
and with several workers a record must not outlive the session's routing claim.

ResourcesSessionFence now keeps its per-session lock and delegates closed
records to ClosedResourcesSessionRecords[EpisodeId], constructed with the
server's session_close_retry_window_seconds. CLOSED_SESSION_RETENTION_S (a
fixed 3600 s that renewed on every close) is gone. example_single_tool_call and
gdpval build the fence from their config. The close-contract docstring now
covers both a routed server, where a typed close is given resources_session_id
even without a cookie, and an app without routing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>
yaoyu-33 added a commit to yaoyu-33/Gym that referenced this pull request Oct 21, 2026
…-fence-migration

Brings in NVIDIA-NeMo#4679's merge of main, where ResourcesSessionFence keeps closed
records in ClosedResourcesSessionRecords for the server's
session_close_retry_window_seconds. swebench_pro and interactive_browser now
build their fence from that config, and the interactive_browser README states
the configurable window instead of CLOSED_SESSION_RETENTION_S.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>
@yaoyu-33

Copy link
Copy Markdown
Contributor Author

/ok to test ff3ecc4

@yaoyu-33
yaoyu-33 disabled auto-merge October 24, 2026 03:03
@yaoyu-33
yaoyu-33 enabled auto-merge October 24, 2026 03:03
@yaoyu-33
yaoyu-33 added this pull request to the merge queue Oct 24, 2026
@github-actions

Copy link
Copy Markdown
Contributor

🔄 Merge queue validation started! You can track the progress here: https://github.com/NVIDIA-NeMo/Gym/actions/runs/40110947808

Merged via the queue into NVIDIA-NeMo:main with commit 7dad3a5 Oct 24, 2026
52 of 55 checks passed
@yaoyu-33
yaoyu-33 deleted the fix/resources-session-close-contract branch October 24, 2026 16:46
kbhardwaj-nvidia added a commit that referenced this pull request Oct 24, 2026
#4679 made check_resources_session_contract require typed refusals and
masked verifies for lost sessions, which failed this server's contract
test in the merge queue.

- Serialize seed and close with ResourcesSessionFence, which also
  bounds how long a closed session is remembered.
- Refuse an unrunnable task with ResourcesSeedRejectedError (422) and a
  conflicting seed or close with ResourcesSessionConflictError (409);
  a close for another episode was a 500 that re-dispatched the episode.
- Set the session cookie only after a seed succeeds.
- Answer a tool call for a session the server no longer holds with
  ResourcesSessionLostError (404), and its verify with a masked
  session_lost sample instead of a 409.

Signed-off-by: Khushi Bhardwaj <kbhardwaj@nvidia.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
linj-glitch pushed a commit to linj-glitch/Gym that referenced this pull request Oct 25, 2026
…irrup (NVIDIA-NeMo#5000)

* feat(enterpriseops_gym): run EnterpriseOps-Gym on OpenSandbox with Stirrup

Add ServiceNow's EnterpriseOps-Gym as a benchmark, run the way Artificial
Analysis runs EnterpriseOps-Gym-AA: the Stirrup harness with only each
task's oracle tools, a 100-turn cap, 3 repeats and strict pass@1.

- resources_servers/enterpriseops_gym: for each task, start its gym
  servers from the upstream images in sandboxes (OpenSandbox), seed the
  task databases from the pinned upstream dumps, forward the agent's
  tool calls to the gym that owns each tool, and score the final
  database state with the task's SQL verifiers. Verifier semantics are
  checked against goldens produced by the upstream harness.
- benchmarks/enterpriseops_gym: prepare the 649 public oracle tasks from
  the pinned Hugging Face dataset and pair the server with the Stirrup
  agent through an environment-server taskset.
- responses_api_agents/stirrup_agent: add `code_exec` (default true) to
  leave out the shell tool when the task's tools are the only tools.

Signed-off-by: Khushi Bhardwaj <kbhardwaj@nvidia.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(enterpriseops_gym): follow the tightened resources-session contract

NVIDIA-NeMo#4679 made check_resources_session_contract require typed refusals and
masked verifies for lost sessions, which failed this server's contract
test in the merge queue.

- Serialize seed and close with ResourcesSessionFence, which also
  bounds how long a closed session is remembered.
- Refuse an unrunnable task with ResourcesSeedRejectedError (422) and a
  conflicting seed or close with ResourcesSessionConflictError (409);
  a close for another episode was a 500 that re-dispatched the episode.
- Set the session cookie only after a seed succeeds.
- Answer a tool call for a session the server no longer holds with
  ResourcesSessionLostError (404), and its verify with a masked
  session_lost sample instead of a 409.

Signed-off-by: Khushi Bhardwaj <kbhardwaj@nvidia.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Signed-off-by: Khushi Bhardwaj <kbhardwaj@nvidia.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
yaoyu-33 added a commit that referenced this pull request Oct 27, 2026
…1-finance-sec-search-sessions

#4679 is now on main (7dad3a5), so its pre-merge commits on this branch
resolve to main's copy. The one conflict, resources_servers/nemo_user_sim/README.md,
takes main's wording (#3910 reworded #4679's text). After the merge the
branch differs from main only in resources_servers/finance_sec_search/.

Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
copy-pr-bot Bot pushed a commit that referenced this pull request Oct 28, 2026
Main's session contract (#4679) refuses, with 409, a close that names another episode after the session
closed, and the mock-tool server already records the closing episode. The SWE server recorded only that a
session closed, so such a close got 200. It now records the episode that closed the session (the seed's
episode when a retired attempt cleared it) and refuses a close for any other. A verify that arrives after the
close is masked as `session_lost`, since the session's sandbox, and with it the patch, is gone.

The contract test now also checks that a close without the cookie releases the sandbox and that the late
verify is masked, and two tests mirror the mock-tool server's: a closed or retired session keeps its episode.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Katherine Huang <katherineh@nvidia.com>
anson627 pushed a commit to anson627/Gym that referenced this pull request Oct 28, 2026
… checks (NVIDIA-NeMo#5007)

* feat(partner_if): add the three SWE tasks to the partner_if benchmark

partner_if grows to 25 tasks (dataset 2.1.0). pif_024-026 run OpenCode (opencode_agent) in the row's Scale-SWE
sandbox, and swe_app.py grades their instruction following.

- swe_app.py serves an Environment Server session for opencode_agent. A typed seed starts the row's image, runs
  the row's pre_commands and Scale-SWE's setup, and hands the sandbox to the agent as sandbox_access; a failed
  step stops the sandbox and fails the seed. Close, retire and shutdown stop it; per-session locks; one worker.
  Grading reads the actor's system prompt and tool-description additions from the row's contract (new
  GradingContract fields) and refuses a contract without the prompt. grade_if is split from Scale-SWE's native
  verify, which stays a diagnostic, and VERIFIER_FIXTURE covers full, zero and malformed cases.
- benchmarks/partner_if adds the SWE environment and routes. The policy's sampling (temperature 1.0, top_p 1.0,
  no output cap) is pinned on a copy of the policy model, since opencode_agent refuses request-level sampling.
  The SWE agent inherits only opencode_agent's command deny-list: OpenCode 1.17.11 lets a tools map undo every
  permission denial. The SWE sandbox keeps the cluster's S3 mount out. prepare.py pins 2.1.0 (25 rows).
- configs/swe.yaml follows the same wiring; the READMEs and manifest describe the SWE tasks.

Validation: gym env test --resources-server partner_if (477 passed); the prepare and dispatcher tests (26
passed); gym env validate partner_if --kind benchmark (25 rows); pre-commit; a smoke on OpenSandbox with GLM 5.3
(pif_001 and pif_024-026, one rollout each: 4/4 healthy, 0 masked).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Katherine Huang <katherineh@nvidia.com>

* fix(partner_if): keep judge calls out of rollout capture and carry tool records

Make partner_if pass Frontier Eval's EC-2 check (gym-p0/v1) without changing Gym core.

- TE-8/TE-9: the grading service calls the judge outside the rollout context. With observability on,
  ServerClient prefixed judge calls made during /verify with the rollout ID, so the model server captured
  them under the rollout with no owning invocation (model_call_ownership_unavailable). Outside the
  rollout context the judge call is forwarded without capture; the row keeps the judge request and reply
  as receipts. NVIDIA-NeMo#4630 adds verifier ownership in core; this can go once it lands.
- TE-5: the dispatcher adds the tool executions in simple_agent's reply trajectory to the episode's agent
  observations. In an agent session simple_agent returns only its invocations at close, so the canonical
  ng_trajectory had no tool records. This is the conversion nemo_user_sim already uses; rollout
  collection joins each record's output from the invocation's conversation.

Validation: partner_if tests 467 passed, dispatcher tests 20 passed, pre-commit clean. A full DFW run
(66 rollouts, GLM 5.3 policy and judge, model-call capture on) passes EC-2: text rows with --no-tools
and tool rows with the default scope both exit 0, TE-1 to TE-9 on every rollout; 279/279 tool calls
have canonical records; health 66/66; pass@1[avg-of-3]/all_if_pass 63.64.

Signed-off-by: Katherine Huang <katherineh@nvidia.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(partner_if): say where the judge's calls are recorded

Since the judge call runs outside the rollout context, it no longer appears in the model-call capture. The
README now says that each row keeps the judge's request and reply in judge_request and judge_response, so
the exit check on the judge's calls reads them there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Katherine Huang <katherineh@nvidia.com>

* fix(partner_if): fit SWE evidence in the judge's input and retry an invalid judge reply

In the 25-task exit checks, 15 of 27 SWE rollouts went unscored: 10 judge inputs exceeded the 1,000,000-character
limit, and 5 judge replies were invalid, all on inputs above 800,000 characters.

- SWE evidence: a tool output longer than 500 characters keeps its first and last 250, with a
  `[… N characters omitted …]` marker between them. Every agent message and tool call stays whole, in order. The
  answer is the agent's final message instead of every message joined, which the trajectory already holds. None of
  the SWE rubrics grades tool output. On the 27 exit-check rollouts the largest judge input goes from 1,464,448 to
  841,649 characters, and the formerly invalid ones from 0.81-1.00M to 0.47-0.59M.
- Judge replies: a reply that fails validation is asked for again once with the same request, and the second reply
  decides. Two invalid replies are masked as judge_unparseable as before. The row keeps the rejected reply with its
  reason in judge_rejected_responses.
- The READMEs say what the judge sees for SWE rows and describe the retry.

Validation: gym env test --resources-server partner_if (483 passed); the dispatcher and prepare tests (27 passed);
pre-commit. Offline, on all 27 SWE rollouts of the three exit-check runs with a stand-in judge: every judge input
fits (largest 841,649), each rollout sends every judged rubric ID in one request, and all 27 are scored.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Katherine Huang <katherineh@nvidia.com>

* docs(partner_if): record the 25-task reference score

The exit checks on dataset 2.1.0 (25 tasks) pass with GLM 5.3 as policy and judge: reference
pass@1[avg-of-3]/all_if_pass 61.33, tolerance 8.5 points (95%), from three runs of 75 rollouts (the other two
scored 58.67 and 57.33). It replaces 2.0.0's 62.12, measured on 22 tasks with a 16,384-token cap. The README
also says that an endpoint which ends long requests needs a longer per-request timeout, as the runs set.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Katherine Huang <katherineh@nvidia.com>

* fix(partner_if): pin dataset 2.1.4 and validate file blocks

Signed-off-by: Arti Jain <artij@nvidia.com>

* docs(partner_if): record dataset 2.1.4 regrade score

Signed-off-by: Arti Jain <artij@nvidia.com>

* docs(partner_if): link the public synthetic examples from the benchmark README

The benchmark's rows are internal. Point readers at the resources server's synthetic
rows, which use the same format and come with recorded rollouts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Katherine Huang <katherineh@nvidia.com>

* fix(partner_if): leave the S3-mount sandbox label to the deployment

The SWE sandbox config set `nemo.nvidia.com/s3-hostpath: "false"` in Gym's public configs, a label only
NVIDIA's clusters read. Sandbox labels are deployment-specific, so the public configs keep only
`environment: partner-if-swe`, and Frontier Eval's config gives the label to keep the cluster's S3 mount out
of the agent's shell. The README drops its sentence about the label.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Katherine Huang <katherineh@nvidia.com>

* fix(partner_if): cue the judge's JSON output in its user message

partner_if asks its judge for a JSON object (`text.format = json_object`) and says so in the system
prompt. Some hosted OpenAI-compatible routes, such as GPT-6 Sol's, accept that format only when the user
message itself says JSON. The judge's user payload now ends with
`"_judge_output_format": "JSON object with an items array"`, the cue the GPT-6 regrade of dataset 2.1.4
added, so new judge requests match that regrade byte for byte.

The manifest moves to 0.2.0, since the 25-task dataset and the judge's input change what scores compare
against. The benchmark README says the SWE scores depend on OpenCode 1.17.11, and the dispatcher README no
longer says the receipt comes back unchanged: the dispatcher adds the reply's tool executions to the
episode's agent observations. The example rollouts are rolled out again with GLM 5.3 as policy and judge,
so their judge requests carry the cue.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Katherine Huang <katherineh@nvidia.com>

* fix(partner_if): judge with GPT-6 Sol under judge protocol v5.3

partner_if's judge becomes GPT-6 Sol (`azure/openai/gpt-6-sol`) by default, still through the `vllm_model`
judge server at temperature 0 with 32768 output tokens; the endpoint and key stay run settings. The judge
protocol moves to `partner-if-evidence-v5.3`, since the previous commit's JSON cue changed the judge's input.
The GPT-6 regrade of dataset 2.1.4 recorded its receipts as v5.2 but injected the same cue, so its judge
requests match v5.3's bytes.

The README names the judge, says the 2.1.4 reference with it is being measured, and keeps the GLM-judged
2.1.0 score as history and the regrade as a cross-check. The example rollouts are rolled out again under
v5.3, with GLM 5.3 as policy and judge.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Katherine Huang <katherineh@nvidia.com>

* docs(partner_if): record the 2.1.4 reference score with the GPT-6 Sol judge

Three full runs of dataset 2.1.4 with GLM 5.3 as the policy and the default GPT-6 Sol judge scored 56.00,
56.00 and 53.33 on `pass@1[avg-of-3]/all_if_pass`, with no masked rollouts. The first is the reference,
with a tolerance of ±7.1 points (95%) from the repeat standard deviation pooled over the three runs. The
regrade through another route to GPT-6 Sol stays as a cross-check, and the GLM-judged 2.1.0 score as a
one-line history note.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Katherine Huang <katherineh@nvidia.com>

* fix(partner_if): leave the policy's sampling to the run

The benchmark fixed the policy's sampling for every task at temperature 1.0 and top_p 1.0. It now leaves
sampling to the run, which sets its model's sampling on the policy model server, as other benchmarks do.
`partner_if_policy_model`, the copy of the run's policy server that every agent calls, keeps that sampling
and only removes the output cap, which the long prompts and OpenCode's per-call cap still need. Runs still set
no request-level sampling, since opencode_agent refuses it.

The README describes the reference run's policy at GLM 5.3's standard sampling (temperature 1.0, top_p 0.95)
and notes that the reference score, measured with top_p fixed at 1.0, is being re-measured.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Katherine Huang <katherineh@nvidia.com>

* docs(partner_if): record the 2.1.4 reference at GLM 5.3's standard sampling

With the policy's sampling left to the run, three full runs of dataset 2.1.4 with GLM 5.3 at its standard
sampling (temperature 1.0, top_p 0.95) and the default GPT-6 Sol judge scored 57.33, 53.33 and 56.00 on
`pass@1[avg-of-3]/all_if_pass`. The first is the reference, with a tolerance of ±9.5 points (95%) from the
repeat standard deviation pooled over the three runs. It replaces the interim score measured with top_p fixed
at 1.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Katherine Huang <katherineh@nvidia.com>

* fix(partner_if): keep the episode that closed a SWE session

Main's session contract (NVIDIA-NeMo#4679) refuses, with 409, a close that names another episode after the session
closed, and the mock-tool server already records the closing episode. The SWE server recorded only that a
session closed, so such a close got 200. It now records the episode that closed the session (the seed's
episode when a retired attempt cleared it) and refuses a close for any other. A verify that arrives after the
close is masked as `session_lost`, since the session's sandbox, and with it the patch, is gone.

The contract test now also checks that a close without the cookie releases the sandbox and that the late
verify is masked, and two tests mirror the mock-tool server's: a closed or retired session keeps its episode.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Katherine Huang <katherineh@nvidia.com>

---------

Signed-off-by: Katherine Huang <katherineh@nvidia.com>
Signed-off-by: Arti Jain <artij@nvidia.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Arti Jain <artij@nvidia.com>

This branch was previously deployed

1 inactive deployment
public — 7ebecb5f Deployed Oct 24, 2026 by copy-pr-bot[bot] via release / finalize / notify #7004
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:env-infra Shared environment framework, lifecycle, registry, scaffolding, and validation bug Something isn't working complexity:high Cross-component or shared-contract change with a large review surface 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.

3 participants