Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 1 addition & 6 deletions packages/sandboxed_gym/src/sandboxed_gym/host/gym_host.sh
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,7 @@ gym_rw=${3:-/tmp/gym-src/Gym}
runtime=${4:-}

if [ -z "$runtime" ]; then
# Prefer an explicitly packaged module path if present in PYTHONPATH layout.
if [ -f "$root/packages/sandboxed_gym/src/sandboxed_gym/runtime/gym_host_runtime.py" ]; then
runtime=$root/packages/sandboxed_gym/src/sandboxed_gym/runtime/gym_host_runtime.py
else
runtime=$root/nemo_rl/environments/sandbox/gym_host_runtime.py
fi
runtime=$root/packages/sandboxed_gym/src/sandboxed_gym/runtime/gym_host_runtime.py
fi

gym_tree=${SANDBOXED_GYM_TREE:-$root/3rdparty/Gym-workspace/Gym}
Expand Down
1 change: 1 addition & 0 deletions packages/sandboxed_gym/src/sandboxed_gym/host/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,7 @@ class NemoGymSandboxedConfig(BaseModel):
sandboxed: bool = False
host_provider: str = "opensandbox"
environment_path: str | None = None
environment_offline: bool = False
sandbox: SandboxConfig | None = None
job_id: str = DEFAULT_JOB_ID
episode_broker: dict[str, Any] = Field(default_factory=dict)
Expand Down
3 changes: 3 additions & 0 deletions packages/sandboxed_gym/src/sandboxed_gym/orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
)
from sandboxed_gym.host.provider import SandboxedGymHostProvider, get_host_provider
from sandboxed_gym.runtime.gym_host_runtime import (
ENVIRONMENT_OFFLINE_ENV_KEY,
ENVIRONMENT_PACKAGE_REQUIRED_ENV_KEY,
GYM_GLOBAL_CONFIG_ENV_KEY,
)
Expand Down Expand Up @@ -276,6 +277,8 @@ def build_gym_host_spec(
# The mount path is ``/job/environment`` with or without a FileSet. This flag is how the
# host distinguishes a required package from an image-bundled tree at the same path.
bootstrap_extra[ENVIRONMENT_PACKAGE_REQUIRED_ENV_KEY] = "true"
if cfg.environment_offline:
bootstrap_extra[ENVIRONMENT_OFFLINE_ENV_KEY] = "true"
bootstrap_env = build_bootstrap_env(
cfg.job_id,
cfg.environment_path or sandbox.env_mount_path,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
#: for image-bundled Gym too; this flag is how a missing ``nemo-environment.yaml`` becomes a
#: FileSet error instead of a silent fallback to the image-shipped environment.
ENVIRONMENT_PACKAGE_REQUIRED_ENV_KEY = "NMP_ENVIRONMENT_PACKAGE_REQUIRED"
ENVIRONMENT_OFFLINE_ENV_KEY = "NMP_ENVIRONMENT_OFFLINE"
UV_CACHE_DIR_KEY = "uv_cache_dir"
UV_VENV_DIR_KEY = "uv_venv_dir"
# Writable /job/work subdirectory where wheels are installed for the running Gym host.
Expand All @@ -58,6 +59,7 @@
MODEL_CALLS_RESULT_KEY = "_nmp_model_calls"
# uv setting that points Gym's per-server dependency resolver at the staged wheelhouse.
UV_FIND_LINKS_ENV_KEY = "UV_FIND_LINKS"
UV_OFFLINE_ENV_KEY = "UV_OFFLINE"
NEMO_GYM_EXTRA_ROOTS_ENV_KEY = "NEMO_GYM_EXTRA_ROOTS"
#: Which agent, resources server, and model to run. Gym has no schema for this key, so
#: the host pops it and rewrites ``config_paths`` before Gym parses the dict.
Expand Down Expand Up @@ -220,6 +222,10 @@ def _environment_package_required() -> bool:
return os.environ.get(ENVIRONMENT_PACKAGE_REQUIRED_ENV_KEY, "").strip().lower() in {"1", "true", "yes"}


def _environment_offline() -> bool:
return os.environ.get(ENVIRONMENT_OFFLINE_ENV_KEY, "").strip().lower() in {"1", "true", "yes"}


def _load_runtime_environment_package(
environment_path: str,
*,
Expand Down Expand Up @@ -287,6 +293,16 @@ def _install_wheels_v1_dependencies(package: EnvironmentPackage | None, work_pat
# for image-owned Gym and its core dependencies.
os.environ[UV_FIND_LINKS_ENV_KEY] = wheels_dir

if _environment_offline():
if UV_OFFLINE_ENV_KEY in os.environ:
print(
f"gym-host: offline requested, but {UV_OFFLINE_ENV_KEY}="
f"{os.environ[UV_OFFLINE_ENV_KEY]} is already set",
flush=True,
)
else:
os.environ[UV_OFFLINE_ENV_KEY] = "1"

# Gym starts agent and resource servers as child Python processes. Prepend the wheel target so
# those processes can import the environment's vendored dependencies.
existing_pythonpath = os.environ.get("PYTHONPATH")
Expand Down Expand Up @@ -606,33 +622,32 @@ def run_rollouts_sync(


class Handler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
_chunked: bool = False
max_request_bytes: int = 268_435_456
max_response_bytes: int = 268_435_456
heartbeat_interval_s: float = _HEARTBEAT_INTERVAL_S
rollout_deadline_s: float = _DEFAULT_ROLLOUT_DEADLINE_S

def parse_request(self) -> bool:
parsed = super().parse_request()
self.close_connection = True
return parsed

def do_GET(self) -> None:
if not self.path.startswith("/health"):
self.send_response(404)
self.end_headers()
self._send_empty(404)
return
# Must match do_POST: a host that passes /health and then 503s every rollout is
# invisible to wait_ready.
if not _READY or _HEAD_SERVER_CONFIG is None or _ROLLOUT_HELPER is None:
body = json.dumps({"status": "starting"}).encode("utf-8")
self.send_response(503)
self._send_json(503, {"status": "starting"})
else:
body = json.dumps({"status": "ready"}).encode("utf-8")
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
self._send_json(200, {"status": "ready"})

def do_POST(self) -> None:
if not self.path.startswith("/rollouts/run"):
self.send_response(404)
self.end_headers()
self._send_empty(404)
return
if not _READY or _HEAD_SERVER_CONFIG is None or _ROLLOUT_HELPER is None:
self._send_json(503, _runtime_error("bootstrap_failed", "Gym host not ready"))
Expand Down Expand Up @@ -690,14 +705,20 @@ def do_POST(self) -> None:
# Committed to 200 before the work is done, so the first byte leaves immediately and no hop
# can mistake a long batch for a dead one. Everything judgeable from the request alone was
# rejected with a real status above; failures from here travel in the body as
# {"error": ...}, which the caller already treats as fatal. No Content-Length: the body is
# delimited by the close that `Connection: close` promises, which is what allows the
# heartbeats below to precede a payload of unknown length.
# {"error": ...}, which the caller already treats as fatal.
self._chunked = self.request_version >= "HTTP/1.1"
if not self._chunked:
self._send_body(200, self._await_results(future, started))
return

self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Connection", "close")
self.send_header("Transfer-Encoding", "chunked")
self._announce_close()
self.end_headers()
self.wfile.write(self._await_results(future, started))
self._write_chunk(self._await_results(future, started))
self.wfile.write(b"0\r\n\r\n")
self.wfile.flush()

def _await_results(self, future: concurrent.futures.Future[list[dict]], started: float) -> bytes:
"""Wait for ``future``, heartbeating while it runs, and return the body to send.
Expand All @@ -718,9 +739,10 @@ def _await_results(self, future: concurrent.futures.Future[list[dict]], started:
# its own, which is not this loop's tick.
done, _ = concurrent.futures.wait([future], timeout=min(self.heartbeat_interval_s, remaining))
if not done:
if not self._chunked:
continue
try:
self.wfile.write(b" ")
self.wfile.flush()
self._write_chunk(b" ")
except OSError as exc:
# The caller is gone. Nothing will read this batch, so stop paying for it:
# cancelling the future propagates to the collector task on the shared loop.
Expand Down Expand Up @@ -765,14 +787,33 @@ def _await_results(self, future: concurrent.futures.Future[list[dict]], started:
def _error_body(self, code: str, message: str) -> bytes:
return json.dumps(_runtime_error(code, message)).encode("utf-8")

def _send_json(self, status: int, payload: dict[str, Any]) -> None:
body = json.dumps(payload).encode("utf-8")
def _write_chunk(self, data: bytes) -> None:
if not data:
return
self.wfile.write(b"%X\r\n%s\r\n" % (len(data), data))
self.wfile.flush()

def _announce_close(self) -> None:
if self.close_connection:
self.send_header("Connection", "close")

def _send_empty(self, status: int) -> None:
self.send_response(status)
self._announce_close()
self.send_header("Content-Length", "0")
self.end_headers()

def _send_body(self, status: int, body: bytes) -> None:
self.send_response(status)
self._announce_close()
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)

def _send_json(self, status: int, payload: dict[str, Any]) -> None:
self._send_body(status, json.dumps(payload).encode("utf-8"))

def log_message(self, format: str, *args: Any) -> None:
return

Expand Down
1 change: 1 addition & 0 deletions packages/sandboxed_gym/src/sandboxed_gym/serve_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ class SandboxedGymServeConfig(BaseModel):
job_id: str = DEFAULT_JOB_ID
host_provider: str = "opensandbox"
environment_path: str | None = None
environment_offline: bool = False
sandbox: SandboxConfig
episode_broker: EpisodeBrokerConfig | dict[str, Any] = Field(default_factory=dict)
gym_global_config: dict[str, Any] = Field(default_factory=dict)
Expand Down
Loading
Loading