diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index 80342bdf4..d023ce3be 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -110,7 +110,7 @@ jobs: dockerfile: envs/opencode_env/server/Dockerfile context: envs/opencode_env - name: opencode-sandbox - dockerfile: envs/opencode_env/sandbox/hf_image/Dockerfile + dockerfile: envs/opencode_env/hf_image/Dockerfile context: envs/opencode_env - name: openapp-env dockerfile: envs/openapp_env/server/Dockerfile diff --git a/docs/source/environments/opencode.md b/docs/source/environments/opencode.md index b936ea3e4..077dd1a27 100644 --- a/docs/source/environments/opencode.md +++ b/docs/source/environments/opencode.md @@ -133,7 +133,7 @@ session.close() instead, swap the backend (`huggingface_hub>=1.22` ships with the package): ```python -from opencode_env.sandbox import HFSandboxBackend +from openenv.core.sandbox import HFSandboxBackend factory = OpenCodeSessionFactory( config=OpenCodeConfig(..., sandbox_home="/root"), # HF sandbox execs as root @@ -144,14 +144,14 @@ factory = OpenCodeSessionFactory( `image="python:3.12"` cold-installs opencode + the proxy deps on every rollout. For faster rollouts use the pre-baked image (opencode + proxy deps already installed), built by CI from -`sandbox/hf_image/Dockerfile`: +`hf_image/Dockerfile`: ```python sandbox_backend=HFSandboxBackend(image="ghcr.io/huggingface/openenv-opencode-sandbox:latest") ``` Any backend satisfying the `SandboxBackend` / `SandboxHandle` / `BgJob` protocols in -`opencode_env.sandbox.base` can be plugged in the same way. +`openenv.core.sandbox.base` can be plugged in the same way. ## Building the Docker Image @@ -257,7 +257,7 @@ opencode and the proxy's Python deps. Build a one-time template that ships those pre-installed: ```bash -.venv/bin/python envs/opencode_env/sandbox/build_template.py +.venv/bin/python src/openenv/core/sandbox/build_template.py # → builds `opencode-rl` template in your E2B account (~1m20s, one-time) ``` diff --git a/envs/opencode_env/README.md b/envs/opencode_env/README.md index 36ec3afb6..5d209864f 100644 --- a/envs/opencode_env/README.md +++ b/envs/opencode_env/README.md @@ -146,7 +146,7 @@ session.close() instead, swap the backend (`huggingface_hub>=1.22` ships with the package): ```python -from opencode_env.sandbox import HFSandboxBackend +from openenv.core.sandbox import HFSandboxBackend factory = OpenCodeSessionFactory( config=OpenCodeConfig(..., sandbox_home="/root"), # HF sandbox execs as root @@ -157,14 +157,14 @@ factory = OpenCodeSessionFactory( `image="python:3.12"` cold-installs opencode + the proxy deps on every rollout. For faster rollouts use the pre-baked image (opencode + proxy deps already installed), built by CI from -`sandbox/hf_image/Dockerfile`: +`hf_image/Dockerfile`: ```python sandbox_backend=HFSandboxBackend(image="ghcr.io/huggingface/openenv-opencode-sandbox:latest") ``` Any backend satisfying the `SandboxBackend` / `SandboxHandle` / `BgJob` protocols in -`opencode_env.sandbox.base` can be plugged in the same way. +`openenv.core.sandbox.base` can be plugged in the same way. ## Building the Docker Image @@ -270,7 +270,7 @@ opencode and the proxy's Python deps. Build a one-time template that ships those pre-installed: ```bash -.venv/bin/python envs/opencode_env/sandbox/build_template.py +.venv/bin/python src/openenv/core/sandbox/build_template.py # → builds `opencode-rl` template in your E2B account (~1m20s, one-time) ``` diff --git a/envs/opencode_env/__init__.py b/envs/opencode_env/__init__.py index 223be6f7b..b0dd5807a 100644 --- a/envs/opencode_env/__init__.py +++ b/envs/opencode_env/__init__.py @@ -30,7 +30,7 @@ RolloutResult, RolloutTurn, ) -from .sandbox import E2BSandboxBackend, SandboxBackend, SandboxHandle +from openenv.core.sandbox import E2BSandboxBackend, SandboxBackend, SandboxHandle from .task import OpenCodeTask __all__ = [ diff --git a/envs/opencode_env/harness.py b/envs/opencode_env/harness.py index 3c5d31e53..f7e3d0197 100644 --- a/envs/opencode_env/harness.py +++ b/envs/opencode_env/harness.py @@ -57,15 +57,17 @@ proxy_trace_path, system_prompt_path, ) -from .sandbox.base import BgJob, SandboxBackend, SandboxHandle +from openenv.core import sandbox as _core_sandbox +from openenv.core.sandbox import BgJob, SandboxBackend, SandboxHandle from .task import OpenCodeTask # Mode B proxy port. In-sandbox paths derive from config.sandbox_home (opencode_runtime). _PROXY_PORT = 7000 -# Local proxy source, uploaded to proxy_source_path(config) unless already baked in. -_PROXY_SOURCE_PATH = Path(__file__).parent / "sandbox" / "interception.py" +# Proxy source (shared, in openenv.core.sandbox), uploaded to proxy_source_path(config) +# unless already baked into the image. +_PROXY_SOURCE_PATH = Path(_core_sandbox.__file__).parent / "interception.py" Verifier = Callable[[SandboxHandle, OpenCodeTask], VerifyResult] diff --git a/envs/opencode_env/sandbox/hf_image/Dockerfile b/envs/opencode_env/hf_image/Dockerfile similarity index 76% rename from envs/opencode_env/sandbox/hf_image/Dockerfile rename to envs/opencode_env/hf_image/Dockerfile index fd6a1d124..85a488fdb 100644 --- a/envs/opencode_env/sandbox/hf_image/Dockerfile +++ b/envs/opencode_env/hf_image/Dockerfile @@ -2,7 +2,7 @@ # time so rollouts skip the cold install. Use with OpenCodeConfig(sandbox_home="/root"). # # Build context = envs/opencode_env: -# docker build -f sandbox/hf_image/Dockerfile -t /opencode-rl:latest . +# docker build -f hf_image/Dockerfile -t /opencode-rl:latest . # docker push /opencode-rl:latest FROM python:3.12 @@ -18,13 +18,14 @@ RUN pip install --no-cache-dir "fastapi>=0.104" "uvicorn[standard]>=0.24" "httpx RUN curl -fsSL https://opencode.ai/install | bash \ && /root/.opencode/bin/opencode --version -# Directory layout the harness expects, plus the pre-copied proxy source. +# Directory layout the harness expects. The proxy source (interception.py) lives in +# openenv.core.sandbox and is uploaded by the harness at rollout start, so it is not +# baked here (matches the pi-sandbox image). RUN mkdir -p /root/.config/opencode \ /root/logs/agent \ /root/logs/verifier \ /root/task \ /root/workdir \ /root/proxy -COPY sandbox/interception.py /root/proxy/interception.py WORKDIR /root/workdir diff --git a/envs/opencode_env/pyproject.toml b/envs/opencode_env/pyproject.toml index 6da6ad754..4ea14da96 100644 --- a/envs/opencode_env/pyproject.toml +++ b/envs/opencode_env/pyproject.toml @@ -48,10 +48,9 @@ server = "opencode_env.server.app:main" include-package-data = true packages = [ "opencode_env", - "opencode_env.sandbox", "opencode_env.server", ] -package-dir = { "opencode_env" = ".", "opencode_env.sandbox" = "sandbox", "opencode_env.server" = "server" } +package-dir = { "opencode_env" = ".", "opencode_env.server" = "server" } [tool.setuptools.package-data] opencode_env = ["**/*.md"] diff --git a/envs/opencode_env/server/opencode_environment.py b/envs/opencode_env/server/opencode_environment.py index 2d7831a73..61f79e5c7 100644 --- a/envs/opencode_env/server/opencode_environment.py +++ b/envs/opencode_env/server/opencode_environment.py @@ -84,12 +84,12 @@ def __init__(self) -> None: try: from ..config import OpenCodeConfig from ..harness import OpenCodeSessionFactory - from ..sandbox import E2BSandboxBackend + from openenv.core.sandbox import E2BSandboxBackend from ..task import OpenCodeTask except ImportError: # pragma: no cover from config import OpenCodeConfig # type: ignore from harness import OpenCodeSessionFactory # type: ignore - from sandbox import E2BSandboxBackend # type: ignore + from openenv.core.sandbox import E2BSandboxBackend from task import OpenCodeTask # type: ignore self._CommandResult = CommandResult diff --git a/envs/opencode_env/sandbox/__init__.py b/src/openenv/core/sandbox/__init__.py similarity index 90% rename from envs/opencode_env/sandbox/__init__.py rename to src/openenv/core/sandbox/__init__.py index d87c6728d..1a5f8b2a7 100644 --- a/envs/opencode_env/sandbox/__init__.py +++ b/src/openenv/core/sandbox/__init__.py @@ -4,11 +4,11 @@ # This source code is licensed under the BSD-style license found in the # LICENSE file in the root directory of this source tree. -"""Sandbox backends for the OpenCode harness. +"""Sandbox backends for OpenEnv harnesses. -The primitive ships with :class:`E2BSandboxBackend` as the default; any backend -that satisfies the :class:`SandboxBackend` / :class:`SandboxHandle` protocols -can be swapped in. +Shared sandbox layer (protocols + E2B / Hugging Face backends + the interception +proxy) used by loop-owning harness environments. Any backend that satisfies the +:class:`SandboxBackend` / :class:`SandboxHandle` protocols can be swapped in. The ``e2b`` import is wrapped in ``try/except`` so this package can be loaded in environments where ``e2b`` isn't installed (CI smoke tests, lint runs). diff --git a/envs/opencode_env/sandbox/base.py b/src/openenv/core/sandbox/base.py similarity index 98% rename from envs/opencode_env/sandbox/base.py rename to src/openenv/core/sandbox/base.py index 76869149a..62b5da372 100644 --- a/envs/opencode_env/sandbox/base.py +++ b/src/openenv/core/sandbox/base.py @@ -17,7 +17,7 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Any, Protocol, runtime_checkable +from typing import Protocol, runtime_checkable @dataclass diff --git a/envs/opencode_env/sandbox/build_template.py b/src/openenv/core/sandbox/build_template.py similarity index 96% rename from envs/opencode_env/sandbox/build_template.py rename to src/openenv/core/sandbox/build_template.py index 01c32d537..6db883601 100644 --- a/envs/opencode_env/sandbox/build_template.py +++ b/src/openenv/core/sandbox/build_template.py @@ -41,7 +41,7 @@ import sys from pathlib import Path -from e2b import Template, default_build_logger +from e2b import default_build_logger, Template _ENV_DIR = Path(__file__).resolve().parent @@ -126,8 +126,7 @@ def main(argv: list[str] | None = None) -> int: print("ERROR: E2B_API_KEY required.", file=sys.stderr) return 2 - print(f"Building template '{args.name}' " - f"(proxy source: {_PROXY_SOURCE})") + print(f"Building template '{args.name}' (proxy source: {_PROXY_SOURCE})") print(f"Skip cache: {args.skip_cache}") print() diff --git a/envs/opencode_env/sandbox/e2b.py b/src/openenv/core/sandbox/e2b.py similarity index 96% rename from envs/opencode_env/sandbox/e2b.py rename to src/openenv/core/sandbox/e2b.py index b567a9e65..896ec1233 100644 --- a/envs/opencode_env/sandbox/e2b.py +++ b/src/openenv/core/sandbox/e2b.py @@ -15,7 +15,7 @@ from e2b import Sandbox from e2b.sandbox_sync.commands.command_handle import CommandHandle -from .base import BgJob, ExecResult, SandboxBackend, SandboxHandle +from .base import BgJob, ExecResult, SandboxHandle class E2BBgJob: @@ -46,9 +46,7 @@ def pid(self) -> int: def wait(self, timeout: float | None = None) -> int: self._thread.join(timeout) if self._thread.is_alive(): - raise TimeoutError( - f"Background command did not exit within {timeout}s" - ) + raise TimeoutError(f"Background command did not exit within {timeout}s") if self._error is not None: # E2B raises CommandExitException on non-zero; treat as exit code. code = getattr(self._error, "exit_code", None) diff --git a/envs/opencode_env/sandbox/hf.py b/src/openenv/core/sandbox/hf.py similarity index 93% rename from envs/opencode_env/sandbox/hf.py rename to src/openenv/core/sandbox/hf.py index f846934c3..26e66a46e 100644 --- a/envs/opencode_env/sandbox/hf.py +++ b/src/openenv/core/sandbox/hf.py @@ -53,13 +53,17 @@ def wait(self, timeout: float | None = None) -> int: # Finished processes stay listed (running=False); a vanished pid means # the sandbox was torn down mid-run, not a clean exit. if proc is None: - raise RuntimeError(f"process {self.pid} vanished (sandbox torn down?)") + raise RuntimeError( + f"process {self.pid} vanished (sandbox torn down?)" + ) if not proc.running: return int(proc.exit_code) if proc.exit_code is not None else 0 if deadline is not None: remaining = deadline - time.monotonic() if remaining <= 0: - raise TimeoutError(f"Background command did not exit within {timeout}s") + raise TimeoutError( + f"Background command did not exit within {timeout}s" + ) time.sleep(min(_WAIT_POLL_INTERVAL_S, remaining)) else: time.sleep(_WAIT_POLL_INTERVAL_S) @@ -134,7 +138,11 @@ def start_bg( def write_text(self, path: str, content: str) -> None: parent = str(PurePosixPath(path).parent) - if parent not in ("", "/", "."): # "." == relative path with no parent dir to create + if parent not in ( + "", + "/", + ".", + ): # "." == relative path with no parent dir to create self._sbx.files.mkdir(parent) self._sbx.files.write(path, content) diff --git a/envs/opencode_env/sandbox/interception.py b/src/openenv/core/sandbox/interception.py similarity index 98% rename from envs/opencode_env/sandbox/interception.py rename to src/openenv/core/sandbox/interception.py index 131d41024..da5b0afce 100644 --- a/envs/opencode_env/sandbox/interception.py +++ b/src/openenv/core/sandbox/interception.py @@ -130,9 +130,7 @@ async def chat_completions(request: Request) -> Response: try: body = json.loads(raw_body) except json.JSONDecodeError: - return JSONResponse( - status_code=400, content={"error": "invalid json body"} - ) + return JSONResponse(status_code=400, content={"error": "invalid json body"}) forwarded_body = _prepare_forwarded_body(body, cfg) headers = { @@ -338,7 +336,7 @@ async def _stream() -> Any: yield line + "\n" if not line.startswith("data:"): continue - data = line[len("data:"):].strip() + data = line[len("data:") :].strip() if data == "[DONE]": continue try: @@ -381,7 +379,11 @@ def _accumulate_stream_chunk(chunk: dict[str, Any], acc: dict[str, Any]) -> None tc_idx = tc.get("index", 0) bucket = acc["tool_calls_by_idx"].setdefault( (idx, tc_idx), - {"id": None, "type": "function", "function": {"name": "", "arguments": ""}}, + { + "id": None, + "type": "function", + "function": {"name": "", "arguments": ""}, + }, ) if tc.get("id"): bucket["id"] = tc["id"] @@ -487,8 +489,7 @@ def _strip_logprobs(response_json: dict[str, Any]) -> dict[str, Any]: choices = out.get("choices") if isinstance(choices, list): out["choices"] = [ - {k: v for k, v in (ch or {}).items() if k != "logprobs"} - for ch in choices + {k: v for k, v in (ch or {}).items() if k != "logprobs"} for ch in choices ] return out @@ -537,9 +538,7 @@ def start(self) -> None: lifespan="on", ) self._server = uvicorn.Server(config) - self._thread = threading.Thread( - target=self._run_server, daemon=True - ) + self._thread = threading.Thread(target=self._run_server, daemon=True) self._thread.start() # Wait for the server to accept connections. deadline = time.time() + 10 diff --git a/tests/envs/test_opencode_env.py b/tests/envs/test_opencode_env.py index b28611874..3e0c7fdb9 100644 --- a/tests/envs/test_opencode_env.py +++ b/tests/envs/test_opencode_env.py @@ -322,7 +322,7 @@ def _exec_with_retry(self, *args, **kwargs): def test_interception_cli_reads_upstream_key_from_env( monkeypatch: pytest.MonkeyPatch, ) -> None: - from opencode_env.sandbox import interception + from openenv.core.sandbox import interception captured = {} diff --git a/tests/envs/test_opencode_hf_sandbox.py b/tests/envs/test_opencode_hf_sandbox.py index d3b8a539f..3bba5275e 100644 --- a/tests/envs/test_opencode_hf_sandbox.py +++ b/tests/envs/test_opencode_hf_sandbox.py @@ -28,8 +28,8 @@ if not hasattr(huggingface_hub, "Sandbox"): # pragma: no cover - only on old hub huggingface_hub.Sandbox = type("Sandbox", (), {}) # type: ignore[attr-defined] -import opencode_env.sandbox.hf as hf_mod # noqa: E402 -from opencode_env.sandbox.hf import HFBgJob # noqa: E402 +import openenv.core.sandbox.hf as hf_mod # noqa: E402 +from openenv.core.sandbox.hf import HFBgJob # noqa: E402 class _FakeProcess: @@ -204,7 +204,7 @@ def kill(self) -> None: # HFSandboxHandle / HFSandboxBackend # -------------------------------------------------------------------------- -from opencode_env.sandbox.hf import HFSandboxBackend, HFSandboxHandle # noqa: E402 +from openenv.core.sandbox.hf import HFSandboxBackend, HFSandboxHandle # noqa: E402 class _FakeCmdResult: