NeMo Gym standardizes RL rollout collection (and feeds trainers like NeMo RL GRPO). This example runs real desktop computer-use environments behind its resources-server contract, where the per-rollout resource is a fork of the task's golden checkpoint — task setup runs once, and every rollout restores the verified task state in well under a second instead of re-provisioning an environment per episode. The Docker filesystem tier restarts processes; byte-identical live process/GUI state requires the explicitly selected CRIU memory tier.
Observation is text-first: computer_observe returns the guest a11y engine's numbered
tree (stable e<N> ids) and mode="diff" returns the ~/+/- delta — a few hundred bytes
per turn, trainable with any tool-calling LLM (no VLM image plumbing required).
Tasks are CUA-Gym-format bundles
(config.json + reward.py with the REWARD: X.X contract); two demo bundles ship in
tasks/. Point CUA_GYM_TASKS at a real CUA-Gym output/final/ export to scale
the task set.
# Docker up, shinken/sandbox-linux built
python examples/nemo_gym/local_loop.pyA scripted agent drives the engine exactly like NeMo Gym's simple_agent would over HTTP
(seed_session → tools → verify) and asserts both demo tasks score REWARD: 1.0.
Per-server venvs land in the workspace, so build it outside the repo:
python examples/nemo_gym/make_workspace.py --dest ~/nemogym-workspace
# wire a policy model (any OpenAI-compatible endpoint) — see the script's printed steps,
# then from the workspace:
ng_run "+config_paths=[resources_servers/shinken_cua/configs/shinken_cua.yaml,...]"
ng_collect_rollouts +agent_name=shinken_cua_simple_agent \
+input_jsonl_fpath=data/smoke.jsonl +output_jsonl_fpath=data/rollouts.jsonl \
+limit=2 +num_samples_in_parallel=1Verified end-to-end (2026-06-11, Kimi K2.6 as the policy over the NVIDIA inference API): both demo tasks at reward 1.0, with the GUI task's trajectory using exactly the intended loop —
zenity-entry: observe → click e7 → type_text "ACME GmbH" → observe(diff: Value confirmed) → key Return
hello-file: observe → exec(printf … > /tmp/hello.txt) → exec(verify) → done
The rollout JSONL is OpenAI-Responses-format with rewards and token accounting — directly consumable by NeMo RL GRPO / OpenRLHF (see NeMo Gym's training tutorials).
Download the released bundle export (10,910 tasks) and triage it for what runs on the
task image (images/linux/Dockerfile.cua):
huggingface-cli download xlangai/CUA-Gym --repo-type dataset \
--include "artifacts/*" --local-dir ~/datasets/cua-gym-dl
tar --use-compress-program=unzstd -xf ~/datasets/cua-gym-dl/artifacts/cua_gym_tasks_v1.tar.zst -C ~/datasets/cua-gym
python scripts/cua_gym_triage.py ~/datasets/cua-gym --json triage.jsonThen emit probe-validated train/validation datasets into the workspace (tasks whose reward is already 1.0 on the unsolved state are excluded — no learning signal):
python examples/nemo_gym/make_workspace.py --dest ~/nemogym-workspace \
--task-root ~/datasets/cua-gym --ids-file <probe-receipt.json>
# data/train.jsonl + data/validation.jsonl; serve with CUA_GYM_TASKS=~/datasets/cua-gym
# and SHINKEN_IMAGE=shinken/sandbox-cua:latest in the resources server's environment.
# Only after auditing post-fork process replay, explicitly set:
# SHINKEN_STATE_FIDELITY=filesystemMeasured on this lane (2026-06-12, one M4 Pro laptop, Kimi K2.6 policy): a 24-task × 2-repeat collection ran 48/48 rollouts in 11 m 48 s at parallel 6 (~244 rollouts/hour) with zero infrastructure failures — mean reward 0.43 (25× 0.0 / 2× 0.2 / 1× 0.4 / 20× 1.0), 8 of 24 task groups with intra-group reward variance (the non-zero-advantage prompts GRPO trains on), 11 tool calls and ~27k tokens per rollout p50. Environment cost per rollout is a fork at p50 0.47 s from the task's golden checkpoint; mechanical runnability over the released export is 160/188 on the file_cli class and 10/12 on a desktop_app sample once the task image carries LibreOffice.
NeMo RL's GRPO integration is online: the trainer hosts the policy in vLLM, NeMo Gym drives rollouts against it live, and the resources server (this one — and therefore the sandbox fleet) just has to be reachable over HTTP. Per NeMo Gym's NeMo RL tutorial, the trainer-side config points at the same artifacts this workspace already contains:
env:
should_use_nemo_gym: true
nemo_gym:
config_paths:
- responses_api_models/vllm_model/configs/vllm_model_for_training.yaml # theirs
- resources_servers/shinken_cua/configs/shinken_cua.yaml # ours
data:
train_jsonl_fpath: data/train.jsonl
validation_jsonl_fpath: data/validation.jsonl
grpo:
num_prompts_per_step: 4
num_generations_per_prompt: 4 # GRPO group — N forks of the same golden stateTheir single-node tutorial trains a ~9B policy on one 8-GPU node (NGC NeMo RL container);
a 1-GPU validation mode exists (cluster.gpus_per_node=1). The environment side is
CPU-only and can run on a separate host (the Gym↔trainer link is plain HTTP) — which is
exactly the shape Shinken's fleet numbers are built for: num_generations_per_prompt
maps to N forks of one golden checkpoint at ~0.5 s each, where re-provisioning
environments per generation is the cost the trainer otherwise eats.
NeMo RL is the production trainer, but it needs CUDA. local_grpo.py
provides a smaller Apple-Silicon smoke: an MLX policy
(Qwen2.5-1.5B-Instruct-4bit + LoRA) drives ShinkenComputerEngine; group-relative
advantages weight assistant-token log-probabilities; Adam updates the LoRA parameters.
This proves reward → gradient → optimizer plumbing only. It is not a full GRPO
implementation: it has no old-policy logprobs, importance ratios, clipping, KL term, or
distributed weight synchronization, and it does not establish learning or convergence.
python -m venv ~/venvs/mlxrl
~/venvs/mlxrl/bin/pip install "mlx-lm>=0.21" -e sdk/python
~/venvs/mlxrl/bin/python examples/nemo_gym/local_grpo.pyA recorded smoke (2026-06-12, M4 Pro, group 8, task hello-file) executed five LoRA
updates when reward spread was present. The sampled rewards below are a run artifact, not
a controlled learning-quality result:
iter 0: mean_reward=0.250 [0,0,0,0,0,1,0,1] updated=True (20s)
iter 1: mean_reward=0.250 [0,0,0,0,1,0,1,0] updated=True (10s)
iter 2: mean_reward=0.125 [0,0,0,0,1,0,0,0] updated=True (11s)
iter 3: mean_reward=0.875 [1,1,1,0,1,1,1,1] updated=True (11s)
iter 4: mean_reward=0.875 [1,1,1,1,1,1,1,0] updated=True (10s)
The environment-side grouping still matches the useful production shape:
num_generations_per_prompt rollouts can be N forks of one golden checkpoint. Algorithm
equivalence belongs to the real trainer connector, not this smoke.
-
Platform scope: the resources-engine lifecycle and Shinken CU/BU backend contracts are OS-neutral. The in-tree demo bundles and deepest automated evidence currently use Linux/X11; that is the present test-coverage depth, not an architectural Linux-first restriction.
-
Session correctness: every successful seed returns a generation token. Tool, verify, end, and reseed calls must carry it; stale requests fail closed and cannot mutate a newer rollout. Requests for one session are linearized, while different sessions remain concurrent.
-
Direct-engine API tightening: initial calls now spell
generation=Noneexplicitly and all later calls reuse the returned token. Providers must expose callabledelete_snapshot()so a bounded cache cannot silently forget substrate resources:seeded = engine.seed(session_id, task_id, generation=None) generation = seeded["generation"] observation = engine.tool( session_id, "computer_observe", {"mode": "tree"}, generation=generation ) reward = engine.verify(session_id, generation=generation)
-
Bounded ownership: abandoned rollouts are reaped actively (not only when another seed arrives), and cached golden snapshots use reset leases plus TTL/LRU eviction. Defaults are
idle_ttl_s=900,max_goldens=32,golden_ttl_s=3600,reap_interval_s=30,max_pending_cleanup=64, andcleanup_retry_batch=16; the example server starts maintenance from its app lifecycle and exposes matchingSHINKEN_*environment overrides. Direct long-lived engine users callstart_maintenance()explicitly; construction is thread-free so fork-based scorers remain safe. Snapshot-delete failures remain tracked and are retried in bounded batches; reaching the cleanup high-water mark fails closed for new seed/teardown work until maintenance makes progress. Terminal close gives every current owner two fair attempts without bypassing that bound; failures stay in the terminal engine ledger, and close raises until a later retry succeeds. -
Process model: one resources-server instance must use one worker because rollout ownership is process-local; startup rejects
num_workers != 1. Scale out with multiple session-routed server instances. A shared external session-owner/control-plane layer is not implemented. -
Shutdown:
engine.close()is terminal. It stops maintenance, waits for admitted calls, and closes every replica.close(delete_goldens=False)deliberately retains owned snapshots for a laterclose(delete_goldens=True)cleanup call; the engine itself does not reopen. -
Golden vs post-fork setup: file state belongs in the bundle's
configsteps (runs once into the golden checkpoint, on the disk tier). Setup that must exist as a running process (an open dialog) goes in our bundle extensionshinken_post_fork— replayed on every replica. On the CRIU memory tier the golden carries processes and that list is typically empty. The in-tree entrypoints pass an auditedSandboxSpec(state_fidelity="filesystem")for the two demo bundles. Bundle content cannot lower its own fidelity requirement: imported bundles default toprocess_memoryand fail closed on the Docker filesystem tier until a trusted caller explicitly opts in after validating the post-fork replay, or selects a process-memory provider. -
shinken gcremains the substrate-level recovery net for resources left by process death.