diff --git a/src/core/.DS_Store b/src/core/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..d2d3245d08a840fee8907754f30b7258fc740777 Binary files /dev/null and b/src/core/.DS_Store differ diff --git a/src/core/README.md b/src/core/README.md new file mode 100644 index 0000000000000000000000000000000000000000..bb8b4746e1955a4546a30e9d29a6bd5ff1866511 --- /dev/null +++ b/src/core/README.md @@ -0,0 +1,180 @@ +# image OpenEnv: Agentic Execution Environments + +An e2e framework for creating, deploying and using isolated execution environments for agentic RL training, built using Gymnasium style simple APIs. OpenEnv provides a standard for interacting with agentic execution environments via simple Gymnasium style APIs - step(), reset(), state(). Users of agentic execution environments can interact with the environment during RL training loops using these simple APIs. + +In addition to making it easier for researchers and RL framework writers, we also provide tools for environment creators making it easier for them to create richer environments and make them available over familar protocols like HTTP and packaged using canonical technologies like docker. Environment creators can use the OpenEnv framework to create environments that are isolated, secure, and easy to deploy and use. + + +## Overview +`openenv-core` provides the foundational building blocks for creating and interacting with containerized environments over HTTP. It enables you to build agent environments that can be deployed as Docker containers and accessed via a simple HTTP API. + +> ⚠️ **Early Development Warning** OpenEnv is currently in an experimental +> stage. You should expect bugs, incomplete features, and APIs that may change +> in future versions. The project welcomes bugfixes, but to make sure things are +> well coordinated you should discuss any significant change before starting the +> work. It's recommended that you signal your intention to contribute in the +> issue tracker, either by filing a new issue or by claiming an existing one. + + +# OpenEnv Core + +Core components for OpenEnv - a framework for building HTTP-based agentic environments. + +## Features + +- **HTTPEnvClient**: Generic HTTP client for interacting with remote environments +- **HTTPEnvServer**: FastAPI-based server wrapper for exposing environments over HTTP +- **Container Providers**: Pluggable architecture for running containers (Docker, Kubernetes, etc.) +- **Type System**: Strongly-typed Action/Observation/State interfaces +- **Web Interface**: Optional web UI for interacting with environments + +## Installation + +```bash +pip install openenv-core +``` + +For development: +```bash +pip install openenv-core[dev] +``` + +## Quick Start + +### Creating an Environment Client + +```python +from openenv_core import HTTPEnvClient, StepResult +from dataclasses import dataclass + +@dataclass +class MyAction: + text: str + +@dataclass +class MyObservation: + response: str + +class MyEnvClient(HTTPEnvClient[MyAction, MyObservation]): + def _step_payload(self, action: MyAction) -> dict: + return {"text": action.text} + + def _parse_result(self, payload: dict) -> StepResult[MyObservation]: + obs_data = payload["observation"] + return StepResult( + observation=MyObservation(**obs_data), + reward=payload.get("reward"), + done=payload.get("done", False) + ) + + def _parse_state(self, payload: dict) -> Any: + return payload + +# Use with Docker +env = MyEnvClient.from_docker_image("my-env:latest") +result = env.reset() +step_result = env.step(MyAction(text="hello")) +env.close() +``` + +### Creating an Environment Server + +```python +from openenv_core.env_server import Environment, HTTPEnvServer, create_app +from dataclasses import dataclass + +@dataclass +class MyAction: + text: str + +@dataclass +class MyObservation: + response: str + reward: float = 0.0 + done: bool = False + +class MyEnvironment(Environment): + def reset(self) -> MyObservation: + return MyObservation(response="Ready") + + def step(self, action: MyAction) -> MyObservation: + return MyObservation( + response=f"Echo: {action.text}", + reward=1.0, + done=False + ) + +# Create FastAPI app +env = MyEnvironment() +app = create_app(env, MyAction, MyObservation) + +# Run with: uvicorn module:app --host 0.0.0.0 --port 8000 +``` + +## Container Providers + +OpenEnv Core supports multiple container providers: + +### Local Docker Provider + +```python +from openenv_core.containers.runtime import LocalDockerProvider + +provider = LocalDockerProvider() +base_url = provider.start_container("my-env:latest") +provider.wait_for_ready(base_url) +# Use environment... +provider.stop_container() +``` + +### Kubernetes Provider (Coming Soon) + +```python +from openenv_core.containers.runtime import KubernetesProvider + +provider = KubernetesProvider(namespace="envs") +base_url = provider.start_container("my-env:latest") +# Use environment... +provider.stop_container() +``` + + +## API Reference + +### HTTPEnvClient + +Base class for environment clients with these abstract methods: + +- `_step_payload(action)`: Convert action to JSON +- `_parse_result(payload)`: Parse response to StepResult +- `_parse_state(payload)`: Parse state response + +### HTTPEnvServer + +Server wrapper with these methods: + +- `register_routes(app)`: Register endpoints on FastAPI app +- `_deserialize_action(data)`: Convert JSON to Action +- `_serialize_observation(obs)`: Convert Observation to JSON + +### Environment Interface + +Base interface for environment implementations: + +- `reset()`: Reset environment and return initial observation +- `step(action)`: Execute action and return observation +- `state`: Property returning current environment state + +## License + +This project is licensed under the BSD-3-Clause License - see the LICENSE file for details. + +## Contributing + +Contributions are welcome! Please see the main OpenEnv repository for contribution guidelines. + +## Links + +- **Homepage**: https://github.com/facebookresearch/OpenEnv +- **Documentation**: https://github.com/facebookresearch/OpenEnv/blob/main/README.md +- **Bug Tracker**: https://github.com/facebookresearch/OpenEnv/issues diff --git a/src/core/__init__.py b/src/core/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..a846763ddf9cbaa060c4cd79662b1904228369e0 --- /dev/null +++ b/src/core/__init__.py @@ -0,0 +1,21 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +"""Core components for agentic environments.""" + +# Re-export main components from submodules for convenience +from .env_server import * +from .client_types import StepResult +from .http_env_client import HTTPEnvClient +from .containers.runtime.uv_provider import UVProvider + +# Note: MCP module doesn't export anything yet + +__all__ = [ + "HTTPEnvClient", + "StepResult", + "UVProvider", +] diff --git a/src/core/__pycache__/__init__.cpython-313.pyc b/src/core/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..381c7c927a9be0b45e7a2cb0cad88fb32d1e0516 Binary files /dev/null and b/src/core/__pycache__/__init__.cpython-313.pyc differ diff --git a/src/core/__pycache__/client_types.cpython-313.pyc b/src/core/__pycache__/client_types.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..96821d69d87f07297462123ec9e67f7096ffdb57 Binary files /dev/null and b/src/core/__pycache__/client_types.cpython-313.pyc differ diff --git a/src/core/__pycache__/http_env_client.cpython-313.pyc b/src/core/__pycache__/http_env_client.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b2fec4e337623cb83d1e77c6ce4ad72694d9e0ff Binary files /dev/null and b/src/core/__pycache__/http_env_client.cpython-313.pyc differ diff --git a/src/core/__pycache__/hub_runner.cpython-313.pyc b/src/core/__pycache__/hub_runner.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d3d0f2435ee8ca8046a10a918bd2067ff361cae8 Binary files /dev/null and b/src/core/__pycache__/hub_runner.cpython-313.pyc differ diff --git a/src/core/client_types.py b/src/core/client_types.py new file mode 100644 index 0000000000000000000000000000000000000000..8808e96bf713e95f94d9bc7f2e743f3fee616306 --- /dev/null +++ b/src/core/client_types.py @@ -0,0 +1,22 @@ +# Type definitions for EnvTorch +from dataclasses import dataclass +from typing import Any, Generic, Optional, TypeVar + +# Generic type for observations +ObsT = TypeVar("ObsT") # TypeVar for typehinting in IDEs + + +@dataclass +class StepResult(Generic[ObsT]): + """ + Represents the result of one environment step. + + Attributes: + observation: The environment's observation after the action. + reward: Scalar reward for this step (optional). + done: Whether the episode is finished. + """ + + observation: ObsT + reward: Optional[float] = None + done: bool = False diff --git a/src/core/containers/__init__.py b/src/core/containers/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..59ce71cdfb16733e5bb941be99d412ec05a7ba7a --- /dev/null +++ b/src/core/containers/__init__.py @@ -0,0 +1,7 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +"""Container management for environment servers.""" \ No newline at end of file diff --git a/src/core/containers/__pycache__/__init__.cpython-313.pyc b/src/core/containers/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0e321e42a42dd4ef7461f86bb0a5a15bf9aaf31b Binary files /dev/null and b/src/core/containers/__pycache__/__init__.cpython-313.pyc differ diff --git a/src/core/containers/images/Dockerfile b/src/core/containers/images/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..9dd7c42a37a4285f2e5e1d43b497e1fcf04624cf --- /dev/null +++ b/src/core/containers/images/Dockerfile @@ -0,0 +1,46 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +# +# OpenEnv Base Image +# +# This is the standard base image for all OpenEnv environment servers. +# It includes the minimal dependencies needed to run HTTP environment servers. +# +# Build: docker build -t openenv-base:latest -f src/core/containers/images/Dockerfile . +# Tag: docker tag openenv-base:latest openenv-base:0.1.0 +# + +FROM python:3.11-slim + +# Set metadata +LABEL maintainer="OpenEnv Team" +LABEL description="Base image for OpenEnv based environment servers" +LABEL version="0.1.0" + +# Install system dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + curl \ + && rm -rf /var/lib/apt/lists/* + +# Install Python dependencies that all environments need +RUN pip install --no-cache-dir \ + fastapi>=0.104.0 \ + "uvicorn[standard]>=0.24.0" \ + requests>=2.25.0 \ + wsproto>=1.0.0 + +# Set working directory +WORKDIR /app + +# Default environment variables +ENV PYTHONPATH=/app/src +ENV PYTHONUNBUFFERED=1 + +# Default expose port (can be overridden) +EXPOSE 8000 + +# Note: CMD should be specified in child Dockerfiles diff --git a/src/core/containers/images/README.md b/src/core/containers/images/README.md new file mode 100644 index 0000000000000000000000000000000000000000..bc286446673987614fa5d4f15b5d1de4060b3642 --- /dev/null +++ b/src/core/containers/images/README.md @@ -0,0 +1,92 @@ +# OpenEnv Base Image + +Standard base image for all OpenEnv environment servers. + +## What's Included + +| Layer | Size | Contents | +|-------|------|----------| +| python:3.11-slim | 200 MB | Base Python runtime | +| + Dependencies | 100 MB | FastAPI, uvicorn, requests | +| **Total** | **~300 MB** | Ready for environment servers | + +## Image Sizes + +``` +openenv-base:latest 300 MB (python + fastapi + uvicorn) +``` +echo-env:latest 500 MB (python + fastapi + uvicorn + app) +coding-env:latest 520 MB (python + fastapi + uvicorn + app + tools) +another-env:latest 510 MB (python + fastapi + uvicorn + app) +--- +Total: 1.5 GB (with lots of duplication) +``` + +### With Base Images (✅ Solution) +``` +openenv-base:latest 300 MB (python + fastapi + uvicorn) +echo-env:latest 50 MB (app only, uses base) +coding-env:latest 70 MB (app + tools, uses base) +another-env:latest 45 MB (app only, uses base) +--- +Total: 465 MB (base shared, minimal duplication) +``` + +## Building the Base Image + +```bash +# From project root +docker build -t openenv-base:latest -f src/core/containers/images/Dockerfile . +``` + +## Usage in Environment Dockerfiles + +Each environment Dockerfile should start with: + +```dockerfile +FROM openenv-base:latest + +# Copy only environment-specific files +COPY src/core/ /app/src/core/ +COPY src/envs/my_env/ /app/src/envs/my_env/ + +# Run the server +CMD ["uvicorn", "envs.my_env.server.app:app", "--host", "0.0.0.0", "--port", "8000"] +``` + +## Base Image Contents + +- Python 3.11-slim +- FastAPI >= 0.104.0 +- Uvicorn >= 0.24.0 +- Requests >= 2.25.0 +- curl (for health checks) + +## Example: Building Echo Environment + +```bash +# Step 1: Build base image (do this once) +docker build -t openenv-base:latest -f src/core/containers/images/Dockerfile . + +# Step 2: Build echo environment (uses base) +docker build -t echo-env:latest -f src/envs/echo_env/server/Dockerfile . + +# Step 3: Run echo environment +docker run -p 8000:8000 echo-env:latest +``` + +## Updating the Base + +When dependencies need updating: + +1. Update `src/core/containers/images/Dockerfile` +2. Rebuild base image +3. Rebuild all environment images (they'll use new base) + +```bash +# Update base +docker build -t openenv-base:latest -f src/core/containers/images/Dockerfile . + +# Rebuild environments (they automatically use new base) +docker build -t echo-env:latest -f src/envs/echo_env/server/Dockerfile . +``` diff --git a/src/core/containers/runtime/__init__.py b/src/core/containers/runtime/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..5cc6cf494bfa22a4446c61b1ea303690afb92d93 --- /dev/null +++ b/src/core/containers/runtime/__init__.py @@ -0,0 +1,17 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +"""Container runtime providers.""" + +from .providers import ContainerProvider, KubernetesProvider, LocalDockerProvider +from .uv_provider import UVProvider + +__all__ = [ + "ContainerProvider", + "LocalDockerProvider", + "KubernetesProvider", + "UVProvider", +] \ No newline at end of file diff --git a/src/core/containers/runtime/__pycache__/__init__.cpython-313.pyc b/src/core/containers/runtime/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..96cba391381a8a5831c770074cddbc59c3a47d32 Binary files /dev/null and b/src/core/containers/runtime/__pycache__/__init__.cpython-313.pyc differ diff --git a/src/core/containers/runtime/__pycache__/providers.cpython-313.pyc b/src/core/containers/runtime/__pycache__/providers.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..04fdc10ea0598f534103baecc548be7689a6c557 Binary files /dev/null and b/src/core/containers/runtime/__pycache__/providers.cpython-313.pyc differ diff --git a/src/core/containers/runtime/__pycache__/uv_provider.cpython-313.pyc b/src/core/containers/runtime/__pycache__/uv_provider.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d80d96b378e6bc3b16571e61bf317213c3a1d88d Binary files /dev/null and b/src/core/containers/runtime/__pycache__/uv_provider.cpython-313.pyc differ diff --git a/src/core/containers/runtime/providers.py b/src/core/containers/runtime/providers.py new file mode 100644 index 0000000000000000000000000000000000000000..a8022ddcac04592044997393fb4d4427c5c43e66 --- /dev/null +++ b/src/core/containers/runtime/providers.py @@ -0,0 +1,293 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +""" +Container provider abstractions for running environment servers. + +This module provides a pluggable architecture for different container providers +(local Docker, Kubernetes, cloud providers, etc.) to be used with HTTPEnvClient. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Any, Dict, Optional + + +class ContainerProvider(ABC): + """ + Abstract base class for container providers. + + Providers implement this interface to support different container platforms: + - LocalDockerProvider: Runs containers on local Docker daemon + - KubernetesProvider: Runs containers in Kubernetes cluster + - FargateProvider: Runs containers on AWS Fargate + - CloudRunProvider: Runs containers on Google Cloud Run + + The provider manages a single container lifecycle and provides the base URL + for connecting to it. + + Example: + >>> provider = LocalDockerProvider() + >>> base_url = provider.start_container("echo-env:latest") + >>> print(base_url) # http://localhost:8000 + >>> # Use the environment via base_url + >>> provider.stop_container() + """ + + @abstractmethod + def start_container( + self, + image: str, + port: Optional[int] = None, + env_vars: Optional[Dict[str, str]] = None, + **kwargs: Any, + ) -> str: + """ + Start a container from the specified image. + + Args: + image: Container image name (e.g., "echo-env:latest") + port: Port to expose (if None, provider chooses) + env_vars: Environment variables to pass to container + **kwargs: Provider-specific options + + Returns: + Base URL to connect to the container (e.g., "http://localhost:8000") + + Raises: + RuntimeError: If container fails to start + """ + pass + + @abstractmethod + def stop_container(self) -> None: + """ + Stop and remove the running container. + + This cleans up the container that was started by start_container(). + """ + pass + + @abstractmethod + def wait_for_ready(self, base_url: str, timeout_s: float = 30.0) -> None: + """ + Wait for the container to be ready to accept requests. + + This typically polls the /health endpoint until it returns 200. + + Args: + base_url: Base URL of the container + timeout_s: Maximum time to wait + + Raises: + TimeoutError: If container doesn't become ready in time + """ + pass + + +class LocalDockerProvider(ContainerProvider): + """ + Container provider for local Docker daemon. + + This provider runs containers on the local machine using Docker. + Useful for development and testing. + + Example: + >>> provider = LocalDockerProvider() + >>> base_url = provider.start_container("echo-env:latest") + >>> # Container running on http://localhost: + >>> provider.stop_container() + """ + + def __init__(self): + """Initialize the local Docker provider.""" + self._container_id: Optional[str] = None + self._container_name: Optional[str] = None + + # Check if Docker is available + import subprocess + + try: + subprocess.run( + ["docker", "version"], + check=True, + capture_output=True, + timeout=5, + ) + except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired): + raise RuntimeError( + "Docker is not available. Please install Docker Desktop or Docker Engine." + ) + + def start_container( + self, + image: str, + port: Optional[int] = None, + env_vars: Optional[Dict[str, str]] = None, + **kwargs: Any, + ) -> str: + """ + Start a Docker container locally. + + Args: + image: Docker image name + port: Port to expose (if None, finds available port) + env_vars: Environment variables for the container + **kwargs: Additional Docker run options + + Returns: + Base URL to connect to the container + """ + import subprocess + import time + + # Find available port if not specified + if port is None: + port = self._find_available_port() + + # Generate container name + self._container_name = self._generate_container_name(image) + + # Build docker run command + cmd = [ + "docker", "run", + "-d", # Detached + "--name", self._container_name, + "-p", f"{port}:8000", # Map port + ] + + # Add environment variables + if env_vars: + for key, value in env_vars.items(): + cmd.extend(["-e", f"{key}={value}"]) + + # Add image + cmd.append(image) + + # Run container + try: + result = subprocess.run(cmd, capture_output=True, text=True, check=True) + self._container_id = result.stdout.strip() + except subprocess.CalledProcessError as e: + error_msg = f"Failed to start Docker container.\nCommand: {' '.join(cmd)}\nExit code: {e.returncode}\nStderr: {e.stderr}\nStdout: {e.stdout}" + raise RuntimeError(error_msg) from e + + # Wait a moment for container to start + time.sleep(1) + + base_url = f"http://localhost:{port}" + return base_url + + def stop_container(self) -> None: + """ + Stop and remove the Docker container. + """ + if self._container_id is None: + return + + import subprocess + + try: + # Stop container + subprocess.run( + ["docker", "stop", self._container_id], + capture_output=True, + check=True, + timeout=10, + ) + + # Remove container + subprocess.run( + ["docker", "rm", self._container_id], + capture_output=True, + check=True, + timeout=10, + ) + except subprocess.CalledProcessError: + # Container might already be stopped/removed + pass + finally: + self._container_id = None + self._container_name = None + + def wait_for_ready(self, base_url: str, timeout_s: float = 30.0) -> None: + """ + Wait for container to be ready by polling /health endpoint. + + Args: + base_url: Base URL of the container + timeout_s: Maximum time to wait + + Raises: + TimeoutError: If container doesn't become ready + """ + import time + import requests + + start_time = time.time() + health_url = f"{base_url}/health" + + while time.time() - start_time < timeout_s: + try: + response = requests.get(health_url, timeout=2.0) + if response.status_code == 200: + return + except requests.RequestException: + pass + + time.sleep(0.5) + + raise TimeoutError( + f"Container at {base_url} did not become ready within {timeout_s}s" + ) + + def _find_available_port(self) -> int: + """ + Find an available port on localhost. + + Returns: + An available port number + """ + import socket + + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("", 0)) + s.listen(1) + port = s.getsockname()[1] + return port + + def _generate_container_name(self, image: str) -> str: + """ + Generate a unique container name based on image name and timestamp. + + Args: + image: Docker image name + + Returns: + A unique container name + """ + import time + + clean_image = image.split("/")[-1].split(":")[0] + timestamp = int(time.time() * 1000) + return f"{clean_image}-{timestamp}" + + +class KubernetesProvider(ContainerProvider): + """ + Container provider for Kubernetes clusters. + + This provider creates pods in a Kubernetes cluster and exposes them + via services or port-forwarding. + + Example: + >>> provider = KubernetesProvider(namespace="envtorch-dev") + >>> base_url = provider.start_container("echo-env:latest") + >>> # Pod running in k8s, accessible via service or port-forward + >>> provider.stop_container() + """ + pass diff --git a/src/core/containers/runtime/uv_provider.py b/src/core/containers/runtime/uv_provider.py new file mode 100644 index 0000000000000000000000000000000000000000..4eb5d5c26851247f0de3f730ac642f664f323855 --- /dev/null +++ b/src/core/containers/runtime/uv_provider.py @@ -0,0 +1,183 @@ +"""Providers for launching Hugging Face Spaces via ``uv run``.""" + +from __future__ import annotations + +import os +import socket +import subprocess +import time +from dataclasses import dataclass, field +from typing import Dict, Optional + +import requests + +from .providers import ContainerProvider + + +def _poll_health(health_url: str, timeout_s: float) -> None: + """Poll a health endpoint until it returns HTTP 200 or times out.""" + + deadline = time.time() + timeout_s + while time.time() < deadline: + try: + response = requests.get(health_url, timeout=2.0) + if response.status_code == 200: + return + except requests.RequestException: + pass + + time.sleep(0.5) + + raise TimeoutError( + f"Server did not become ready within {timeout_s:.1f} seconds" + ) + + +def _create_uv_command( + space_id: str, + host: str, + port: int, + reload: bool, + project_url: Optional[str] = None, +) -> list[str]: + command = [ + "uv", + "run", + "--project", + project_url or f"git+https://huggingface.co/spaces/{space_id}", + "--", + "server", + "--host", + host, + "--port", + str(port), + ] + if reload: + command.append("--reload") + return command + + +@dataclass +class UVProvider(ContainerProvider): + """ContainerProvider implementation backed by ``uv run``.""" + + space_id: str + host: str = "0.0.0.0" + port: Optional[int] = None + reload: bool = False + project_url: Optional[str] = None + connect_host: Optional[str] = None + extra_env: Optional[Dict[str, str]] = None + context_timeout_s: float = 60.0 + + _process: subprocess.Popen | None = field(init=False, default=None) + _base_url: str | None = field(init=False, default=None) + + def start_container( + self, + image: str, + port: Optional[int] = None, + env_vars: Optional[Dict[str, str]] = None, + **_: Dict[str, str], + ) -> str: + if self._process is not None and self._process.poll() is None: + raise RuntimeError("UVProvider is already running") + + self.space_id = image or self.space_id + + bind_port = port or self.port or self._find_free_port() + + command = _create_uv_command( + self.space_id, + self.host, + bind_port, + self.reload, + project_url=self.project_url, + ) + + env = os.environ.copy() + if self.extra_env: + env.update(self.extra_env) + if env_vars: + env.update(env_vars) + + try: + self._process = subprocess.Popen(command, env=env) + except FileNotFoundError as exc: + raise RuntimeError( + "`uv` executable not found. Install uv from " + "https://github.com/astral-sh/uv and ensure it is on PATH." + ) from exc + except OSError as exc: + raise RuntimeError(f"Failed to launch `uv run`: {exc}") from exc + + client_host = self.connect_host or ( + "127.0.0.1" if self.host in {"0.0.0.0", "::"} else self.host + ) + self._base_url = f"http://{client_host}:{bind_port}" + self.port = bind_port + return self._base_url + + def wait_for_ready(self, base_url: str, timeout_s: float = 60.0) -> None: + if self._process and self._process.poll() is not None: + code = self._process.returncode + raise RuntimeError( + f"uv process exited prematurely with code {code}" + ) + + _poll_health(f"{base_url}/health", timeout_s) + + def stop_container(self) -> None: + if self._process is None: + return + + if self._process.poll() is None: + self._process.terminate() + try: + self._process.wait(timeout=10.0) + except subprocess.TimeoutExpired: + self._process.kill() + self._process.wait(timeout=5.0) + + self._process = None + self._base_url = None + + def start(self) -> str: + return self.start_container(self.space_id, port=self.port) + + def stop(self) -> None: + self.stop_container() + + def wait_for_ready_default(self, timeout_s: float | None = None) -> None: + if self._base_url is None: + raise RuntimeError("UVProvider has not been started") + self.wait_for_ready( + self._base_url, + timeout_s or self.context_timeout_s, + ) + + def close(self) -> None: + self.stop_container() + + def __enter__(self) -> "UVProvider": + if self._base_url is None: + base_url = self.start_container(self.space_id, port=self.port) + self.wait_for_ready(base_url, timeout_s=self.context_timeout_s) + return self + + def __exit__(self, exc_type, exc, tb) -> None: + self.stop_container() + + def _find_free_port(self) -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.bind(("", 0)) + sock.listen(1) + return sock.getsockname()[1] + + @property + def base_url(self) -> str: + if self._base_url is None: + raise RuntimeError("UVProvider has not been started") + return self._base_url + + diff --git a/src/core/containers/test_local_docker_provider.py b/src/core/containers/test_local_docker_provider.py new file mode 100644 index 0000000000000000000000000000000000000000..e435ff6dc56f90ecf454c1b4ebe368348e3dac1f --- /dev/null +++ b/src/core/containers/test_local_docker_provider.py @@ -0,0 +1,258 @@ +#!/usr/bin/env python3 +""" +End-to-end test for LocalDockerProvider. + +This script tests the complete flow: +1. Start a container using LocalDockerProvider +2. Wait for it to be ready +3. Make HTTP requests to test the environment +4. Clean up the container +""" + +import sys +from pathlib import Path + +# Add src to path +sys.path.insert(0, str(Path(__file__).parent.parent.parent)) + +import requests + +from core.containers.runtime import LocalDockerProvider + +# TODO: Remove this test or make it a functional test sicne this will be tested in e2e test for echo env +def test_local_docker_provider(): + """Test LocalDockerProvider end-to-end.""" + print("=" * 60) + print("LocalDockerProvider End-to-End Test") + print("=" * 60) + print() + + provider = None + + try: + # Step 1: Create provider + print("Step 1: Creating LocalDockerProvider...") + provider = LocalDockerProvider() + print("✓ Provider created\n") + + # Step 2: Start container + print("Step 2: Starting echo-env container...") + base_url = provider.start_container("echo-env:latest") + print(f"✓ Container started at: {base_url}") + if provider._container_id: + print(f" Container ID: {provider._container_id[:12]}...") + if provider._container_name: + print(f" Container name: {provider._container_name}\n") + + # Step 3: Wait for ready + print("Step 3: Waiting for container to be ready...") + provider.wait_for_ready(base_url, timeout_s=30.0) + print("✓ Container is ready!\n") + + # Step 4: Test health endpoint + print("Step 4: Testing /health endpoint...") + response = requests.get(f"{base_url}/health") + print(f" Status: {response.status_code}") + print(f" Response: {response.json()}") + assert response.status_code == 200 + assert response.json()["status"] == "healthy" + print("✓ Health check passed\n") + + # Step 5: Test reset endpoint + print("Step 5: Testing /reset endpoint...") + response = requests.post( + f"{base_url}/reset", + json={}, + headers={"Content-Type": "application/json"}, + ) + print(f" Status: {response.status_code}") + data = response.json() + print(f" Message: {data['observation']['echoed_message']}") + print(f" Reward: {data['reward']}") + print(f" Done: {data['done']}") + assert response.status_code == 200 + assert data["observation"]["echoed_message"] == "Echo environment ready!" + print("✓ Reset test passed\n") + + # Step 6: Test step endpoint + print("Step 6: Testing /step endpoint...") + response = requests.post( + f"{base_url}/step", + json={"action": {"message": "Hello from LocalDockerProvider!"}}, + headers={"Content-Type": "application/json"}, + ) + print(f" Status: {response.status_code}") + data = response.json() + print(f" Echoed: {data['observation']['echoed_message']}") + print(f" Length: {data['observation']['message_length']}") + print(f" Reward: {data['reward']}") + assert response.status_code == 200 + assert data["observation"]["echoed_message"] == "Hello from LocalDockerProvider!" + assert data["observation"]["message_length"] == 31 + print("✓ Step test passed\n") + + # Step 7: Test state endpoint + print("Step 7: Testing /state endpoint...") + response = requests.get(f"{base_url}/state") + print(f" Status: {response.status_code}") + data = response.json() + print(f" Episode ID: {data['episode_id']}") + print(f" Step count: {data['step_count']}") + assert response.status_code == 200 + assert data["step_count"] == 1 # One step from above + print("✓ State test passed\n") + + # Step 8: Multiple steps + print("Step 8: Testing multiple steps...") + for i in range(3): + response = requests.post( + f"{base_url}/step", + json={"action": {"message": f"Message {i+1}"}}, + headers={"Content-Type": "application/json"}, + ) + assert response.status_code == 200 + print(f" Step {i+1}: ✓") + + # Check state updated + response = requests.get(f"{base_url}/state") + data = response.json() + assert data["step_count"] == 4 # 1 + 3 more steps + print(f" Final step count: {data['step_count']}") + print("✓ Multiple steps test passed\n") + + print("=" * 60) + print("✓ All tests passed!") + print("=" * 60) + print() + + return True + + except Exception as e: + print(f"\n❌ Test failed: {e}") + import traceback + traceback.print_exc() + return False + + finally: + # Step 9: Cleanup + if provider is not None: + print("\nStep 9: Cleaning up container...") + try: + provider.stop_container() + print("✓ Container stopped and removed\n") + except Exception as e: + print(f"⚠️ Cleanup warning: {e}\n") + + +def test_provider_with_custom_port(): + """Test provider with custom port.""" + print("=" * 60) + print("LocalDockerProvider with Custom Port Test") + print("=" * 60) + print() + + provider = None + + try: + provider = LocalDockerProvider() + + print("Starting container on custom port 8123...") + base_url = provider.start_container("echo-env:latest", port=8123) + print(f"✓ Started at: {base_url}") + assert ":8123" in base_url + + print("Waiting for ready...") + provider.wait_for_ready(base_url) + print("✓ Ready!") + + print("Testing health...") + response = requests.get(f"{base_url}/health") + assert response.status_code == 200 + print("✓ Health check passed") + + print("\n✓ Custom port test passed!\n") + return True + + except Exception as e: + print(f"\n❌ Test failed: {e}") + return False + + finally: + if provider is not None: + provider.stop_container() + print("✓ Cleaned up\n") + + +def test_provider_with_env_vars(): + """Test provider with environment variables.""" + print("=" * 60) + print("LocalDockerProvider with Environment Variables Test") + print("=" * 60) + print() + + provider = None + + try: + provider = LocalDockerProvider() + + print("Starting container with environment variables...") + base_url = provider.start_container( + "echo-env:latest", + env_vars={"DEBUG": "true", "LOG_LEVEL": "info"} + ) + print(f"✓ Started at: {base_url}") + + print("Waiting for ready...") + provider.wait_for_ready(base_url) + print("✓ Ready!") + + print("Testing health...") + response = requests.get(f"{base_url}/health") + assert response.status_code == 200 + print("✓ Health check passed") + + print("\n✓ Environment variables test passed!\n") + return True + + except Exception as e: + print(f"\n❌ Test failed: {e}") + return False + + finally: + if provider is not None: + provider.stop_container() + print("✓ Cleaned up\n") + + +if __name__ == "__main__": + print() + print("🐳 LocalDockerProvider Test Suite") + print() + + results = [] + + # Run basic test + results.append(("Basic End-to-End", test_local_docker_provider())) + + # Run custom port test + results.append(("Custom Port", test_provider_with_custom_port())) + + # Run environment variables test + results.append(("Environment Variables", test_provider_with_env_vars())) + + # Summary + print("=" * 60) + print("Test Summary") + print("=" * 60) + for name, passed in results: + status = "✓ PASSED" if passed else "✗ FAILED" + print(f"{name:25} {status}") + print("=" * 60) + + all_passed = all(result for _, result in results) + if all_passed: + print("\n🎉 All tests passed!") + exit(0) + else: + print("\n❌ Some tests failed") + exit(1) diff --git a/src/core/core/README.md b/src/core/core/README.md new file mode 100644 index 0000000000000000000000000000000000000000..4bfb01f3fc1ab7dcd5b48b74749c68d8475f0e60 --- /dev/null +++ b/src/core/core/README.md @@ -0,0 +1,180 @@ +# image OpenEnv: Agentic Execution Environments + +An e2e framework for creating, deploying and using isolated execution environments for agentic RL training, built using Gymnasium style simple APIs. OpenEnv provides a standard for interacting with agentic execution environments via simple Gymnasium style APIs - step(), reset(), state(). Users of agentic execution environments can interact with the environment during RL training loops using these simple APIs. + +In addition to making it easier for researchers and RL framework writers, we also provide tools for environment creators making it easier for them to create richer environments and make them available over familiar protocols like HTTP and packaged using canonical technologies like docker. Environment creators can use the OpenEnv framework to create environments that are isolated, secure, and easy to deploy and use. + + +## Overview +`openenv-core` provides the foundational building blocks for creating and interacting with containerized environments over HTTP. It enables you to build agent environments that can be deployed as Docker containers and accessed via a simple HTTP API. + +> ⚠️ **Early Development Warning** OpenEnv is currently in an experimental +> stage. You should expect bugs, incomplete features, and APIs that may change +> in future versions. The project welcomes bugfixes, but to make sure things are +> well coordinated you should discuss any significant change before starting the +> work. It's recommended that you signal your intention to contribute in the +> issue tracker, either by filing a new issue or by claiming an existing one. + + +# OpenEnv Core + +Core components for OpenEnv - a framework for building HTTP-based agentic environments. + +## Features + +- **HTTPEnvClient**: Generic HTTP client for interacting with remote environments +- **HTTPEnvServer**: FastAPI-based server wrapper for exposing environments over HTTP +- **Container Providers**: Pluggable architecture for running containers (Docker, Kubernetes, etc.) +- **Type System**: Strongly-typed Action/Observation/State interfaces +- **Web Interface**: Optional web UI for interacting with environments + +## Installation + +```bash +pip install openenv-core +``` + +For development: +```bash +pip install openenv-core[dev] +``` + +## Quick Start + +### Creating an Environment Client + +```python +from openenv_core import HTTPEnvClient, StepResult +from dataclasses import dataclass + +@dataclass +class MyAction: + text: str + +@dataclass +class MyObservation: + response: str + +class MyEnvClient(HTTPEnvClient[MyAction, MyObservation]): + def _step_payload(self, action: MyAction) -> dict: + return {"text": action.text} + + def _parse_result(self, payload: dict) -> StepResult[MyObservation]: + obs_data = payload["observation"] + return StepResult( + observation=MyObservation(**obs_data), + reward=payload.get("reward"), + done=payload.get("done", False) + ) + + def _parse_state(self, payload: dict) -> Any: + return payload + +# Use with Docker +env = MyEnvClient.from_docker_image("my-env:latest") +result = env.reset() +step_result = env.step(MyAction(text="hello")) +env.close() +``` + +### Creating an Environment Server + +```python +from openenv_core.env_server import Environment, HTTPEnvServer, create_app +from dataclasses import dataclass + +@dataclass +class MyAction: + text: str + +@dataclass +class MyObservation: + response: str + reward: float = 0.0 + done: bool = False + +class MyEnvironment(Environment): + def reset(self) -> MyObservation: + return MyObservation(response="Ready") + + def step(self, action: MyAction) -> MyObservation: + return MyObservation( + response=f"Echo: {action.text}", + reward=1.0, + done=False + ) + +# Create FastAPI app +env = MyEnvironment() +app = create_app(env, MyAction, MyObservation) + +# Run with: uvicorn module:app --host 0.0.0.0 --port 8000 +``` + +## Container Providers + +OpenEnv Core supports multiple container providers: + +### Local Docker Provider + +```python +from openenv_core.containers.runtime import LocalDockerProvider + +provider = LocalDockerProvider() +base_url = provider.start_container("my-env:latest") +provider.wait_for_ready(base_url) +# Use environment... +provider.stop_container() +``` + +### Kubernetes Provider (Coming Soon) + +```python +from openenv_core.containers.runtime import KubernetesProvider + +provider = KubernetesProvider(namespace="envs") +base_url = provider.start_container("my-env:latest") +# Use environment... +provider.stop_container() +``` + + +## API Reference + +### HTTPEnvClient + +Base class for environment clients with these abstract methods: + +- `_step_payload(action)`: Convert action to JSON +- `_parse_result(payload)`: Parse response to StepResult +- `_parse_state(payload)`: Parse state response + +### HTTPEnvServer + +Server wrapper with these methods: + +- `register_routes(app)`: Register endpoints on FastAPI app +- `_deserialize_action(data)`: Convert JSON to Action +- `_serialize_observation(obs)`: Convert Observation to JSON + +### Environment Interface + +Base interface for environment implementations: + +- `reset()`: Reset environment and return initial observation +- `step(action)`: Execute action and return observation +- `state`: Property returning current environment state + +## License + +This project is licensed under the BSD-3-Clause License - see the LICENSE file for details. + +## Contributing + +Contributions are welcome! Please see the main OpenEnv repository for contribution guidelines. + +## Links + +- **Homepage**: https://github.com/facebookresearch/OpenEnv +- **Documentation**: https://github.com/facebookresearch/OpenEnv/blob/main/README.md +- **Bug Tracker**: https://github.com/facebookresearch/OpenEnv/issues diff --git a/src/core/core/__init__.py b/src/core/core/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..a846763ddf9cbaa060c4cd79662b1904228369e0 --- /dev/null +++ b/src/core/core/__init__.py @@ -0,0 +1,21 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +"""Core components for agentic environments.""" + +# Re-export main components from submodules for convenience +from .env_server import * +from .client_types import StepResult +from .http_env_client import HTTPEnvClient +from .containers.runtime.uv_provider import UVProvider + +# Note: MCP module doesn't export anything yet + +__all__ = [ + "HTTPEnvClient", + "StepResult", + "UVProvider", +] diff --git a/src/core/core/__pycache__/__init__.cpython-313.pyc b/src/core/core/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8bb5c276ee1e7010b2b3ff8dbbae91ee994d3ca3 Binary files /dev/null and b/src/core/core/__pycache__/__init__.cpython-313.pyc differ diff --git a/src/core/core/__pycache__/client_types.cpython-313.pyc b/src/core/core/__pycache__/client_types.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b47fe506698c5fc745752348b50866dac5e4b8a1 Binary files /dev/null and b/src/core/core/__pycache__/client_types.cpython-313.pyc differ diff --git a/src/core/core/__pycache__/http_env_client.cpython-313.pyc b/src/core/core/__pycache__/http_env_client.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e88830c44c9b300e7d5cc017c1321e72db1f8fa7 Binary files /dev/null and b/src/core/core/__pycache__/http_env_client.cpython-313.pyc differ diff --git a/src/core/core/client_types.py b/src/core/core/client_types.py new file mode 100644 index 0000000000000000000000000000000000000000..8808e96bf713e95f94d9bc7f2e743f3fee616306 --- /dev/null +++ b/src/core/core/client_types.py @@ -0,0 +1,22 @@ +# Type definitions for EnvTorch +from dataclasses import dataclass +from typing import Any, Generic, Optional, TypeVar + +# Generic type for observations +ObsT = TypeVar("ObsT") # TypeVar for typehinting in IDEs + + +@dataclass +class StepResult(Generic[ObsT]): + """ + Represents the result of one environment step. + + Attributes: + observation: The environment's observation after the action. + reward: Scalar reward for this step (optional). + done: Whether the episode is finished. + """ + + observation: ObsT + reward: Optional[float] = None + done: bool = False diff --git a/src/core/core/containers/__init__.py b/src/core/core/containers/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..59ce71cdfb16733e5bb941be99d412ec05a7ba7a --- /dev/null +++ b/src/core/core/containers/__init__.py @@ -0,0 +1,7 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +"""Container management for environment servers.""" \ No newline at end of file diff --git a/src/core/core/containers/__pycache__/__init__.cpython-313.pyc b/src/core/core/containers/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..680bdce733394ff009d222dc7505f963b571e03d Binary files /dev/null and b/src/core/core/containers/__pycache__/__init__.cpython-313.pyc differ diff --git a/src/core/core/containers/images/Dockerfile b/src/core/core/containers/images/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..5c8b723afd6e2695ee6342f7cf40f27dd5339075 --- /dev/null +++ b/src/core/core/containers/images/Dockerfile @@ -0,0 +1,47 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +# +# OpenEnv Base Image +# +# This is the standard base image for all OpenEnv environment servers. +# It includes the minimal dependencies needed to run HTTP environment servers. +# +# Build: docker build -t openenv-base:latest -f src/core/containers/images/Dockerfile . +# Tag: docker tag openenv-base:latest openenv-base:0.1.0 +# + +FROM python:3.11-slim + +# Set metadata +LABEL maintainer="OpenEnv Team" +LABEL description="Base image for OpenEnv based environment servers" +LABEL version="0.1.0" + +# Install system dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + curl \ + && rm -rf /var/lib/apt/lists/* + +# Install Python dependencies that all environments need +RUN pip install --no-cache-dir \ + "fastapi>=0.104.0" \ + "uvicorn[standard]>=0.24.0" \ + "requests>=2.25.0" \ + "wsproto>=1.0.0" \ + smolagents + +# Set working directory +WORKDIR /app + +# Default environment variables +ENV PYTHONPATH=/app/src +ENV PYTHONUNBUFFERED=1 + +# Default expose port (can be overridden) +EXPOSE 8000 + +# Note: CMD should be specified in child Dockerfiles diff --git a/src/core/core/containers/images/README.md b/src/core/core/containers/images/README.md new file mode 100644 index 0000000000000000000000000000000000000000..bc286446673987614fa5d4f15b5d1de4060b3642 --- /dev/null +++ b/src/core/core/containers/images/README.md @@ -0,0 +1,92 @@ +# OpenEnv Base Image + +Standard base image for all OpenEnv environment servers. + +## What's Included + +| Layer | Size | Contents | +|-------|------|----------| +| python:3.11-slim | 200 MB | Base Python runtime | +| + Dependencies | 100 MB | FastAPI, uvicorn, requests | +| **Total** | **~300 MB** | Ready for environment servers | + +## Image Sizes + +``` +openenv-base:latest 300 MB (python + fastapi + uvicorn) +``` +echo-env:latest 500 MB (python + fastapi + uvicorn + app) +coding-env:latest 520 MB (python + fastapi + uvicorn + app + tools) +another-env:latest 510 MB (python + fastapi + uvicorn + app) +--- +Total: 1.5 GB (with lots of duplication) +``` + +### With Base Images (✅ Solution) +``` +openenv-base:latest 300 MB (python + fastapi + uvicorn) +echo-env:latest 50 MB (app only, uses base) +coding-env:latest 70 MB (app + tools, uses base) +another-env:latest 45 MB (app only, uses base) +--- +Total: 465 MB (base shared, minimal duplication) +``` + +## Building the Base Image + +```bash +# From project root +docker build -t openenv-base:latest -f src/core/containers/images/Dockerfile . +``` + +## Usage in Environment Dockerfiles + +Each environment Dockerfile should start with: + +```dockerfile +FROM openenv-base:latest + +# Copy only environment-specific files +COPY src/core/ /app/src/core/ +COPY src/envs/my_env/ /app/src/envs/my_env/ + +# Run the server +CMD ["uvicorn", "envs.my_env.server.app:app", "--host", "0.0.0.0", "--port", "8000"] +``` + +## Base Image Contents + +- Python 3.11-slim +- FastAPI >= 0.104.0 +- Uvicorn >= 0.24.0 +- Requests >= 2.25.0 +- curl (for health checks) + +## Example: Building Echo Environment + +```bash +# Step 1: Build base image (do this once) +docker build -t openenv-base:latest -f src/core/containers/images/Dockerfile . + +# Step 2: Build echo environment (uses base) +docker build -t echo-env:latest -f src/envs/echo_env/server/Dockerfile . + +# Step 3: Run echo environment +docker run -p 8000:8000 echo-env:latest +``` + +## Updating the Base + +When dependencies need updating: + +1. Update `src/core/containers/images/Dockerfile` +2. Rebuild base image +3. Rebuild all environment images (they'll use new base) + +```bash +# Update base +docker build -t openenv-base:latest -f src/core/containers/images/Dockerfile . + +# Rebuild environments (they automatically use new base) +docker build -t echo-env:latest -f src/envs/echo_env/server/Dockerfile . +``` diff --git a/src/core/core/containers/runtime/__init__.py b/src/core/core/containers/runtime/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..5cc6cf494bfa22a4446c61b1ea303690afb92d93 --- /dev/null +++ b/src/core/core/containers/runtime/__init__.py @@ -0,0 +1,17 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +"""Container runtime providers.""" + +from .providers import ContainerProvider, KubernetesProvider, LocalDockerProvider +from .uv_provider import UVProvider + +__all__ = [ + "ContainerProvider", + "LocalDockerProvider", + "KubernetesProvider", + "UVProvider", +] \ No newline at end of file diff --git a/src/core/core/containers/runtime/__pycache__/__init__.cpython-313.pyc b/src/core/core/containers/runtime/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..28d4e5af5b0f8553a7e6ceab67808a2a03948c80 Binary files /dev/null and b/src/core/core/containers/runtime/__pycache__/__init__.cpython-313.pyc differ diff --git a/src/core/core/containers/runtime/__pycache__/providers.cpython-313.pyc b/src/core/core/containers/runtime/__pycache__/providers.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a23c691d2230f04c2e053b6e6f4b098650fcdd5d Binary files /dev/null and b/src/core/core/containers/runtime/__pycache__/providers.cpython-313.pyc differ diff --git a/src/core/core/containers/runtime/providers.py b/src/core/core/containers/runtime/providers.py new file mode 100644 index 0000000000000000000000000000000000000000..a8022ddcac04592044997393fb4d4427c5c43e66 --- /dev/null +++ b/src/core/core/containers/runtime/providers.py @@ -0,0 +1,293 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +""" +Container provider abstractions for running environment servers. + +This module provides a pluggable architecture for different container providers +(local Docker, Kubernetes, cloud providers, etc.) to be used with HTTPEnvClient. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Any, Dict, Optional + + +class ContainerProvider(ABC): + """ + Abstract base class for container providers. + + Providers implement this interface to support different container platforms: + - LocalDockerProvider: Runs containers on local Docker daemon + - KubernetesProvider: Runs containers in Kubernetes cluster + - FargateProvider: Runs containers on AWS Fargate + - CloudRunProvider: Runs containers on Google Cloud Run + + The provider manages a single container lifecycle and provides the base URL + for connecting to it. + + Example: + >>> provider = LocalDockerProvider() + >>> base_url = provider.start_container("echo-env:latest") + >>> print(base_url) # http://localhost:8000 + >>> # Use the environment via base_url + >>> provider.stop_container() + """ + + @abstractmethod + def start_container( + self, + image: str, + port: Optional[int] = None, + env_vars: Optional[Dict[str, str]] = None, + **kwargs: Any, + ) -> str: + """ + Start a container from the specified image. + + Args: + image: Container image name (e.g., "echo-env:latest") + port: Port to expose (if None, provider chooses) + env_vars: Environment variables to pass to container + **kwargs: Provider-specific options + + Returns: + Base URL to connect to the container (e.g., "http://localhost:8000") + + Raises: + RuntimeError: If container fails to start + """ + pass + + @abstractmethod + def stop_container(self) -> None: + """ + Stop and remove the running container. + + This cleans up the container that was started by start_container(). + """ + pass + + @abstractmethod + def wait_for_ready(self, base_url: str, timeout_s: float = 30.0) -> None: + """ + Wait for the container to be ready to accept requests. + + This typically polls the /health endpoint until it returns 200. + + Args: + base_url: Base URL of the container + timeout_s: Maximum time to wait + + Raises: + TimeoutError: If container doesn't become ready in time + """ + pass + + +class LocalDockerProvider(ContainerProvider): + """ + Container provider for local Docker daemon. + + This provider runs containers on the local machine using Docker. + Useful for development and testing. + + Example: + >>> provider = LocalDockerProvider() + >>> base_url = provider.start_container("echo-env:latest") + >>> # Container running on http://localhost: + >>> provider.stop_container() + """ + + def __init__(self): + """Initialize the local Docker provider.""" + self._container_id: Optional[str] = None + self._container_name: Optional[str] = None + + # Check if Docker is available + import subprocess + + try: + subprocess.run( + ["docker", "version"], + check=True, + capture_output=True, + timeout=5, + ) + except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired): + raise RuntimeError( + "Docker is not available. Please install Docker Desktop or Docker Engine." + ) + + def start_container( + self, + image: str, + port: Optional[int] = None, + env_vars: Optional[Dict[str, str]] = None, + **kwargs: Any, + ) -> str: + """ + Start a Docker container locally. + + Args: + image: Docker image name + port: Port to expose (if None, finds available port) + env_vars: Environment variables for the container + **kwargs: Additional Docker run options + + Returns: + Base URL to connect to the container + """ + import subprocess + import time + + # Find available port if not specified + if port is None: + port = self._find_available_port() + + # Generate container name + self._container_name = self._generate_container_name(image) + + # Build docker run command + cmd = [ + "docker", "run", + "-d", # Detached + "--name", self._container_name, + "-p", f"{port}:8000", # Map port + ] + + # Add environment variables + if env_vars: + for key, value in env_vars.items(): + cmd.extend(["-e", f"{key}={value}"]) + + # Add image + cmd.append(image) + + # Run container + try: + result = subprocess.run(cmd, capture_output=True, text=True, check=True) + self._container_id = result.stdout.strip() + except subprocess.CalledProcessError as e: + error_msg = f"Failed to start Docker container.\nCommand: {' '.join(cmd)}\nExit code: {e.returncode}\nStderr: {e.stderr}\nStdout: {e.stdout}" + raise RuntimeError(error_msg) from e + + # Wait a moment for container to start + time.sleep(1) + + base_url = f"http://localhost:{port}" + return base_url + + def stop_container(self) -> None: + """ + Stop and remove the Docker container. + """ + if self._container_id is None: + return + + import subprocess + + try: + # Stop container + subprocess.run( + ["docker", "stop", self._container_id], + capture_output=True, + check=True, + timeout=10, + ) + + # Remove container + subprocess.run( + ["docker", "rm", self._container_id], + capture_output=True, + check=True, + timeout=10, + ) + except subprocess.CalledProcessError: + # Container might already be stopped/removed + pass + finally: + self._container_id = None + self._container_name = None + + def wait_for_ready(self, base_url: str, timeout_s: float = 30.0) -> None: + """ + Wait for container to be ready by polling /health endpoint. + + Args: + base_url: Base URL of the container + timeout_s: Maximum time to wait + + Raises: + TimeoutError: If container doesn't become ready + """ + import time + import requests + + start_time = time.time() + health_url = f"{base_url}/health" + + while time.time() - start_time < timeout_s: + try: + response = requests.get(health_url, timeout=2.0) + if response.status_code == 200: + return + except requests.RequestException: + pass + + time.sleep(0.5) + + raise TimeoutError( + f"Container at {base_url} did not become ready within {timeout_s}s" + ) + + def _find_available_port(self) -> int: + """ + Find an available port on localhost. + + Returns: + An available port number + """ + import socket + + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("", 0)) + s.listen(1) + port = s.getsockname()[1] + return port + + def _generate_container_name(self, image: str) -> str: + """ + Generate a unique container name based on image name and timestamp. + + Args: + image: Docker image name + + Returns: + A unique container name + """ + import time + + clean_image = image.split("/")[-1].split(":")[0] + timestamp = int(time.time() * 1000) + return f"{clean_image}-{timestamp}" + + +class KubernetesProvider(ContainerProvider): + """ + Container provider for Kubernetes clusters. + + This provider creates pods in a Kubernetes cluster and exposes them + via services or port-forwarding. + + Example: + >>> provider = KubernetesProvider(namespace="envtorch-dev") + >>> base_url = provider.start_container("echo-env:latest") + >>> # Pod running in k8s, accessible via service or port-forward + >>> provider.stop_container() + """ + pass diff --git a/src/core/core/containers/runtime/uv_provider.py b/src/core/core/containers/runtime/uv_provider.py new file mode 100644 index 0000000000000000000000000000000000000000..4eb5d5c26851247f0de3f730ac642f664f323855 --- /dev/null +++ b/src/core/core/containers/runtime/uv_provider.py @@ -0,0 +1,183 @@ +"""Providers for launching Hugging Face Spaces via ``uv run``.""" + +from __future__ import annotations + +import os +import socket +import subprocess +import time +from dataclasses import dataclass, field +from typing import Dict, Optional + +import requests + +from .providers import ContainerProvider + + +def _poll_health(health_url: str, timeout_s: float) -> None: + """Poll a health endpoint until it returns HTTP 200 or times out.""" + + deadline = time.time() + timeout_s + while time.time() < deadline: + try: + response = requests.get(health_url, timeout=2.0) + if response.status_code == 200: + return + except requests.RequestException: + pass + + time.sleep(0.5) + + raise TimeoutError( + f"Server did not become ready within {timeout_s:.1f} seconds" + ) + + +def _create_uv_command( + space_id: str, + host: str, + port: int, + reload: bool, + project_url: Optional[str] = None, +) -> list[str]: + command = [ + "uv", + "run", + "--project", + project_url or f"git+https://huggingface.co/spaces/{space_id}", + "--", + "server", + "--host", + host, + "--port", + str(port), + ] + if reload: + command.append("--reload") + return command + + +@dataclass +class UVProvider(ContainerProvider): + """ContainerProvider implementation backed by ``uv run``.""" + + space_id: str + host: str = "0.0.0.0" + port: Optional[int] = None + reload: bool = False + project_url: Optional[str] = None + connect_host: Optional[str] = None + extra_env: Optional[Dict[str, str]] = None + context_timeout_s: float = 60.0 + + _process: subprocess.Popen | None = field(init=False, default=None) + _base_url: str | None = field(init=False, default=None) + + def start_container( + self, + image: str, + port: Optional[int] = None, + env_vars: Optional[Dict[str, str]] = None, + **_: Dict[str, str], + ) -> str: + if self._process is not None and self._process.poll() is None: + raise RuntimeError("UVProvider is already running") + + self.space_id = image or self.space_id + + bind_port = port or self.port or self._find_free_port() + + command = _create_uv_command( + self.space_id, + self.host, + bind_port, + self.reload, + project_url=self.project_url, + ) + + env = os.environ.copy() + if self.extra_env: + env.update(self.extra_env) + if env_vars: + env.update(env_vars) + + try: + self._process = subprocess.Popen(command, env=env) + except FileNotFoundError as exc: + raise RuntimeError( + "`uv` executable not found. Install uv from " + "https://github.com/astral-sh/uv and ensure it is on PATH." + ) from exc + except OSError as exc: + raise RuntimeError(f"Failed to launch `uv run`: {exc}") from exc + + client_host = self.connect_host or ( + "127.0.0.1" if self.host in {"0.0.0.0", "::"} else self.host + ) + self._base_url = f"http://{client_host}:{bind_port}" + self.port = bind_port + return self._base_url + + def wait_for_ready(self, base_url: str, timeout_s: float = 60.0) -> None: + if self._process and self._process.poll() is not None: + code = self._process.returncode + raise RuntimeError( + f"uv process exited prematurely with code {code}" + ) + + _poll_health(f"{base_url}/health", timeout_s) + + def stop_container(self) -> None: + if self._process is None: + return + + if self._process.poll() is None: + self._process.terminate() + try: + self._process.wait(timeout=10.0) + except subprocess.TimeoutExpired: + self._process.kill() + self._process.wait(timeout=5.0) + + self._process = None + self._base_url = None + + def start(self) -> str: + return self.start_container(self.space_id, port=self.port) + + def stop(self) -> None: + self.stop_container() + + def wait_for_ready_default(self, timeout_s: float | None = None) -> None: + if self._base_url is None: + raise RuntimeError("UVProvider has not been started") + self.wait_for_ready( + self._base_url, + timeout_s or self.context_timeout_s, + ) + + def close(self) -> None: + self.stop_container() + + def __enter__(self) -> "UVProvider": + if self._base_url is None: + base_url = self.start_container(self.space_id, port=self.port) + self.wait_for_ready(base_url, timeout_s=self.context_timeout_s) + return self + + def __exit__(self, exc_type, exc, tb) -> None: + self.stop_container() + + def _find_free_port(self) -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.bind(("", 0)) + sock.listen(1) + return sock.getsockname()[1] + + @property + def base_url(self) -> str: + if self._base_url is None: + raise RuntimeError("UVProvider has not been started") + return self._base_url + + diff --git a/src/core/core/containers/test_local_docker_provider.py b/src/core/core/containers/test_local_docker_provider.py new file mode 100644 index 0000000000000000000000000000000000000000..e435ff6dc56f90ecf454c1b4ebe368348e3dac1f --- /dev/null +++ b/src/core/core/containers/test_local_docker_provider.py @@ -0,0 +1,258 @@ +#!/usr/bin/env python3 +""" +End-to-end test for LocalDockerProvider. + +This script tests the complete flow: +1. Start a container using LocalDockerProvider +2. Wait for it to be ready +3. Make HTTP requests to test the environment +4. Clean up the container +""" + +import sys +from pathlib import Path + +# Add src to path +sys.path.insert(0, str(Path(__file__).parent.parent.parent)) + +import requests + +from core.containers.runtime import LocalDockerProvider + +# TODO: Remove this test or make it a functional test sicne this will be tested in e2e test for echo env +def test_local_docker_provider(): + """Test LocalDockerProvider end-to-end.""" + print("=" * 60) + print("LocalDockerProvider End-to-End Test") + print("=" * 60) + print() + + provider = None + + try: + # Step 1: Create provider + print("Step 1: Creating LocalDockerProvider...") + provider = LocalDockerProvider() + print("✓ Provider created\n") + + # Step 2: Start container + print("Step 2: Starting echo-env container...") + base_url = provider.start_container("echo-env:latest") + print(f"✓ Container started at: {base_url}") + if provider._container_id: + print(f" Container ID: {provider._container_id[:12]}...") + if provider._container_name: + print(f" Container name: {provider._container_name}\n") + + # Step 3: Wait for ready + print("Step 3: Waiting for container to be ready...") + provider.wait_for_ready(base_url, timeout_s=30.0) + print("✓ Container is ready!\n") + + # Step 4: Test health endpoint + print("Step 4: Testing /health endpoint...") + response = requests.get(f"{base_url}/health") + print(f" Status: {response.status_code}") + print(f" Response: {response.json()}") + assert response.status_code == 200 + assert response.json()["status"] == "healthy" + print("✓ Health check passed\n") + + # Step 5: Test reset endpoint + print("Step 5: Testing /reset endpoint...") + response = requests.post( + f"{base_url}/reset", + json={}, + headers={"Content-Type": "application/json"}, + ) + print(f" Status: {response.status_code}") + data = response.json() + print(f" Message: {data['observation']['echoed_message']}") + print(f" Reward: {data['reward']}") + print(f" Done: {data['done']}") + assert response.status_code == 200 + assert data["observation"]["echoed_message"] == "Echo environment ready!" + print("✓ Reset test passed\n") + + # Step 6: Test step endpoint + print("Step 6: Testing /step endpoint...") + response = requests.post( + f"{base_url}/step", + json={"action": {"message": "Hello from LocalDockerProvider!"}}, + headers={"Content-Type": "application/json"}, + ) + print(f" Status: {response.status_code}") + data = response.json() + print(f" Echoed: {data['observation']['echoed_message']}") + print(f" Length: {data['observation']['message_length']}") + print(f" Reward: {data['reward']}") + assert response.status_code == 200 + assert data["observation"]["echoed_message"] == "Hello from LocalDockerProvider!" + assert data["observation"]["message_length"] == 31 + print("✓ Step test passed\n") + + # Step 7: Test state endpoint + print("Step 7: Testing /state endpoint...") + response = requests.get(f"{base_url}/state") + print(f" Status: {response.status_code}") + data = response.json() + print(f" Episode ID: {data['episode_id']}") + print(f" Step count: {data['step_count']}") + assert response.status_code == 200 + assert data["step_count"] == 1 # One step from above + print("✓ State test passed\n") + + # Step 8: Multiple steps + print("Step 8: Testing multiple steps...") + for i in range(3): + response = requests.post( + f"{base_url}/step", + json={"action": {"message": f"Message {i+1}"}}, + headers={"Content-Type": "application/json"}, + ) + assert response.status_code == 200 + print(f" Step {i+1}: ✓") + + # Check state updated + response = requests.get(f"{base_url}/state") + data = response.json() + assert data["step_count"] == 4 # 1 + 3 more steps + print(f" Final step count: {data['step_count']}") + print("✓ Multiple steps test passed\n") + + print("=" * 60) + print("✓ All tests passed!") + print("=" * 60) + print() + + return True + + except Exception as e: + print(f"\n❌ Test failed: {e}") + import traceback + traceback.print_exc() + return False + + finally: + # Step 9: Cleanup + if provider is not None: + print("\nStep 9: Cleaning up container...") + try: + provider.stop_container() + print("✓ Container stopped and removed\n") + except Exception as e: + print(f"⚠️ Cleanup warning: {e}\n") + + +def test_provider_with_custom_port(): + """Test provider with custom port.""" + print("=" * 60) + print("LocalDockerProvider with Custom Port Test") + print("=" * 60) + print() + + provider = None + + try: + provider = LocalDockerProvider() + + print("Starting container on custom port 8123...") + base_url = provider.start_container("echo-env:latest", port=8123) + print(f"✓ Started at: {base_url}") + assert ":8123" in base_url + + print("Waiting for ready...") + provider.wait_for_ready(base_url) + print("✓ Ready!") + + print("Testing health...") + response = requests.get(f"{base_url}/health") + assert response.status_code == 200 + print("✓ Health check passed") + + print("\n✓ Custom port test passed!\n") + return True + + except Exception as e: + print(f"\n❌ Test failed: {e}") + return False + + finally: + if provider is not None: + provider.stop_container() + print("✓ Cleaned up\n") + + +def test_provider_with_env_vars(): + """Test provider with environment variables.""" + print("=" * 60) + print("LocalDockerProvider with Environment Variables Test") + print("=" * 60) + print() + + provider = None + + try: + provider = LocalDockerProvider() + + print("Starting container with environment variables...") + base_url = provider.start_container( + "echo-env:latest", + env_vars={"DEBUG": "true", "LOG_LEVEL": "info"} + ) + print(f"✓ Started at: {base_url}") + + print("Waiting for ready...") + provider.wait_for_ready(base_url) + print("✓ Ready!") + + print("Testing health...") + response = requests.get(f"{base_url}/health") + assert response.status_code == 200 + print("✓ Health check passed") + + print("\n✓ Environment variables test passed!\n") + return True + + except Exception as e: + print(f"\n❌ Test failed: {e}") + return False + + finally: + if provider is not None: + provider.stop_container() + print("✓ Cleaned up\n") + + +if __name__ == "__main__": + print() + print("🐳 LocalDockerProvider Test Suite") + print() + + results = [] + + # Run basic test + results.append(("Basic End-to-End", test_local_docker_provider())) + + # Run custom port test + results.append(("Custom Port", test_provider_with_custom_port())) + + # Run environment variables test + results.append(("Environment Variables", test_provider_with_env_vars())) + + # Summary + print("=" * 60) + print("Test Summary") + print("=" * 60) + for name, passed in results: + status = "✓ PASSED" if passed else "✗ FAILED" + print(f"{name:25} {status}") + print("=" * 60) + + all_passed = all(result for _, result in results) + if all_passed: + print("\n🎉 All tests passed!") + exit(0) + else: + print("\n❌ Some tests failed") + exit(1) diff --git a/src/core/core/env_server/__init__.py b/src/core/core/env_server/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..79e66535f0e74ae18d84181a234365fca8f3ffc1 --- /dev/null +++ b/src/core/core/env_server/__init__.py @@ -0,0 +1,35 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +"""Core environment interfaces and types.""" + +from .base_transforms import CompositeTransform, NullTransform +from .http_server import HTTPEnvServer, create_app, create_fastapi_app +from .interfaces import Environment, Message, ModelTokenizer, Transform +from .types import Action, Observation, State +from .web_interface import create_web_interface_app, WebInterfaceManager + +__all__ = [ + # Core interfaces + "Environment", + "Transform", + "Message", + "ModelTokenizer", + # Types + "Action", + "Observation", + "State", + # Base transforms + "CompositeTransform", + "NullTransform", + # HTTP Server + "HTTPEnvServer", + "create_app", + "create_fastapi_app", + # Web Interface + "create_web_interface_app", + "WebInterfaceManager", +] diff --git a/src/core/core/env_server/__pycache__/__init__.cpython-313.pyc b/src/core/core/env_server/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e1d6e83bb7d5118f8e7d96a9e4865b54c0b580f2 Binary files /dev/null and b/src/core/core/env_server/__pycache__/__init__.cpython-313.pyc differ diff --git a/src/core/core/env_server/__pycache__/base_transforms.cpython-313.pyc b/src/core/core/env_server/__pycache__/base_transforms.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9e2a93a6cba895e6173a6cbc77bb55ffd5b70350 Binary files /dev/null and b/src/core/core/env_server/__pycache__/base_transforms.cpython-313.pyc differ diff --git a/src/core/core/env_server/__pycache__/http_server.cpython-313.pyc b/src/core/core/env_server/__pycache__/http_server.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1d4d3e3e351c689df6ea7e8206c6146bc6903c04 Binary files /dev/null and b/src/core/core/env_server/__pycache__/http_server.cpython-313.pyc differ diff --git a/src/core/core/env_server/__pycache__/interfaces.cpython-313.pyc b/src/core/core/env_server/__pycache__/interfaces.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c5c647a54a7b1f528d9447ff22d44d2ae3be7b2e Binary files /dev/null and b/src/core/core/env_server/__pycache__/interfaces.cpython-313.pyc differ diff --git a/src/core/core/env_server/__pycache__/types.cpython-313.pyc b/src/core/core/env_server/__pycache__/types.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d7cf0cd6e6efe8b78376d736e97f28b8a60561fa Binary files /dev/null and b/src/core/core/env_server/__pycache__/types.cpython-313.pyc differ diff --git a/src/core/core/env_server/__pycache__/web_interface.cpython-313.pyc b/src/core/core/env_server/__pycache__/web_interface.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c54d26445717ecb1576c8cfde46af93d6203076a Binary files /dev/null and b/src/core/core/env_server/__pycache__/web_interface.cpython-313.pyc differ diff --git a/src/core/core/env_server/base_transforms.py b/src/core/core/env_server/base_transforms.py new file mode 100644 index 0000000000000000000000000000000000000000..d8165e3d77ba23bbd4c765e46cc38bc6c475ad4c --- /dev/null +++ b/src/core/core/env_server/base_transforms.py @@ -0,0 +1,29 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +"""Base transform implementations for composing environment-specific transforms.""" + +from .interfaces import Transform +from .types import Observation + + +class CompositeTransform(Transform): + """Combines multiple transforms into a single transform.""" + + def __init__(self, transforms: list[Transform]): + self.transforms = transforms + + def __call__(self, observation: Observation) -> Observation: + for transform in self.transforms: + observation = transform(observation) + return observation + + +class NullTransform(Transform): + """Default transform that passes through unchanged.""" + + def __call__(self, observation: Observation) -> Observation: + return observation \ No newline at end of file diff --git a/src/core/core/env_server/http_server.py b/src/core/core/env_server/http_server.py new file mode 100644 index 0000000000000000000000000000000000000000..d18873f0126d8c9198ac24f7308c6a358ccd9435 --- /dev/null +++ b/src/core/core/env_server/http_server.py @@ -0,0 +1,233 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +""" +HTTP server wrapper for Environment instances. + +This module provides utilities to wrap any Environment subclass and expose it +over HTTP endpoints that HTTPEnvClient can consume. +""" + +from __future__ import annotations + +import os +from dataclasses import asdict +from typing import Any, Dict, Type + +from .interfaces import Environment +from .types import Action, Observation +from fastapi import Body, FastAPI + +class HTTPEnvServer: + """ + HTTP server wrapper for Environment instances. + + This class wraps an Environment and exposes its reset(), step(), and state + methods as HTTP endpoints compatible with HTTPEnvClient. + + The server expects: + - Action deserialization: Converts JSON dict to Action subclass + - Observation serialization: Converts Observation subclass to JSON dict + + Example: + >>> from core.env_server import HTTPEnvServer + >>> from envs.coding_env.server import CodeExecutionEnvironment + >>> + >>> env = CodeExecutionEnvironment() + >>> server = HTTPEnvServer(env) + >>> + >>> # Register routes with FastAPI + >>> from fastapi import FastAPI + >>> app = FastAPI() + >>> server.register_routes(app) + """ + + def __init__( + self, + env: Environment, + action_cls: Type[Action], + observation_cls: Type[Observation], + ): + """ + Initialize HTTP server wrapper. + + Args: + env: The Environment instance to wrap + action_cls: The Action subclass this environment expects + observation_cls: The Observation subclass this environment returns + """ + self.env = env + self.action_cls = action_cls + self.observation_cls = observation_cls + + def register_routes(self, app: Any) -> None: + """ + Register HTTP routes on a FastAPI application. + + Args: + app: FastAPI application instance + """ + + if not isinstance(app, FastAPI): + raise TypeError("app must be a FastAPI instance") + + @app.post("/reset") + async def reset(request: Dict[str, Any] = Body(default={})) -> Dict[str, Any]: + """Reset endpoint - returns initial observation.""" + # TODO: Handle seed, episode_id from request if provided + observation = self.env.reset() + return self._serialize_observation(observation) + + @app.post("/step") + async def step(request: Dict[str, Any]) -> Dict[str, Any]: + """Step endpoint - executes action and returns observation.""" + action_data = request.get("action", {}) + # TODO: Handle timeout_s, request_id, episode_id from request if provided + + # Deserialize action + action = self._deserialize_action(action_data) + + # Execute step + observation = self.env.step(action) + + # Return serialized observation + return self._serialize_observation(observation) + + @app.get("/state") + async def get_state() -> Dict[str, Any]: + """State endpoint - returns current environment state.""" + state = self.env.state + return asdict(state) + + @app.get("/health") + async def health() -> Dict[str, str]: + """Health check endpoint.""" + return {"status": "healthy"} + + + def _deserialize_action(self, action_data: Dict[str, Any]) -> Action: + """ + Convert JSON dict to Action instance. + + Args: + action_data: Dictionary containing action data + + Returns: + Action instance + + Note: + This is a simple implementation. Subclasses may need to override + for more complex deserialization logic. + """ + # Remove metadata if present (it will be set via kw_only field) + metadata = action_data.pop("metadata", {}) + action = self.action_cls(**action_data) + action.metadata = metadata + return action + + def _serialize_observation(self, observation: Observation) -> Dict[str, Any]: + """ + Convert Observation instance to JSON-compatible dict. + + Args: + observation: Observation instance + + Returns: + Dictionary compatible with HTTPEnvClient._parse_result() + + The format matches what HTTPEnvClient expects: + { + "observation": {...}, # Observation fields + "reward": float | None, + "done": bool, + } + """ + obs_dict = asdict(observation) + + # Extract reward and done (these are part of StepResult on client side) + reward = obs_dict.pop("reward", None) + done = obs_dict.pop("done", False) + obs_dict.pop("metadata", None) # Remove metadata from observation + + # Return in HTTPEnvClient expected format + return { + "observation": obs_dict, + "reward": reward, + "done": done, + } + +def create_app( + env: Environment, + action_cls: Type[Action], + observation_cls: Type[Observation], + env_name: Optional[str] = None, +) -> Any: + """ + Create a FastAPI application with or without web interface. + + This function creates a FastAPI app with the web interface enabled by default, + including README integration for better user experience. + + Args: + env: The Environment instance to serve + action_cls: The Action subclass this environment expects + observation_cls: The Observation subclass this environment returns + env_name: Optional environment name for README loading + + Returns: + FastAPI application instance with or without web interface and README integration + """ + # Check if web interface should be enabled + # This can be controlled via environment variable or build argument + enable_web = ( + os.getenv("ENABLE_WEB_INTERFACE", "false").lower() in ("true", "1", "yes") + ) + + if enable_web: + # Import web interface only when needed + from .web_interface import create_web_interface_app + return create_web_interface_app(env, action_cls, observation_cls, env_name) + else: + # Use standard FastAPI app without web interface + return create_fastapi_app(env, action_cls, observation_cls) + + +def create_fastapi_app( + env: Environment, + action_cls: Type[Action], + observation_cls: Type[Observation], +) -> Any: + """ + Create a FastAPI application with routes for the given environment. + + Args: + env: The Environment instance to serve + action_cls: The Action subclass this environment expects + observation_cls: The Observation subclass this environment returns + + Returns: + FastAPI application instance with routes registered + + Example: + >>> from envs.coding_env.server import CodeExecutionEnvironment + >>> from envs.coding_env.models import CodeAction, CodeObservation + >>> + >>> env = CodeExecutionEnvironment() + >>> app = create_fastapi_app(env, CodeAction, CodeObservation) + >>> + >>> # Run with: uvicorn module:app --host 0.0.0.0 --port 8000 + """ + try: + from fastapi import FastAPI + except ImportError: + raise ImportError( + "FastAPI is required. Install with: pip install fastapi uvicorn" + ) + + app = FastAPI(title="Environment HTTP Server") + server = HTTPEnvServer(env, action_cls, observation_cls) + server.register_routes(app) + return app diff --git a/src/core/core/env_server/interfaces.py b/src/core/core/env_server/interfaces.py new file mode 100644 index 0000000000000000000000000000000000000000..caa2d76db1b079f0c15277d1dac2db44e4173ac9 --- /dev/null +++ b/src/core/core/env_server/interfaces.py @@ -0,0 +1,118 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +from abc import ABC, abstractmethod +from typing import Any, Protocol, TypedDict + +from .types import Action, Observation, State + + +class Message(TypedDict): + """A message in a conversation. + + Compatible with Huggingface chat template format. + """ + + role: str + content: str + + +class ModelTokenizer(Protocol): + """Protocol for tokenizers that support chat templates. + + This protocol defines the interface that tokenizers must implement + to work with chat-based environments. It's compatible with + Huggingface transformers tokenizers. + """ + + def apply_chat_template( + self, + conversation: list[Message], + tokenize: bool = True, + return_tensors: str | None = None, + **kwargs: Any, + ) -> Any: + """Apply a chat template to format and optionally tokenize a conversation. + + Args: + conversation: List of message dictionaries with 'role' and 'content' + tokenize: Whether to tokenize the output + return_tensors: Format for returned tensors ('pt' for PyTorch) + **kwargs: Additional arguments + + Returns: + Formatted and optionally tokenized conversation + """ + ... + + def decode( + self, token_ids: Any, skip_special_tokens: bool = False, **kwargs: Any + ) -> str: + """Decode token IDs back to text. + + Args: + token_ids: Token IDs to decode + skip_special_tokens: Whether to skip special tokens in output + **kwargs: Additional arguments + + Returns: + Decoded text string + """ + ... + + +class Transform(ABC): + """Transform observations to add rewards, metrics, or other modifications. + + Transforms follow the TorchRL pattern where they take an observation + and return a (potentially modified) observation. This allows for + flexible reward computation and observation augmentation. + """ + + @abstractmethod + def __call__(self, observation: Observation) -> Observation: + """Transform an observation. + + Args: + observation: The input observation + + Returns: + The transformed observation + """ + pass + + +class Environment(ABC): + """Base class for all environment servers following Gym/Gymnasium API. + + Args: + transform: Optional transform to apply to observations + """ + + def __init__(self, transform: Transform | None = None): + self.transform = transform + + @abstractmethod + def reset(self) -> Observation: + """Reset the environment and return initial observation.""" + pass + + @abstractmethod + def step(self, action: Action) -> Observation: + """Take a step in the environment.""" + pass + + @property + @abstractmethod + def state(self) -> State: + """Get the current environment state.""" + pass + + def _apply_transform(self, observation: Observation) -> Observation: + """Apply transform if one is provided.""" + if self.transform is not None: + return self.transform(observation) + return observation diff --git a/src/core/core/env_server/types.py b/src/core/core/env_server/types.py new file mode 100644 index 0000000000000000000000000000000000000000..70da9f3ca2257ba6c27fb95a58db0a6ec37ccf3e --- /dev/null +++ b/src/core/core/env_server/types.py @@ -0,0 +1,57 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional, Union + + +# Type aliases +Scalar = Union[int, float, bool] + + +@dataclass(kw_only=True) +class Action: + """Base class for all environment actions.""" + + metadata: Dict[str, Any] = field(default_factory=dict) + + +@dataclass(kw_only=True) +class Observation: + """Base class for all environment observations.""" + + done: bool = False + reward: Union[bool, int, float, None] = None + metadata: Dict[str, Any] = field(default_factory=dict) + + +@dataclass +class State: + """Base class for environment state.""" + + episode_id: Optional[str] = None + step_count: int = 0 + + +@dataclass +class CodeExecResult: + """Result of code execution containing stdout, stderr, and exit code.""" + + stdout: str + stderr: str + exit_code: int + + +@dataclass +class EnvironmentMetadata: + """Metadata about an environment for documentation and UI purposes.""" + + name: str + description: str + readme_content: Optional[str] = None + version: Optional[str] = None + author: Optional[str] = None + documentation_url: Optional[str] = None diff --git a/src/core/core/env_server/web_interface.py b/src/core/core/env_server/web_interface.py new file mode 100644 index 0000000000000000000000000000000000000000..3c36aa1de336f86161429f8a382054ec89db1912 --- /dev/null +++ b/src/core/core/env_server/web_interface.py @@ -0,0 +1,1613 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +""" +Web interface for OpenEnv environments. + +This module provides a web-based interface for interacting with OpenEnv environments, +including a two-pane layout for HumanAgent interaction and state observation. +""" + +from __future__ import annotations + +import json +import time +from dataclasses import asdict, dataclass +from typing import Any, Dict, List, Optional, Type +from datetime import datetime + +from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Request +from fastapi.responses import HTMLResponse, FileResponse +from fastapi.staticfiles import StaticFiles +from pydantic import BaseModel + +from .interfaces import Environment +from .types import Action, Observation, State, EnvironmentMetadata + + +def load_environment_metadata(env: Environment, env_name: Optional[str] = None) -> EnvironmentMetadata: + """ + Load environment metadata including README content. + + Args: + env: The environment instance + env_name: Optional environment name for README file lookup + + Returns: + EnvironmentMetadata with loaded information + """ + # Try to get metadata from environment if it has a method for it + if hasattr(env, 'get_metadata'): + return env.get_metadata() + + # Default metadata + metadata = EnvironmentMetadata( + name=env_name or env.__class__.__name__, + description=f"{env.__class__.__name__} environment", + version="1.0.0" + ) + + # Try to load README from file system + readme_content = _load_readme_from_filesystem(env_name) + if readme_content: + metadata.readme_content = readme_content + + return metadata + + +def _load_readme_from_filesystem(env_name: Optional[str]) -> Optional[str]: + """ + Load README content from the filesystem. + + Tries multiple locations: + 1. Container filesystem: /app/README.md + 2. Local development: src/envs/{env_name}/README.md + 3. Environment variable: ENV_README_PATH + """ + import os + from pathlib import Path + + # Try container filesystem first + container_readme = Path("/app/README.md") + if container_readme.exists(): + try: + return container_readme.read_text(encoding='utf-8') + except Exception: + pass + + # Try environment variable path + custom_path = os.environ.get("ENV_README_PATH") + if custom_path and Path(custom_path).exists(): + try: + return Path(custom_path).read_text(encoding='utf-8') + except Exception: + pass + + # Try local development path + if env_name: + local_readme = Path(f"src/envs/{env_name}/README.md") + if local_readme.exists(): + try: + return local_readme.read_text(encoding='utf-8') + except Exception: + pass + + return None + + +@dataclass +class ActionLog: + """Log entry for an action taken.""" + timestamp: str + action: Dict[str, Any] + observation: Dict[str, Any] + reward: Optional[float] + done: bool + step_count: int + + +@dataclass +class EpisodeState: + """Current episode state for the web interface.""" + episode_id: Optional[str] + step_count: int + current_observation: Optional[Dict[str, Any]] + action_logs: List[ActionLog] + is_reset: bool = True + + +class WebInterfaceManager: + """Manages the web interface for an environment.""" + + def __init__( + self, + env: Environment, + action_cls: Type[Action], + observation_cls: Type[Observation], + metadata: Optional[EnvironmentMetadata] = None, + ): + self.env = env + self.action_cls = action_cls + self.observation_cls = observation_cls + self.metadata = metadata or EnvironmentMetadata( + name=env.__class__.__name__, + description=f"{env.__class__.__name__} environment" + ) + self.episode_state = EpisodeState( + episode_id=None, + step_count=0, + current_observation=None, + action_logs=[] + ) + self.connected_clients: List[WebSocket] = [] + + async def connect_websocket(self, websocket: WebSocket): + """Connect a new WebSocket client.""" + await websocket.accept() + self.connected_clients.append(websocket) + + # Send current state to the new client + await self._send_state_update() + + async def disconnect_websocket(self, websocket: WebSocket): + """Disconnect a WebSocket client.""" + if websocket in self.connected_clients: + self.connected_clients.remove(websocket) + + async def _send_state_update(self): + """Send current state to all connected clients.""" + if not self.connected_clients: + return + + state_data = { + "type": "state_update", + "episode_state": asdict(self.episode_state) + } + + # Send to all connected clients + disconnected_clients = [] + for client in self.connected_clients: + try: + await client.send_text(json.dumps(state_data)) + except: + disconnected_clients.append(client) + + # Remove disconnected clients + for client in disconnected_clients: + self.connected_clients.remove(client) + + async def reset_environment(self) -> Dict[str, Any]: + """Reset the environment and update state.""" + observation = self.env.reset() + state = self.env.state + + # Update episode state + self.episode_state.episode_id = state.episode_id + self.episode_state.step_count = 0 + self.episode_state.current_observation = asdict(observation) + self.episode_state.action_logs = [] + self.episode_state.is_reset = True + + # Send state update + await self._send_state_update() + + return { + "observation": asdict(observation), + "reward": observation.reward, + "done": observation.done, + } + + async def step_environment(self, action_data: Dict[str, Any]) -> Dict[str, Any]: + """Execute a step in the environment and update state.""" + # Deserialize action + action = self._deserialize_action(action_data) + + # Execute step + observation = self.env.step(action) + state = self.env.state + + # Create action log + action_log = ActionLog( + timestamp=datetime.now().isoformat(), + action=asdict(action), + observation=asdict(observation), + reward=observation.reward, + done=observation.done, + step_count=state.step_count + ) + + # Update episode state + self.episode_state.episode_id = state.episode_id + self.episode_state.step_count = state.step_count + self.episode_state.current_observation = asdict(observation) + self.episode_state.action_logs.append(action_log) + self.episode_state.is_reset = False + + # Send state update + await self._send_state_update() + + return { + "observation": asdict(observation), + "reward": observation.reward, + "done": observation.done, + } + + def get_state(self) -> Dict[str, Any]: + """Get current environment state.""" + state = self.env.state + return asdict(state) + + def _deserialize_action(self, action_data: Dict[str, Any]) -> Action: + """Convert JSON dict to Action instance.""" + metadata = action_data.pop("metadata", {}) + + # Handle tensor fields that come from JSON as lists + processed_data = {} + for key, value in action_data.items(): + if key == "tokens" and isinstance(value, (list, str)): + # Convert list or string to tensor + if isinstance(value, str): + # If it's a string, try to parse it as a list of numbers + try: + import json + value = json.loads(value) + except: + # If parsing fails, treat as empty list + value = [] + if isinstance(value, list): + import torch + processed_data[key] = torch.tensor(value, dtype=torch.long) + else: + processed_data[key] = value + elif key == "action_id" and isinstance(value, str): + # Convert action_id from string to int + try: + processed_data[key] = int(value) + except ValueError: + # If conversion fails, keep original value + processed_data[key] = value + else: + processed_data[key] = value + + action = self.action_cls(**processed_data) + action.metadata = metadata + return action + + +def create_web_interface_app( + env: Environment, + action_cls: Type[Action], + observation_cls: Type[Observation], + env_name: Optional[str] = None, +) -> FastAPI: + """ + Create a FastAPI application with web interface for the given environment. + + Args: + env: The Environment instance to serve + action_cls: The Action subclass this environment expects + observation_cls: The Observation subclass this environment returns + env_name: Optional environment name for README loading + + Returns: + FastAPI application instance with web interface + """ + from .http_server import create_fastapi_app + + # Create the base environment app + app = create_fastapi_app(env, action_cls, observation_cls) + + # Load environment metadata + metadata = load_environment_metadata(env, env_name) + + # Create web interface manager + web_manager = WebInterfaceManager(env, action_cls, observation_cls, metadata) + + # Add web interface routes + @app.get("/web", response_class=HTMLResponse) + async def web_interface(): + """Serve the web interface.""" + return get_web_interface_html(action_cls, web_manager.metadata) + + @app.get("/web/metadata") + async def web_metadata(): + """Get environment metadata.""" + return asdict(web_manager.metadata) + + @app.websocket("/ws") + async def websocket_endpoint(websocket: WebSocket): + """WebSocket endpoint for real-time updates.""" + await web_manager.connect_websocket(websocket) + try: + while True: + # Keep connection alive + await websocket.receive_text() + except WebSocketDisconnect: + await web_manager.disconnect_websocket(websocket) + + @app.post("/web/reset") + async def web_reset(): + """Reset endpoint for web interface.""" + return await web_manager.reset_environment() + + @app.post("/web/step") + async def web_step(request: Dict[str, Any]): + """Step endpoint for web interface.""" + # Check if this is a message-based request (chat environment) + if "message" in request: + message = request["message"] + # Convert message to action using the environment's message_to_action method + action = web_manager.env.message_to_action(message) + action_data = {"tokens": action.tokens.tolist()} + else: + action_data = request.get("action", {}) + + return await web_manager.step_environment(action_data) + + @app.get("/web/state") + async def web_state(): + """State endpoint for web interface.""" + return web_manager.get_state() + + return app + + +def get_web_interface_html(action_cls: Type[Action], metadata: Optional[EnvironmentMetadata] = None) -> str: + """Generate the HTML for the web interface.""" + + # Check if this is a chat environment by looking for tokens field + is_chat_env = False + if hasattr(action_cls, '__dataclass_fields__'): + for field_name, field_info in action_cls.__dataclass_fields__.items(): + if field_name == 'tokens' and hasattr(field_info.type, '__name__') and 'Tensor' in field_info.type.__name__: + is_chat_env = True + break + + # Get action fields for dynamic form generation with enhanced metadata + action_fields = _extract_action_fields(action_cls) + + return f""" + + + + + + OpenEnv Web Interface + + + +
+ +
+
+ + HumanAgent Interface +
+
+ + {_generate_instructions_section(metadata)} + + + {_generate_action_interface(action_fields, is_chat_env)} + + +
+ + +
+ + +
+

Current State

+
+
+ Status: + Not initialized +
+
+ Episode ID: + - +
+
+ Step Count: + 0 +
+
+
+
+
+ + +
+
+ State Observer +
+
+ +
+

Current Observation

+
+ No observation yet +
+
+ + +
+

Action History

+
+ No actions taken yet +
+
+
+
+
+ + + + + """.replace('{_generate_action_form_fields(action_fields)}', _generate_action_form_fields(action_fields)) + + +def _generate_instructions_section(metadata: Optional[EnvironmentMetadata]) -> str: + """Generate the instructions section with environment documentation.""" + if not metadata or not metadata.readme_content: + return '' + + # Convert markdown to HTML (basic conversion) + import re + html_content = _markdown_to_html(metadata.readme_content) + + return f''' + +
+
+

{metadata.name}

+ +
+
+
+ {html_content} +
+
+
+ ''' + + +def _extract_action_fields(action_cls: Type[Action]) -> List[Dict[str, Any]]: + """Extract enhanced field metadata from Action class for form generation.""" + import typing + from typing import get_origin, get_args + + action_fields = [] + if not hasattr(action_cls, '__dataclass_fields__'): + return action_fields + + for field_name, field_info in action_cls.__dataclass_fields__.items(): + if field_name == 'metadata': + continue + + field_type = field_info.type + field_metadata = _extract_field_metadata(field_name, field_info) + + # Determine input type based on field type + input_type = _determine_input_type(field_type) + + # Check if field is required + is_required = field_info.default is field_info.default_factory + + action_fields.append({ + 'name': field_name, + 'type': input_type, + 'required': is_required, + 'description': field_metadata.get('description', ''), + 'default_value': field_metadata.get('default_value'), + 'choices': field_metadata.get('choices', []), + 'min_value': field_metadata.get('min_value'), + 'max_value': field_metadata.get('max_value'), + 'placeholder': field_metadata.get('placeholder', ''), + 'help_text': field_metadata.get('help_text', ''), + }) + + return action_fields + + +def _extract_field_metadata(field_name: str, field_info) -> Dict[str, Any]: + """Extract metadata from dataclass field including docstring and type hints.""" + import typing + from typing import get_origin, get_args, Literal, Union, Optional + + metadata = {} + + # Extract description from field docstring or annotation + if hasattr(field_info, 'metadata') and field_info.metadata: + # Check for custom metadata + for meta in field_info.metadata: + if isinstance(meta, dict): + metadata.update(meta) + + # Extract type information + field_type = field_info.type + origin = get_origin(field_type) + + # Handle Literal types for dropdown choices + if origin is Literal: + args = get_args(field_type) + metadata['choices'] = list(args) + + # Handle Optional types + if origin is Union: + args = get_args(field_type) + if len(args) == 2 and type(None) in args: + # This is Optional[SomeType] + non_none_type = args[0] if args[1] is type(None) else args[1] + metadata['optional'] = True + # Recursively check the non-None type for choices + if get_origin(non_none_type) is Literal: + metadata['choices'] = list(get_args(non_none_type)) + else: + # Regular Union type + metadata['choices'] = [str(arg) for arg in args if arg is not type(None)] + + # Handle numeric constraints + if field_type in (int, float): + # Check for common constraint patterns in field name + if 'count' in field_name.lower() or 'num' in field_name.lower(): + metadata['min_value'] = 0 + if 'id' in field_name.lower(): + metadata['min_value'] = 0 + + # Generate placeholder text + if 'message' in field_name.lower(): + metadata['placeholder'] = f'Enter {field_name.replace("_", " ")}...' + elif 'code' in field_name.lower(): + metadata['placeholder'] = 'Enter Python code here...' + elif 'tokens' in field_name.lower(): + metadata['placeholder'] = 'Enter comma-separated token IDs (e.g., 1,2,3,4,5)' + else: + metadata['placeholder'] = f'Enter {field_name.replace("_", " ")}...' + + # Generate help text based on field name and type + if 'action_id' in field_name.lower(): + metadata['help_text'] = 'The action ID to execute in the environment' + elif 'game_name' in field_name.lower(): + metadata['help_text'] = 'Name of the game or environment' + elif 'tokens' in field_name.lower(): + metadata['help_text'] = 'Token IDs as a comma-separated list of integers' + elif 'code' in field_name.lower(): + metadata['help_text'] = 'Python code to execute in the environment' + elif 'message' in field_name.lower(): + metadata['help_text'] = 'Text message to send' + + return metadata + + +def _determine_input_type(field_type) -> str: + """Determine the appropriate HTML input type for a field type.""" + import typing + from typing import get_origin, get_args, Literal, Union + + # Handle direct types + if field_type == str: + return "text" + elif field_type == int: + return "number" + elif field_type == float: + return "number" + elif field_type == bool: + return "checkbox" + + # Handle complex types + origin = get_origin(field_type) + + if origin is Literal: + return "select" + elif origin is Union: + args = get_args(field_type) + if len(args) == 2 and type(None) in args: + # Optional type - use the non-None type + non_none_type = args[0] if args[1] is type(None) else args[1] + return _determine_input_type(non_none_type) + elif all(isinstance(arg, str) for arg in args if arg is not type(None)): + return "select" + else: + return "text" + elif hasattr(field_type, '__name__') and 'Tensor' in field_type.__name__: + return "tensor" + else: + return "text" + + +def _markdown_to_html(markdown: str) -> str: + """Convert basic markdown to HTML for README display.""" + import html + import re + + # Escape HTML first + html_content = html.escape(markdown) + + # Convert headers + html_content = re.sub(r'^# (.*?)$', r'

\1

', html_content, flags=re.MULTILINE) + html_content = re.sub(r'^## (.*?)$', r'

\1

', html_content, flags=re.MULTILINE) + html_content = re.sub(r'^### (.*?)$', r'

\1

', html_content, flags=re.MULTILINE) + + # Convert code blocks + html_content = re.sub(r'```(.*?)\n(.*?)\n```', r'
\2
', html_content, flags=re.DOTALL) + html_content = re.sub(r'`([^`]+)`', r'\1', html_content) + + # Convert bold and italic + html_content = re.sub(r'\*\*(.*?)\*\*', r'\1', html_content) + html_content = re.sub(r'\*(.*?)\*', r'\1', html_content) + + # Convert lists + html_content = re.sub(r'^- (.*?)$', r'
  • \1
  • ', html_content, flags=re.MULTILINE) + html_content = re.sub(r'(
  • .*
  • )', r'', html_content, flags=re.DOTALL) + + # Convert line breaks + html_content = html_content.replace('\n', '
    ') + + return html_content + + +def _generate_action_interface(action_fields: List[Dict[str, Any]], is_chat_env: bool) -> str: + """Generate either a chat interface or action form based on environment type.""" + if is_chat_env: + return _generate_chat_interface() + else: + return _generate_action_form(action_fields) + +def _generate_chat_interface() -> str: + """Generate a chat-style interface for chat environments.""" + return ''' + +
    +

    Chat Interface

    +
    +
    +
    System
    +
    Chat environment ready. Send a message to start the conversation.
    +
    +
    +
    +
    + + +
    +
    + + +
    +
    +
    + ''' + +def _generate_action_form(action_fields: List[Dict[str, Any]]) -> str: + """Generate a traditional action form for non-chat environments.""" + return f''' + +
    +

    Take Action

    +
    + {_generate_action_form_fields(action_fields)} + +
    +
    + ''' + +def _generate_action_form_fields(action_fields: List[Dict[str, Any]]) -> str: + """Generate HTML form fields for action input with enhanced metadata.""" + if not action_fields: + return '

    No action fields available

    ' + + fields_html = [] + for field in action_fields: + field_html = _generate_single_field(field) + fields_html.append(field_html) + + return '\n'.join(fields_html) + + +def _generate_single_field(field: Dict[str, Any]) -> str: + """Generate HTML for a single form field with enhanced metadata.""" + field_name = field['name'] + field_type = field['type'] + required = field['required'] + placeholder = field.get('placeholder', '') + help_text = field.get('help_text', '') + choices = field.get('choices', []) + min_value = field.get('min_value') + max_value = field.get('max_value') + default_value = field.get('default_value') + + # Build label with required indicator + label_text = field_name.replace('_', ' ').title() + if required: + label_text += ' *' + + # Build input attributes + input_attrs = [] + if required: + input_attrs.append('required') + if placeholder: + input_attrs.append(f'placeholder="{placeholder}"') + if min_value is not None: + input_attrs.append(f'min="{min_value}"') + if max_value is not None: + input_attrs.append(f'max="{max_value}"') + if default_value is not None: + input_attrs.append(f'value="{default_value}"') + + attrs_str = ' '.join(input_attrs) + + if field_type == 'checkbox': + return f''' +
    + + {f'{help_text}' if help_text else ''} +
    + ''' + + elif field_type == 'select': + options_html = [] + if not required: + options_html.append(f'') + + for choice in choices: + selected = 'selected' if str(choice) == str(default_value) else '' + options_html.append(f'') + + return f''' +
    + + + {f'{help_text}' if help_text else ''} +
    + ''' + + elif field_type == 'tensor': + return f''' +
    + + + {help_text or 'Enter token IDs as comma-separated integers (e.g., 1,2,3,4,5)'} +
    + ''' + + elif field_type == 'text' and ('message' in field_name.lower() or 'code' in field_name.lower()): + return f''' +
    + + + {f'{help_text}' if help_text else ''} +
    + ''' + + else: + return f''' +
    + + + {f'{help_text}' if help_text else ''} +
    + ''' diff --git a/src/core/core/http_env_client.py b/src/core/core/http_env_client.py new file mode 100644 index 0000000000000000000000000000000000000000..7f21a31fcbdf65054ebc70dc1cc6785353f9fa6b --- /dev/null +++ b/src/core/core/http_env_client.py @@ -0,0 +1,246 @@ +""" +core/runner_env.py +Minimal HTTP-based environment client. +- Talks to a single env worker exposing: POST /reset, POST /step + +Future hooks (commented below) for: +- episode_id, seed on reset +- request_id on step +- custom headers (auth/trace) +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Any, Dict, Generic, Optional, Type, TYPE_CHECKING, TypeVar + +import requests + +from .client_types import StepResult +from .containers.runtime import LocalDockerProvider +from .containers.runtime.uv_provider import UVProvider + +if TYPE_CHECKING: + from .containers.runtime import ContainerProvider + +ActT = TypeVar("ActT") +ObsT = TypeVar("ObsT") +EnvClientT = TypeVar("EnvClientT", bound="HTTPEnvClient") + + +class HTTPEnvClient(ABC, Generic[ActT, ObsT]): + def __init__( + self, + base_url: str, + request_timeout_s: float = 15.0, + default_headers: Optional[Dict[str, str]] = None, + provider: Optional["ContainerProvider"] = None, + ): + self._base = base_url.rstrip("/") + self._timeout = float(request_timeout_s) + self._http = requests.Session() + self._headers = default_headers or {} + self._provider = provider + + @classmethod + def from_docker_image( + cls: Type[EnvClientT], + image: str, + provider: Optional["ContainerProvider"] = None, + **kwargs: Any, + ) -> EnvClientT: + """ + Create an environment client by spinning up a Docker container locally. + + This is a development utility that: + 1. Starts a Docker container from the specified image + 2. Waits for the server to be ready + 3. Creates and returns a client instance connected to the container + + Note: + The caller or a higher-level orchestrator manages the container + lifecycle. The container continues running until it is stopped. + + Args: + image: Docker image name to run (e.g., "echo-env:latest") + provider: Container provider to use (defaults to + ``LocalDockerProvider``) + **kwargs: Additional arguments passed to + ``provider.start_container()`` (e.g., env_vars, port) + + Returns: + An instance of the client class connected to the running container + + Example: + >>> from envs.coding_env.client import CodingEnv + >>> from envs.coding_env.models import CodeAction + >>> + >>> # Create environment from image + >>> env = CodingEnv.from_docker_image("coding-env:latest") + >>> + >>> # Create environment with custom env vars + >>> env = CodingEnv.from_docker_image( + ... "coding-env:latest", + ... env_vars={"MY_VAR": "value"} + ... ) + >>> + >>> # Use the environment + >>> result = env.reset() + >>> print(result.observation) + >>> + >>> step_result = env.step(CodeAction(code="print('hello')")) + >>> print(step_result.observation.stdout) + >>> + >>> # Cleanup (optional) + >>> env.close() + """ + + # Use default provider if none provided + if provider is None: + provider = LocalDockerProvider() + + # 1. Start container with optional kwargs (e.g., env_vars, port) + base_url = provider.start_container(image, **kwargs) + + # 2. Wait for server to be ready + provider.wait_for_ready(base_url) + + # 3. Create and return client instance with provider reference + return cls(base_url=base_url, provider=provider) + + @classmethod + def from_hub( + cls: Type[EnvClientT], + space_id: str, + *, + use_docker: bool = True, + provider: Optional["ContainerProvider"] = None, + host: str = "0.0.0.0", + port: Optional[int] = None, + reload: bool = False, + timeout_s: float = 60.0, + runner: Optional[UVProvider] = None, + project_url: Optional[str] = None, + connect_host: Optional[str] = None, + extra_env: Optional[Dict[str, str]] = None, + **provider_kwargs: Any, + ) -> EnvClientT: + """Create a client from a Hugging Face Space. + + Set ``use_docker=True`` to launch the registry image with a container + provider. The default ``use_docker=False`` runs the Space locally using + ``uv run`` through :class:`UVProvider`. + """ + + if use_docker: + if provider is None: + provider = LocalDockerProvider() + + tag = provider_kwargs.pop("tag", "latest") + image = provider_kwargs.pop( + "image", + f"registry.hf.space/{space_id.replace('/', '-')}:" f"{tag}", + ) + + base_url = provider.start_container(image, **provider_kwargs) + provider.wait_for_ready(base_url) + return cls(base_url=base_url, provider=provider) + + uv_runner = runner or UVProvider( + space_id=space_id, + host=host, + port=port, + reload=reload, + project_url=project_url, + connect_host=connect_host, + extra_env=extra_env, + ) + + base_url = uv_runner.start() + + try: + uv_runner.wait_for_ready(timeout_s=timeout_s) + except Exception: + uv_runner.stop() + raise + + return cls(base_url=base_url, provider=uv_runner) + + @abstractmethod + def _step_payload(self, action: ActT) -> dict: + """Convert an Action object to the JSON body expected by the env server.""" + raise NotImplementedError + + @abstractmethod + def _parse_result(self, payload: dict) -> StepResult[ObsT]: + """Convert a JSON response from the env server to StepResult[ObsT].""" + raise NotImplementedError + + @abstractmethod + def _parse_state(self, payload: dict) -> Any: + """Convert a JSON response from the state endpoint to a State object.""" + raise NotImplementedError + + # ---------- Environment Server Interface Methods ---------- + def reset(self) -> StepResult[ObsT]: + body: Dict[str, Any] = {} + # TODO: later: + # body["seed"] = seed + # body["episode_id"] = episode_id + r = self._http.post( + f"{self._base}/reset", + json=body, + headers=self._headers, + timeout=self._timeout, + ) + r.raise_for_status() + return self._parse_result(r.json()) + + def step(self, action: ActT) -> StepResult[ObsT]: + body: Dict[str, Any] = { + "action": self._step_payload(action), + "timeout_s": int(self._timeout), + } + # TODO: later: + # body["request_id"] = str(uuid.uuid4()) + # body["episode_id"] = current_episode_id + r = self._http.post( + f"{self._base}/step", + json=body, + headers=self._headers, + timeout=self._timeout, + ) + r.raise_for_status() + return self._parse_result(r.json()) + + def state(self) -> Any: + """ + Get the current environment state from the server. + + Returns: + State object with environment state information (e.g., episode_id, step_count) + + Example: + >>> client = EchoEnv.from_docker_image("echo-env:latest") + >>> result = client.reset() + >>> state = client.state() + >>> print(state.episode_id) + >>> print(state.step_count) + """ + r = self._http.get( + f"{self._base}/state", + headers=self._headers, + timeout=self._timeout, + ) + r.raise_for_status() + return self._parse_state(r.json()) + + def close(self) -> None: + """ + Close the environment and clean up resources. + + If this client was created via from_docker_image(), this will stop + and remove the associated container. + """ + if self._provider is not None: + self._provider.stop_container() diff --git a/src/core/core/pyproject.toml b/src/core/core/pyproject.toml new file mode 100644 index 0000000000000000000000000000000000000000..32602f58cb6f066e6874f977f1838483a2ea931b --- /dev/null +++ b/src/core/core/pyproject.toml @@ -0,0 +1,46 @@ +[build-system] +requires = ["setuptools>=45", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "openenv-core" +version = "0.1.0" +description = "Core components for OpenEnv - HTTP-based agentic environments" +readme = "README.md" +requires-python = ">=3.8" +license = {text = "BSD-3-Clause"} +authors = [ + {name = "Meta Platforms, Inc.", email = "opensource@meta.com"} +] +keywords = ["environment", "agent", "http", "docker", "fastapi"] + +dependencies = [ + "requests>=2.25.0", + "fastapi>=0.104.0", + "uvicorn>=0.24.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=7.0.0", + "black>=23.0.0", + "ruff>=0.1.0", + "mypy>=1.0.0", +] + +[project.urls] +Homepage = "https://github.com/facebookresearch/OpenEnv" +Repository = "https://github.com/facebookresearch/OpenEnv" +Documentation = "https://github.com/facebookresearch/OpenEnv/blob/main/README.md" +"Bug Tracker" = "https://github.com/facebookresearch/OpenEnv/issues" + +[tool.setuptools] +py-modules = ["openenv_core.__init__", "openenv_core.http_env_client", "openenv_core.client_types"] +packages = [ + "openenv_core", + "openenv_core.containers", + "openenv_core.containers.runtime", + "openenv_core.env_server", + "openenv_core.tools" +] +package-dir = {"openenv_core" = "."} diff --git a/src/core/core/tools/__init__.py b/src/core/core/tools/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..034e7f0686b81bcdda4f30cf1e32b86cd4fa2bf5 --- /dev/null +++ b/src/core/core/tools/__init__.py @@ -0,0 +1,16 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +"""Core tools for code execution and other utilities.""" + +from .git_server_client import GitServerClient, RepoInfo +from .local_python_executor import PyExecutor + +__all__ = [ + "PyExecutor", + "GitServerClient", + "RepoInfo", +] \ No newline at end of file diff --git a/src/core/core/tools/git_server_client.py b/src/core/core/tools/git_server_client.py new file mode 100644 index 0000000000000000000000000000000000000000..143bc363b3db278a5cfd09a1b47a736fda8d3bfa --- /dev/null +++ b/src/core/core/tools/git_server_client.py @@ -0,0 +1,362 @@ +#!/usr/bin/env python3 +""" +Git Server Client for connecting to external Gitea instance. + +This module provides a lightweight client for interacting with a shared +Gitea service, optimized for task-based isolation where multiple environment +instances share the same Gitea server but have isolated workspaces. +""" + +import json +import os +import shutil +import subprocess +import time +from dataclasses import dataclass +from pathlib import Path +from urllib.parse import urlparse + + +@dataclass +class RepoInfo: + """Information about a repository.""" + + name: str + url: str + commit: str + clone_url: str + + +class GitServerClient: + """ + Client for connecting to an external Gitea server. + + This client is optimized for task-based isolation where: + - Multiple tasks share the same Gitea instance + - Each task has its own isolated workspace + - Fast reset() via git operations (no server restart) + - Repos are pre-migrated to Gitea once + + Args: + gitea_url: URL of the Gitea server (e.g., "http://gitea:3000") + username: Gitea username for authentication + password: Gitea password for authentication + workspace_dir: Local workspace directory for cloning repos + + Example: + >>> # Connect to shared Gitea (credentials from environment) + >>> import os + >>> client = GitServerClient( + ... gitea_url=os.getenv("GITEA_URL"), + ... username=os.getenv("GITEA_USERNAME"), + ... password=os.getenv("GITEA_PASSWORD") + ... ) + >>> client.wait_for_ready() + >>> # Clone repo to workspace + >>> path = client.clone_to_workspace("my-repo", commit="abc123") + >>> # Fast reset to base state + >>> client.reset_workspace("my-repo", commit="abc123") + """ + + def __init__( + self, + gitea_url: str, + username: str, + password: str, + workspace_dir: str = "/workspace", + ): + """Initialize Git Server Client.""" + self.gitea_url = gitea_url.rstrip("/") + self.username = username + self.password = password + self.workspace_dir = Path(workspace_dir) + self.is_ready = False + + # Parse Gitea URL + parsed = urlparse(self.gitea_url) + self.domain = parsed.hostname or "localhost" + self.port = parsed.port or 3000 + + # Ensure workspace exists + os.makedirs(self.workspace_dir, exist_ok=True) + + # Configure git credentials + self._configure_git() + + def _configure_git(self): + """Configure git credentials for automatic authentication.""" + home_dir = Path.home() + + # Git config + git_config = f"""[user] + name = {self.username} + email = {self.username}@local.env +[init] + defaultBranch = main +[credential] + helper = store +""" + gitconfig_path = home_dir / ".gitconfig" + gitconfig_path.write_text(git_config) + + # Git credentials + git_credentials = f"http://{self.username}:{self.password}@{self.domain}:{self.port}\n" + gitcreds_path = home_dir / ".git-credentials" + gitcreds_path.write_text(git_credentials) + gitcreds_path.chmod(0o600) + + def wait_for_ready(self, timeout: int = 30) -> bool: + """ + Wait for Gitea server to be ready. + + Args: + timeout: Maximum seconds to wait + + Returns: + True if server is ready, False otherwise + """ + start_time = time.time() + while time.time() - start_time < timeout: + try: + result = subprocess.run( + ["curl", "-sf", f"{self.gitea_url}/"], + capture_output=True, + timeout=5, + ) + if result.returncode == 0: + self.is_ready = True + return True + except subprocess.TimeoutExpired: + pass + except Exception: + pass + + time.sleep(1) + + return False + + def list_repositories(self) -> list[dict[str, str]]: + """ + List all repositories in Gitea. + + Returns: + List of repository information dictionaries + """ + if not self.is_ready: + raise RuntimeError("Gitea server is not ready") + + result = subprocess.run( + [ + "curl", + "-s", + f"{self.gitea_url}/api/v1/user/repos", + "-u", + f"{self.username}:{self.password}", + ], + capture_output=True, + text=True, + ) + + if result.returncode != 0: + return [] + + try: + repos = json.loads(result.stdout) + return [ + { + "name": repo["name"], + "full_name": repo["full_name"], + "clone_url": repo["clone_url"], + "description": repo.get("description", ""), + } + for repo in repos + ] + except (json.JSONDecodeError, KeyError): + return [] + + def clone_to_workspace( + self, repo_name: str, target_dir: str | None = None, commit: str = "main" + ) -> str: + """ + Clone a repository to the workspace at a specific commit. + + This creates a fresh clone optimized for task isolation. + + Args: + repo_name: Name of repository to clone + target_dir: Target directory name (defaults to repo_name) + commit: Commit hash or branch to check out + + Returns: + Path to cloned repository + + Raises: + RuntimeError: If clone fails + """ + if not self.is_ready: + raise RuntimeError("Gitea server is not ready") + + target_dir = target_dir or repo_name + target_path = self.workspace_dir / target_dir + + # Remove existing directory if present + if target_path.exists(): + shutil.rmtree(target_path) + + clone_url = f"{self.gitea_url}/{self.username}/{repo_name}.git" + + # Clone repository + result = subprocess.run( + ["git", "clone", clone_url, str(target_path)], + capture_output=True, + text=True, + ) + + if result.returncode != 0: + raise RuntimeError(f"Clone failed: {result.stderr}") + + # Checkout specific commit + if commit != "main": + result = subprocess.run( + ["git", "checkout", commit], + cwd=str(target_path), + capture_output=True, + text=True, + ) + + if result.returncode != 0: + raise RuntimeError(f"Checkout failed: {result.stderr}") + + return str(target_path) + + def reset_workspace(self, repo_name: str, commit: str = "main") -> bool: + """ + Fast reset of workspace to base state (optimized for task resets). + + This is much faster than re-cloning. It: + 1. Checks out the target commit + 2. Resets to that commit (hard) + 3. Cleans untracked files + + Args: + repo_name: Name of repository (directory in workspace) + commit: Commit hash or branch to reset to + + Returns: + True if reset successful + + Raises: + RuntimeError: If reset fails + """ + repo_path = self.workspace_dir / repo_name + + if not repo_path.exists(): + raise RuntimeError(f"Repository not found in workspace: {repo_name}") + + # Fetch latest (in case commit is new) + subprocess.run( + ["git", "fetch", "--all"], + cwd=str(repo_path), + capture_output=True, + ) + + # Checkout and hard reset to commit + result = subprocess.run( + ["git", "checkout", commit], + cwd=str(repo_path), + capture_output=True, + text=True, + ) + + if result.returncode != 0: + raise RuntimeError(f"Checkout failed: {result.stderr}") + + result = subprocess.run( + ["git", "reset", "--hard", f"origin/{commit}" if commit != "main" else commit], + cwd=str(repo_path), + capture_output=True, + text=True, + ) + + if result.returncode != 0: + # Try without origin/ prefix + result = subprocess.run( + ["git", "reset", "--hard", commit], + cwd=str(repo_path), + capture_output=True, + text=True, + ) + if result.returncode != 0: + raise RuntimeError(f"Reset failed: {result.stderr}") + + # Clean untracked files and directories + subprocess.run( + ["git", "clean", "-fdx"], + cwd=str(repo_path), + capture_output=True, + ) + + return True + + def execute_git_command( + self, command: str, working_dir: str = "" + ) -> tuple[int, str, str]: + """ + Execute a git command in the workspace. + + Args: + command: Git command to execute (without 'git' prefix) + working_dir: Working directory relative to workspace + + Returns: + Tuple of (exit_code, stdout, stderr) + """ + work_path = ( + self.workspace_dir / working_dir if working_dir else self.workspace_dir + ) + + if not work_path.exists(): + return (1, "", f"Working directory does not exist: {work_path}") + + # Split command safely + cmd_parts = ["git"] + command.split() + + result = subprocess.run( + cmd_parts, + cwd=str(work_path), + capture_output=True, + text=True, + ) + + return (result.returncode, result.stdout, result.stderr) + + def get_current_commit(self, repo_name: str) -> str: + """ + Get current commit hash of a workspace repository. + + Args: + repo_name: Name of repository in workspace + + Returns: + Commit hash + """ + repo_path = self.workspace_dir / repo_name + + if not repo_path.exists(): + raise RuntimeError(f"Repository not found: {repo_name}") + + result = subprocess.run( + ["git", "rev-parse", "HEAD"], + cwd=str(repo_path), + capture_output=True, + text=True, + ) + + if result.returncode != 0: + raise RuntimeError(f"Failed to get commit: {result.stderr}") + + return result.stdout.strip() + + def workspace_exists(self, repo_name: str) -> bool: + """Check if a repository exists in workspace.""" + return (self.workspace_dir / repo_name).exists() diff --git a/src/core/core/tools/local_python_executor.py b/src/core/core/tools/local_python_executor.py new file mode 100644 index 0000000000000000000000000000000000000000..ba4477d52f3f0f295ec568dc21d807a711ea2cc5 --- /dev/null +++ b/src/core/core/tools/local_python_executor.py @@ -0,0 +1,105 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +""" +Local Python Executor. + +This module provides functionality for executing Python code locally by wrapping +the smolagents LocalPythonExecutor. +""" + +from smolagents import LocalPythonExecutor + +from core.env_server.types import CodeExecResult + + +class PyExecutor: + """ + Wrapper around smolagents LocalPythonExecutor for executing Python code. + + This class provides a simple interface to execute Python code in a subprocess + and capture the results including stdout, stderr, and exit code. + + Args: + additional_imports: List of additional module imports to authorize. + For example: ["numpy", "pandas", "matplotlib"] + These will be added to the base authorized imports. + + Example: + >>> # Basic usage with default imports + >>> executor = PyExecutor() + >>> result = executor.run("print('Hello, World!')") + >>> print(result.stdout) # "Hello, World!\n" + >>> print(result.exit_code) # 0 + >>> + >>> # Usage with additional imports + >>> executor = PyExecutor(additional_imports=["numpy", "pandas"]) + >>> result = executor.run("import numpy as np\\nprint(np.array([1, 2, 3]))") + >>> print(result.stdout) # "[1 2 3]\n" + """ + + def __init__(self, additional_imports: list[str] | None = None): + """ + Initialize the PyExecutor with a LocalPythonExecutor instance. + + Args: + additional_imports: List of additional module names to authorize for import. + Defaults to an empty list if not provided. + """ + if additional_imports is None: + additional_imports = [] + self._executor = LocalPythonExecutor( + additional_authorized_imports=additional_imports + ) + # Initialize tools to make BASE_PYTHON_TOOLS available (including print) + self._executor.send_tools({}) + + def run(self, code: str) -> CodeExecResult: + """ + Execute Python code and return the result. + + Args: + code: Python code string to execute + + Returns: + CodeExecResult containing stdout, stderr, and exit_code + + Example: + >>> executor = PyExecutor() + >>> result = executor.run("x = 5 + 3\\nprint(x)") + >>> print(result.stdout) # "8\n" + >>> print(result.exit_code) # 0 + >>> + >>> # Error handling + >>> result = executor.run("1 / 0") + >>> print(result.exit_code) # 1 + >>> print(result.stderr) # Contains error message + """ + try: + # Execute the code using LocalPythonExecutor + # LocalPythonExecutor returns a CodeOutput object with output, logs, is_final_answer + exec_result = self._executor(code) + + # Extract the logs (which contain print outputs) as stdout + # The output field contains the return value of the code + stdout = exec_result.logs + stderr = "" + exit_code = 0 # Success + + return CodeExecResult( + stdout=stdout, + stderr=stderr, + exit_code=exit_code, + ) + + except Exception as e: + # LocalPythonExecutor raises InterpreterError for various issues + # (syntax errors, forbidden operations, runtime errors, etc.) + return CodeExecResult( + stdout="", + stderr=str(e), + exit_code=1, # Non-zero indicates error + ) diff --git a/src/core/env_server/__init__.py b/src/core/env_server/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..79e66535f0e74ae18d84181a234365fca8f3ffc1 --- /dev/null +++ b/src/core/env_server/__init__.py @@ -0,0 +1,35 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +"""Core environment interfaces and types.""" + +from .base_transforms import CompositeTransform, NullTransform +from .http_server import HTTPEnvServer, create_app, create_fastapi_app +from .interfaces import Environment, Message, ModelTokenizer, Transform +from .types import Action, Observation, State +from .web_interface import create_web_interface_app, WebInterfaceManager + +__all__ = [ + # Core interfaces + "Environment", + "Transform", + "Message", + "ModelTokenizer", + # Types + "Action", + "Observation", + "State", + # Base transforms + "CompositeTransform", + "NullTransform", + # HTTP Server + "HTTPEnvServer", + "create_app", + "create_fastapi_app", + # Web Interface + "create_web_interface_app", + "WebInterfaceManager", +] diff --git a/src/core/env_server/__pycache__/__init__.cpython-313.pyc b/src/core/env_server/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9aa78f3c757381d9be765a5b246d7b9b1fc4497f Binary files /dev/null and b/src/core/env_server/__pycache__/__init__.cpython-313.pyc differ diff --git a/src/core/env_server/__pycache__/base_transforms.cpython-313.pyc b/src/core/env_server/__pycache__/base_transforms.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..61982cdddd803e60ffdfa66896d9032c3fa8e570 Binary files /dev/null and b/src/core/env_server/__pycache__/base_transforms.cpython-313.pyc differ diff --git a/src/core/env_server/__pycache__/http_server.cpython-313.pyc b/src/core/env_server/__pycache__/http_server.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..458e51a9b6ffbd9518a4734b27bff139e182a063 Binary files /dev/null and b/src/core/env_server/__pycache__/http_server.cpython-313.pyc differ diff --git a/src/core/env_server/__pycache__/interfaces.cpython-313.pyc b/src/core/env_server/__pycache__/interfaces.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..cdbe3d01c42ac5ab35a18ea39dfa56289a5958f3 Binary files /dev/null and b/src/core/env_server/__pycache__/interfaces.cpython-313.pyc differ diff --git a/src/core/env_server/__pycache__/types.cpython-313.pyc b/src/core/env_server/__pycache__/types.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..50dce0dc82cbdd39e1f9b6633e1acd82450af39a Binary files /dev/null and b/src/core/env_server/__pycache__/types.cpython-313.pyc differ diff --git a/src/core/env_server/__pycache__/web_interface.cpython-313.pyc b/src/core/env_server/__pycache__/web_interface.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b269e6d4dfe878dbb1f6a13e3dd9307ebff59c24 Binary files /dev/null and b/src/core/env_server/__pycache__/web_interface.cpython-313.pyc differ diff --git a/src/core/env_server/base_transforms.py b/src/core/env_server/base_transforms.py new file mode 100644 index 0000000000000000000000000000000000000000..d8165e3d77ba23bbd4c765e46cc38bc6c475ad4c --- /dev/null +++ b/src/core/env_server/base_transforms.py @@ -0,0 +1,29 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +"""Base transform implementations for composing environment-specific transforms.""" + +from .interfaces import Transform +from .types import Observation + + +class CompositeTransform(Transform): + """Combines multiple transforms into a single transform.""" + + def __init__(self, transforms: list[Transform]): + self.transforms = transforms + + def __call__(self, observation: Observation) -> Observation: + for transform in self.transforms: + observation = transform(observation) + return observation + + +class NullTransform(Transform): + """Default transform that passes through unchanged.""" + + def __call__(self, observation: Observation) -> Observation: + return observation \ No newline at end of file diff --git a/src/core/env_server/http_server.py b/src/core/env_server/http_server.py new file mode 100644 index 0000000000000000000000000000000000000000..d18873f0126d8c9198ac24f7308c6a358ccd9435 --- /dev/null +++ b/src/core/env_server/http_server.py @@ -0,0 +1,233 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +""" +HTTP server wrapper for Environment instances. + +This module provides utilities to wrap any Environment subclass and expose it +over HTTP endpoints that HTTPEnvClient can consume. +""" + +from __future__ import annotations + +import os +from dataclasses import asdict +from typing import Any, Dict, Type + +from .interfaces import Environment +from .types import Action, Observation +from fastapi import Body, FastAPI + +class HTTPEnvServer: + """ + HTTP server wrapper for Environment instances. + + This class wraps an Environment and exposes its reset(), step(), and state + methods as HTTP endpoints compatible with HTTPEnvClient. + + The server expects: + - Action deserialization: Converts JSON dict to Action subclass + - Observation serialization: Converts Observation subclass to JSON dict + + Example: + >>> from core.env_server import HTTPEnvServer + >>> from envs.coding_env.server import CodeExecutionEnvironment + >>> + >>> env = CodeExecutionEnvironment() + >>> server = HTTPEnvServer(env) + >>> + >>> # Register routes with FastAPI + >>> from fastapi import FastAPI + >>> app = FastAPI() + >>> server.register_routes(app) + """ + + def __init__( + self, + env: Environment, + action_cls: Type[Action], + observation_cls: Type[Observation], + ): + """ + Initialize HTTP server wrapper. + + Args: + env: The Environment instance to wrap + action_cls: The Action subclass this environment expects + observation_cls: The Observation subclass this environment returns + """ + self.env = env + self.action_cls = action_cls + self.observation_cls = observation_cls + + def register_routes(self, app: Any) -> None: + """ + Register HTTP routes on a FastAPI application. + + Args: + app: FastAPI application instance + """ + + if not isinstance(app, FastAPI): + raise TypeError("app must be a FastAPI instance") + + @app.post("/reset") + async def reset(request: Dict[str, Any] = Body(default={})) -> Dict[str, Any]: + """Reset endpoint - returns initial observation.""" + # TODO: Handle seed, episode_id from request if provided + observation = self.env.reset() + return self._serialize_observation(observation) + + @app.post("/step") + async def step(request: Dict[str, Any]) -> Dict[str, Any]: + """Step endpoint - executes action and returns observation.""" + action_data = request.get("action", {}) + # TODO: Handle timeout_s, request_id, episode_id from request if provided + + # Deserialize action + action = self._deserialize_action(action_data) + + # Execute step + observation = self.env.step(action) + + # Return serialized observation + return self._serialize_observation(observation) + + @app.get("/state") + async def get_state() -> Dict[str, Any]: + """State endpoint - returns current environment state.""" + state = self.env.state + return asdict(state) + + @app.get("/health") + async def health() -> Dict[str, str]: + """Health check endpoint.""" + return {"status": "healthy"} + + + def _deserialize_action(self, action_data: Dict[str, Any]) -> Action: + """ + Convert JSON dict to Action instance. + + Args: + action_data: Dictionary containing action data + + Returns: + Action instance + + Note: + This is a simple implementation. Subclasses may need to override + for more complex deserialization logic. + """ + # Remove metadata if present (it will be set via kw_only field) + metadata = action_data.pop("metadata", {}) + action = self.action_cls(**action_data) + action.metadata = metadata + return action + + def _serialize_observation(self, observation: Observation) -> Dict[str, Any]: + """ + Convert Observation instance to JSON-compatible dict. + + Args: + observation: Observation instance + + Returns: + Dictionary compatible with HTTPEnvClient._parse_result() + + The format matches what HTTPEnvClient expects: + { + "observation": {...}, # Observation fields + "reward": float | None, + "done": bool, + } + """ + obs_dict = asdict(observation) + + # Extract reward and done (these are part of StepResult on client side) + reward = obs_dict.pop("reward", None) + done = obs_dict.pop("done", False) + obs_dict.pop("metadata", None) # Remove metadata from observation + + # Return in HTTPEnvClient expected format + return { + "observation": obs_dict, + "reward": reward, + "done": done, + } + +def create_app( + env: Environment, + action_cls: Type[Action], + observation_cls: Type[Observation], + env_name: Optional[str] = None, +) -> Any: + """ + Create a FastAPI application with or without web interface. + + This function creates a FastAPI app with the web interface enabled by default, + including README integration for better user experience. + + Args: + env: The Environment instance to serve + action_cls: The Action subclass this environment expects + observation_cls: The Observation subclass this environment returns + env_name: Optional environment name for README loading + + Returns: + FastAPI application instance with or without web interface and README integration + """ + # Check if web interface should be enabled + # This can be controlled via environment variable or build argument + enable_web = ( + os.getenv("ENABLE_WEB_INTERFACE", "false").lower() in ("true", "1", "yes") + ) + + if enable_web: + # Import web interface only when needed + from .web_interface import create_web_interface_app + return create_web_interface_app(env, action_cls, observation_cls, env_name) + else: + # Use standard FastAPI app without web interface + return create_fastapi_app(env, action_cls, observation_cls) + + +def create_fastapi_app( + env: Environment, + action_cls: Type[Action], + observation_cls: Type[Observation], +) -> Any: + """ + Create a FastAPI application with routes for the given environment. + + Args: + env: The Environment instance to serve + action_cls: The Action subclass this environment expects + observation_cls: The Observation subclass this environment returns + + Returns: + FastAPI application instance with routes registered + + Example: + >>> from envs.coding_env.server import CodeExecutionEnvironment + >>> from envs.coding_env.models import CodeAction, CodeObservation + >>> + >>> env = CodeExecutionEnvironment() + >>> app = create_fastapi_app(env, CodeAction, CodeObservation) + >>> + >>> # Run with: uvicorn module:app --host 0.0.0.0 --port 8000 + """ + try: + from fastapi import FastAPI + except ImportError: + raise ImportError( + "FastAPI is required. Install with: pip install fastapi uvicorn" + ) + + app = FastAPI(title="Environment HTTP Server") + server = HTTPEnvServer(env, action_cls, observation_cls) + server.register_routes(app) + return app diff --git a/src/core/env_server/interfaces.py b/src/core/env_server/interfaces.py new file mode 100644 index 0000000000000000000000000000000000000000..caa2d76db1b079f0c15277d1dac2db44e4173ac9 --- /dev/null +++ b/src/core/env_server/interfaces.py @@ -0,0 +1,118 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +from abc import ABC, abstractmethod +from typing import Any, Protocol, TypedDict + +from .types import Action, Observation, State + + +class Message(TypedDict): + """A message in a conversation. + + Compatible with Huggingface chat template format. + """ + + role: str + content: str + + +class ModelTokenizer(Protocol): + """Protocol for tokenizers that support chat templates. + + This protocol defines the interface that tokenizers must implement + to work with chat-based environments. It's compatible with + Huggingface transformers tokenizers. + """ + + def apply_chat_template( + self, + conversation: list[Message], + tokenize: bool = True, + return_tensors: str | None = None, + **kwargs: Any, + ) -> Any: + """Apply a chat template to format and optionally tokenize a conversation. + + Args: + conversation: List of message dictionaries with 'role' and 'content' + tokenize: Whether to tokenize the output + return_tensors: Format for returned tensors ('pt' for PyTorch) + **kwargs: Additional arguments + + Returns: + Formatted and optionally tokenized conversation + """ + ... + + def decode( + self, token_ids: Any, skip_special_tokens: bool = False, **kwargs: Any + ) -> str: + """Decode token IDs back to text. + + Args: + token_ids: Token IDs to decode + skip_special_tokens: Whether to skip special tokens in output + **kwargs: Additional arguments + + Returns: + Decoded text string + """ + ... + + +class Transform(ABC): + """Transform observations to add rewards, metrics, or other modifications. + + Transforms follow the TorchRL pattern where they take an observation + and return a (potentially modified) observation. This allows for + flexible reward computation and observation augmentation. + """ + + @abstractmethod + def __call__(self, observation: Observation) -> Observation: + """Transform an observation. + + Args: + observation: The input observation + + Returns: + The transformed observation + """ + pass + + +class Environment(ABC): + """Base class for all environment servers following Gym/Gymnasium API. + + Args: + transform: Optional transform to apply to observations + """ + + def __init__(self, transform: Transform | None = None): + self.transform = transform + + @abstractmethod + def reset(self) -> Observation: + """Reset the environment and return initial observation.""" + pass + + @abstractmethod + def step(self, action: Action) -> Observation: + """Take a step in the environment.""" + pass + + @property + @abstractmethod + def state(self) -> State: + """Get the current environment state.""" + pass + + def _apply_transform(self, observation: Observation) -> Observation: + """Apply transform if one is provided.""" + if self.transform is not None: + return self.transform(observation) + return observation diff --git a/src/core/env_server/types.py b/src/core/env_server/types.py new file mode 100644 index 0000000000000000000000000000000000000000..70da9f3ca2257ba6c27fb95a58db0a6ec37ccf3e --- /dev/null +++ b/src/core/env_server/types.py @@ -0,0 +1,57 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional, Union + + +# Type aliases +Scalar = Union[int, float, bool] + + +@dataclass(kw_only=True) +class Action: + """Base class for all environment actions.""" + + metadata: Dict[str, Any] = field(default_factory=dict) + + +@dataclass(kw_only=True) +class Observation: + """Base class for all environment observations.""" + + done: bool = False + reward: Union[bool, int, float, None] = None + metadata: Dict[str, Any] = field(default_factory=dict) + + +@dataclass +class State: + """Base class for environment state.""" + + episode_id: Optional[str] = None + step_count: int = 0 + + +@dataclass +class CodeExecResult: + """Result of code execution containing stdout, stderr, and exit code.""" + + stdout: str + stderr: str + exit_code: int + + +@dataclass +class EnvironmentMetadata: + """Metadata about an environment for documentation and UI purposes.""" + + name: str + description: str + readme_content: Optional[str] = None + version: Optional[str] = None + author: Optional[str] = None + documentation_url: Optional[str] = None diff --git a/src/core/env_server/web_interface.py b/src/core/env_server/web_interface.py new file mode 100644 index 0000000000000000000000000000000000000000..3c36aa1de336f86161429f8a382054ec89db1912 --- /dev/null +++ b/src/core/env_server/web_interface.py @@ -0,0 +1,1613 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +""" +Web interface for OpenEnv environments. + +This module provides a web-based interface for interacting with OpenEnv environments, +including a two-pane layout for HumanAgent interaction and state observation. +""" + +from __future__ import annotations + +import json +import time +from dataclasses import asdict, dataclass +from typing import Any, Dict, List, Optional, Type +from datetime import datetime + +from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Request +from fastapi.responses import HTMLResponse, FileResponse +from fastapi.staticfiles import StaticFiles +from pydantic import BaseModel + +from .interfaces import Environment +from .types import Action, Observation, State, EnvironmentMetadata + + +def load_environment_metadata(env: Environment, env_name: Optional[str] = None) -> EnvironmentMetadata: + """ + Load environment metadata including README content. + + Args: + env: The environment instance + env_name: Optional environment name for README file lookup + + Returns: + EnvironmentMetadata with loaded information + """ + # Try to get metadata from environment if it has a method for it + if hasattr(env, 'get_metadata'): + return env.get_metadata() + + # Default metadata + metadata = EnvironmentMetadata( + name=env_name or env.__class__.__name__, + description=f"{env.__class__.__name__} environment", + version="1.0.0" + ) + + # Try to load README from file system + readme_content = _load_readme_from_filesystem(env_name) + if readme_content: + metadata.readme_content = readme_content + + return metadata + + +def _load_readme_from_filesystem(env_name: Optional[str]) -> Optional[str]: + """ + Load README content from the filesystem. + + Tries multiple locations: + 1. Container filesystem: /app/README.md + 2. Local development: src/envs/{env_name}/README.md + 3. Environment variable: ENV_README_PATH + """ + import os + from pathlib import Path + + # Try container filesystem first + container_readme = Path("/app/README.md") + if container_readme.exists(): + try: + return container_readme.read_text(encoding='utf-8') + except Exception: + pass + + # Try environment variable path + custom_path = os.environ.get("ENV_README_PATH") + if custom_path and Path(custom_path).exists(): + try: + return Path(custom_path).read_text(encoding='utf-8') + except Exception: + pass + + # Try local development path + if env_name: + local_readme = Path(f"src/envs/{env_name}/README.md") + if local_readme.exists(): + try: + return local_readme.read_text(encoding='utf-8') + except Exception: + pass + + return None + + +@dataclass +class ActionLog: + """Log entry for an action taken.""" + timestamp: str + action: Dict[str, Any] + observation: Dict[str, Any] + reward: Optional[float] + done: bool + step_count: int + + +@dataclass +class EpisodeState: + """Current episode state for the web interface.""" + episode_id: Optional[str] + step_count: int + current_observation: Optional[Dict[str, Any]] + action_logs: List[ActionLog] + is_reset: bool = True + + +class WebInterfaceManager: + """Manages the web interface for an environment.""" + + def __init__( + self, + env: Environment, + action_cls: Type[Action], + observation_cls: Type[Observation], + metadata: Optional[EnvironmentMetadata] = None, + ): + self.env = env + self.action_cls = action_cls + self.observation_cls = observation_cls + self.metadata = metadata or EnvironmentMetadata( + name=env.__class__.__name__, + description=f"{env.__class__.__name__} environment" + ) + self.episode_state = EpisodeState( + episode_id=None, + step_count=0, + current_observation=None, + action_logs=[] + ) + self.connected_clients: List[WebSocket] = [] + + async def connect_websocket(self, websocket: WebSocket): + """Connect a new WebSocket client.""" + await websocket.accept() + self.connected_clients.append(websocket) + + # Send current state to the new client + await self._send_state_update() + + async def disconnect_websocket(self, websocket: WebSocket): + """Disconnect a WebSocket client.""" + if websocket in self.connected_clients: + self.connected_clients.remove(websocket) + + async def _send_state_update(self): + """Send current state to all connected clients.""" + if not self.connected_clients: + return + + state_data = { + "type": "state_update", + "episode_state": asdict(self.episode_state) + } + + # Send to all connected clients + disconnected_clients = [] + for client in self.connected_clients: + try: + await client.send_text(json.dumps(state_data)) + except: + disconnected_clients.append(client) + + # Remove disconnected clients + for client in disconnected_clients: + self.connected_clients.remove(client) + + async def reset_environment(self) -> Dict[str, Any]: + """Reset the environment and update state.""" + observation = self.env.reset() + state = self.env.state + + # Update episode state + self.episode_state.episode_id = state.episode_id + self.episode_state.step_count = 0 + self.episode_state.current_observation = asdict(observation) + self.episode_state.action_logs = [] + self.episode_state.is_reset = True + + # Send state update + await self._send_state_update() + + return { + "observation": asdict(observation), + "reward": observation.reward, + "done": observation.done, + } + + async def step_environment(self, action_data: Dict[str, Any]) -> Dict[str, Any]: + """Execute a step in the environment and update state.""" + # Deserialize action + action = self._deserialize_action(action_data) + + # Execute step + observation = self.env.step(action) + state = self.env.state + + # Create action log + action_log = ActionLog( + timestamp=datetime.now().isoformat(), + action=asdict(action), + observation=asdict(observation), + reward=observation.reward, + done=observation.done, + step_count=state.step_count + ) + + # Update episode state + self.episode_state.episode_id = state.episode_id + self.episode_state.step_count = state.step_count + self.episode_state.current_observation = asdict(observation) + self.episode_state.action_logs.append(action_log) + self.episode_state.is_reset = False + + # Send state update + await self._send_state_update() + + return { + "observation": asdict(observation), + "reward": observation.reward, + "done": observation.done, + } + + def get_state(self) -> Dict[str, Any]: + """Get current environment state.""" + state = self.env.state + return asdict(state) + + def _deserialize_action(self, action_data: Dict[str, Any]) -> Action: + """Convert JSON dict to Action instance.""" + metadata = action_data.pop("metadata", {}) + + # Handle tensor fields that come from JSON as lists + processed_data = {} + for key, value in action_data.items(): + if key == "tokens" and isinstance(value, (list, str)): + # Convert list or string to tensor + if isinstance(value, str): + # If it's a string, try to parse it as a list of numbers + try: + import json + value = json.loads(value) + except: + # If parsing fails, treat as empty list + value = [] + if isinstance(value, list): + import torch + processed_data[key] = torch.tensor(value, dtype=torch.long) + else: + processed_data[key] = value + elif key == "action_id" and isinstance(value, str): + # Convert action_id from string to int + try: + processed_data[key] = int(value) + except ValueError: + # If conversion fails, keep original value + processed_data[key] = value + else: + processed_data[key] = value + + action = self.action_cls(**processed_data) + action.metadata = metadata + return action + + +def create_web_interface_app( + env: Environment, + action_cls: Type[Action], + observation_cls: Type[Observation], + env_name: Optional[str] = None, +) -> FastAPI: + """ + Create a FastAPI application with web interface for the given environment. + + Args: + env: The Environment instance to serve + action_cls: The Action subclass this environment expects + observation_cls: The Observation subclass this environment returns + env_name: Optional environment name for README loading + + Returns: + FastAPI application instance with web interface + """ + from .http_server import create_fastapi_app + + # Create the base environment app + app = create_fastapi_app(env, action_cls, observation_cls) + + # Load environment metadata + metadata = load_environment_metadata(env, env_name) + + # Create web interface manager + web_manager = WebInterfaceManager(env, action_cls, observation_cls, metadata) + + # Add web interface routes + @app.get("/web", response_class=HTMLResponse) + async def web_interface(): + """Serve the web interface.""" + return get_web_interface_html(action_cls, web_manager.metadata) + + @app.get("/web/metadata") + async def web_metadata(): + """Get environment metadata.""" + return asdict(web_manager.metadata) + + @app.websocket("/ws") + async def websocket_endpoint(websocket: WebSocket): + """WebSocket endpoint for real-time updates.""" + await web_manager.connect_websocket(websocket) + try: + while True: + # Keep connection alive + await websocket.receive_text() + except WebSocketDisconnect: + await web_manager.disconnect_websocket(websocket) + + @app.post("/web/reset") + async def web_reset(): + """Reset endpoint for web interface.""" + return await web_manager.reset_environment() + + @app.post("/web/step") + async def web_step(request: Dict[str, Any]): + """Step endpoint for web interface.""" + # Check if this is a message-based request (chat environment) + if "message" in request: + message = request["message"] + # Convert message to action using the environment's message_to_action method + action = web_manager.env.message_to_action(message) + action_data = {"tokens": action.tokens.tolist()} + else: + action_data = request.get("action", {}) + + return await web_manager.step_environment(action_data) + + @app.get("/web/state") + async def web_state(): + """State endpoint for web interface.""" + return web_manager.get_state() + + return app + + +def get_web_interface_html(action_cls: Type[Action], metadata: Optional[EnvironmentMetadata] = None) -> str: + """Generate the HTML for the web interface.""" + + # Check if this is a chat environment by looking for tokens field + is_chat_env = False + if hasattr(action_cls, '__dataclass_fields__'): + for field_name, field_info in action_cls.__dataclass_fields__.items(): + if field_name == 'tokens' and hasattr(field_info.type, '__name__') and 'Tensor' in field_info.type.__name__: + is_chat_env = True + break + + # Get action fields for dynamic form generation with enhanced metadata + action_fields = _extract_action_fields(action_cls) + + return f""" + + + + + + OpenEnv Web Interface + + + +
    + +
    +
    + + HumanAgent Interface +
    +
    + + {_generate_instructions_section(metadata)} + + + {_generate_action_interface(action_fields, is_chat_env)} + + +
    + + +
    + + +
    +

    Current State

    +
    +
    + Status: + Not initialized +
    +
    + Episode ID: + - +
    +
    + Step Count: + 0 +
    +
    +
    +
    +
    + + +
    +
    + State Observer +
    +
    + +
    +

    Current Observation

    +
    + No observation yet +
    +
    + + +
    +

    Action History

    +
    + No actions taken yet +
    +
    +
    +
    +
    + + + + + """.replace('{_generate_action_form_fields(action_fields)}', _generate_action_form_fields(action_fields)) + + +def _generate_instructions_section(metadata: Optional[EnvironmentMetadata]) -> str: + """Generate the instructions section with environment documentation.""" + if not metadata or not metadata.readme_content: + return '' + + # Convert markdown to HTML (basic conversion) + import re + html_content = _markdown_to_html(metadata.readme_content) + + return f''' + +
    +
    +

    {metadata.name}

    + +
    +
    +
    + {html_content} +
    +
    +
    + ''' + + +def _extract_action_fields(action_cls: Type[Action]) -> List[Dict[str, Any]]: + """Extract enhanced field metadata from Action class for form generation.""" + import typing + from typing import get_origin, get_args + + action_fields = [] + if not hasattr(action_cls, '__dataclass_fields__'): + return action_fields + + for field_name, field_info in action_cls.__dataclass_fields__.items(): + if field_name == 'metadata': + continue + + field_type = field_info.type + field_metadata = _extract_field_metadata(field_name, field_info) + + # Determine input type based on field type + input_type = _determine_input_type(field_type) + + # Check if field is required + is_required = field_info.default is field_info.default_factory + + action_fields.append({ + 'name': field_name, + 'type': input_type, + 'required': is_required, + 'description': field_metadata.get('description', ''), + 'default_value': field_metadata.get('default_value'), + 'choices': field_metadata.get('choices', []), + 'min_value': field_metadata.get('min_value'), + 'max_value': field_metadata.get('max_value'), + 'placeholder': field_metadata.get('placeholder', ''), + 'help_text': field_metadata.get('help_text', ''), + }) + + return action_fields + + +def _extract_field_metadata(field_name: str, field_info) -> Dict[str, Any]: + """Extract metadata from dataclass field including docstring and type hints.""" + import typing + from typing import get_origin, get_args, Literal, Union, Optional + + metadata = {} + + # Extract description from field docstring or annotation + if hasattr(field_info, 'metadata') and field_info.metadata: + # Check for custom metadata + for meta in field_info.metadata: + if isinstance(meta, dict): + metadata.update(meta) + + # Extract type information + field_type = field_info.type + origin = get_origin(field_type) + + # Handle Literal types for dropdown choices + if origin is Literal: + args = get_args(field_type) + metadata['choices'] = list(args) + + # Handle Optional types + if origin is Union: + args = get_args(field_type) + if len(args) == 2 and type(None) in args: + # This is Optional[SomeType] + non_none_type = args[0] if args[1] is type(None) else args[1] + metadata['optional'] = True + # Recursively check the non-None type for choices + if get_origin(non_none_type) is Literal: + metadata['choices'] = list(get_args(non_none_type)) + else: + # Regular Union type + metadata['choices'] = [str(arg) for arg in args if arg is not type(None)] + + # Handle numeric constraints + if field_type in (int, float): + # Check for common constraint patterns in field name + if 'count' in field_name.lower() or 'num' in field_name.lower(): + metadata['min_value'] = 0 + if 'id' in field_name.lower(): + metadata['min_value'] = 0 + + # Generate placeholder text + if 'message' in field_name.lower(): + metadata['placeholder'] = f'Enter {field_name.replace("_", " ")}...' + elif 'code' in field_name.lower(): + metadata['placeholder'] = 'Enter Python code here...' + elif 'tokens' in field_name.lower(): + metadata['placeholder'] = 'Enter comma-separated token IDs (e.g., 1,2,3,4,5)' + else: + metadata['placeholder'] = f'Enter {field_name.replace("_", " ")}...' + + # Generate help text based on field name and type + if 'action_id' in field_name.lower(): + metadata['help_text'] = 'The action ID to execute in the environment' + elif 'game_name' in field_name.lower(): + metadata['help_text'] = 'Name of the game or environment' + elif 'tokens' in field_name.lower(): + metadata['help_text'] = 'Token IDs as a comma-separated list of integers' + elif 'code' in field_name.lower(): + metadata['help_text'] = 'Python code to execute in the environment' + elif 'message' in field_name.lower(): + metadata['help_text'] = 'Text message to send' + + return metadata + + +def _determine_input_type(field_type) -> str: + """Determine the appropriate HTML input type for a field type.""" + import typing + from typing import get_origin, get_args, Literal, Union + + # Handle direct types + if field_type == str: + return "text" + elif field_type == int: + return "number" + elif field_type == float: + return "number" + elif field_type == bool: + return "checkbox" + + # Handle complex types + origin = get_origin(field_type) + + if origin is Literal: + return "select" + elif origin is Union: + args = get_args(field_type) + if len(args) == 2 and type(None) in args: + # Optional type - use the non-None type + non_none_type = args[0] if args[1] is type(None) else args[1] + return _determine_input_type(non_none_type) + elif all(isinstance(arg, str) for arg in args if arg is not type(None)): + return "select" + else: + return "text" + elif hasattr(field_type, '__name__') and 'Tensor' in field_type.__name__: + return "tensor" + else: + return "text" + + +def _markdown_to_html(markdown: str) -> str: + """Convert basic markdown to HTML for README display.""" + import html + import re + + # Escape HTML first + html_content = html.escape(markdown) + + # Convert headers + html_content = re.sub(r'^# (.*?)$', r'

    \1

    ', html_content, flags=re.MULTILINE) + html_content = re.sub(r'^## (.*?)$', r'

    \1

    ', html_content, flags=re.MULTILINE) + html_content = re.sub(r'^### (.*?)$', r'

    \1

    ', html_content, flags=re.MULTILINE) + + # Convert code blocks + html_content = re.sub(r'```(.*?)\n(.*?)\n```', r'
    \2
    ', html_content, flags=re.DOTALL) + html_content = re.sub(r'`([^`]+)`', r'\1', html_content) + + # Convert bold and italic + html_content = re.sub(r'\*\*(.*?)\*\*', r'\1', html_content) + html_content = re.sub(r'\*(.*?)\*', r'\1', html_content) + + # Convert lists + html_content = re.sub(r'^- (.*?)$', r'
  • \1
  • ', html_content, flags=re.MULTILINE) + html_content = re.sub(r'(
  • .*
  • )', r'', html_content, flags=re.DOTALL) + + # Convert line breaks + html_content = html_content.replace('\n', '
    ') + + return html_content + + +def _generate_action_interface(action_fields: List[Dict[str, Any]], is_chat_env: bool) -> str: + """Generate either a chat interface or action form based on environment type.""" + if is_chat_env: + return _generate_chat_interface() + else: + return _generate_action_form(action_fields) + +def _generate_chat_interface() -> str: + """Generate a chat-style interface for chat environments.""" + return ''' + +
    +

    Chat Interface

    +
    +
    +
    System
    +
    Chat environment ready. Send a message to start the conversation.
    +
    +
    +
    +
    + + +
    +
    + + +
    +
    +
    + ''' + +def _generate_action_form(action_fields: List[Dict[str, Any]]) -> str: + """Generate a traditional action form for non-chat environments.""" + return f''' + +
    +

    Take Action

    +
    + {_generate_action_form_fields(action_fields)} + +
    +
    + ''' + +def _generate_action_form_fields(action_fields: List[Dict[str, Any]]) -> str: + """Generate HTML form fields for action input with enhanced metadata.""" + if not action_fields: + return '

    No action fields available

    ' + + fields_html = [] + for field in action_fields: + field_html = _generate_single_field(field) + fields_html.append(field_html) + + return '\n'.join(fields_html) + + +def _generate_single_field(field: Dict[str, Any]) -> str: + """Generate HTML for a single form field with enhanced metadata.""" + field_name = field['name'] + field_type = field['type'] + required = field['required'] + placeholder = field.get('placeholder', '') + help_text = field.get('help_text', '') + choices = field.get('choices', []) + min_value = field.get('min_value') + max_value = field.get('max_value') + default_value = field.get('default_value') + + # Build label with required indicator + label_text = field_name.replace('_', ' ').title() + if required: + label_text += ' *' + + # Build input attributes + input_attrs = [] + if required: + input_attrs.append('required') + if placeholder: + input_attrs.append(f'placeholder="{placeholder}"') + if min_value is not None: + input_attrs.append(f'min="{min_value}"') + if max_value is not None: + input_attrs.append(f'max="{max_value}"') + if default_value is not None: + input_attrs.append(f'value="{default_value}"') + + attrs_str = ' '.join(input_attrs) + + if field_type == 'checkbox': + return f''' +
    + + {f'{help_text}' if help_text else ''} +
    + ''' + + elif field_type == 'select': + options_html = [] + if not required: + options_html.append(f'') + + for choice in choices: + selected = 'selected' if str(choice) == str(default_value) else '' + options_html.append(f'') + + return f''' +
    + + + {f'{help_text}' if help_text else ''} +
    + ''' + + elif field_type == 'tensor': + return f''' +
    + + + {help_text or 'Enter token IDs as comma-separated integers (e.g., 1,2,3,4,5)'} +
    + ''' + + elif field_type == 'text' and ('message' in field_name.lower() or 'code' in field_name.lower()): + return f''' +
    + + + {f'{help_text}' if help_text else ''} +
    + ''' + + else: + return f''' +
    + + + {f'{help_text}' if help_text else ''} +
    + ''' diff --git a/src/core/http_env_client.py b/src/core/http_env_client.py new file mode 100644 index 0000000000000000000000000000000000000000..dce46c8c5d6487792d6405f234bc71f35eef211c --- /dev/null +++ b/src/core/http_env_client.py @@ -0,0 +1,250 @@ +""" +core/runner_env.py +Minimal HTTP-based environment client. +- Talks to a single env worker exposing: POST /reset, POST /step + +Future hooks (commented below) for: +- episode_id, seed on reset +- request_id on step +- custom headers (auth/trace) +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Any, Dict, Generic, Optional, Type, TYPE_CHECKING, TypeVar + +import requests + +from .client_types import StepResult +from .containers.runtime.uv_provider import UVProvider +from .containers.runtime import LocalDockerProvider + +if TYPE_CHECKING: + from .containers.runtime import ContainerProvider + +ActT = TypeVar("ActT") +ObsT = TypeVar("ObsT") +EnvClientT = TypeVar("EnvClientT", bound="HTTPEnvClient") + + +class HTTPEnvClient(ABC, Generic[ActT, ObsT]): + def __init__( + self, + base_url: str, + request_timeout_s: float = 15.0, + default_headers: Optional[Dict[str, str]] = None, + provider: Optional["ContainerProvider"] = None, + ): + self._base = base_url.rstrip("/") + self._timeout = float(request_timeout_s) + self._http = requests.Session() + self._headers = default_headers or {} + self._provider = provider + + @classmethod + def from_docker_image( + cls: Type[EnvClientT], + image: str, + provider: Optional["ContainerProvider"] = None, + **kwargs: Any, + ) -> EnvClientT: + """ + Create an environment client by spinning up a Docker container locally. + + This is a development utility that: + 1. Starts a Docker container from the specified image + 2. Waits for the server to be ready + 3. Creates and returns a client instance connected to the container + + Note: + The caller or a higher-level orchestrator manages the container + lifecycle. The container continues running until it is stopped. + + Args: + image: Docker image name to run (e.g., "echo-env:latest") + provider: Container provider to use (defaults to + ``LocalDockerProvider``) + **kwargs: Additional arguments passed to + ``provider.start_container()`` (e.g., env_vars, port) + + Returns: + An instance of the client class connected to the running container + + Example: + >>> from envs.coding_env.client import CodingEnv + >>> from envs.coding_env.models import CodeAction + >>> + >>> # Create environment from image + >>> env = CodingEnv.from_docker_image("coding-env:latest") + >>> + >>> # Create environment with custom env vars + >>> env = CodingEnv.from_docker_image( + ... "coding-env:latest", + ... env_vars={"MY_VAR": "value"} + ... ) + >>> + >>> # Use the environment + >>> result = env.reset() + >>> print(result.observation) + >>> + >>> step_result = env.step(CodeAction(code="print('hello')")) + >>> print(step_result.observation.stdout) + >>> + >>> # Cleanup (optional) + >>> env.close() + """ + + # Use default provider if none provided + if provider is None: + provider = LocalDockerProvider() + + # 1. Start container with optional kwargs (e.g., env_vars, port) + base_url = provider.start_container(image, **kwargs) + + # 2. Wait for server to be ready + provider.wait_for_ready(base_url) + + # 3. Create and return client instance with provider reference + return cls(base_url=base_url, provider=provider) + + @classmethod + def from_hub( + cls: Type[EnvClientT], + space_id: str, + *, + use_docker: bool = False, + provider: Optional["ContainerProvider"] = None, + host: str = "0.0.0.0", + port: Optional[int] = None, + reload: bool = False, + timeout_s: float = 60.0, + runner: Optional[UVProvider] = None, + project_url: Optional[str] = None, + connect_host: Optional[str] = None, + extra_env: Optional[Dict[str, str]] = None, + **provider_kwargs: Any, + ) -> EnvClientT: + """Create a client from a Hugging Face Space.""" + + if use_docker: + if provider is None: + provider = LocalDockerProvider() + + tag = provider_kwargs.pop("tag", "latest") + image = provider_kwargs.pop( + "image", + f"registry.hf.space/{space_id.replace('/', '-')}:{tag}", + ) + + base_url = provider.start_container(image, **provider_kwargs) + provider.wait_for_ready(base_url, timeout_s=timeout_s) + return cls(base_url=base_url, provider=provider) + + uv_runner = runner or UVProvider( + space_id=space_id, + host=host, + port=port, + reload=reload, + project_url=project_url, + connect_host=connect_host, + extra_env=extra_env, + ) + + non_docker_kwargs = dict(provider_kwargs) + env_vars = non_docker_kwargs.pop("env_vars", None) + + base_url = uv_runner.start_container( + space_id, + port=port, + env_vars=env_vars, + **non_docker_kwargs, + ) + + try: + uv_runner.wait_for_ready(base_url, timeout_s=timeout_s) + except Exception: + uv_runner.stop_container() + raise + + return cls(base_url=base_url, provider=uv_runner) + + @abstractmethod + def _step_payload(self, action: ActT) -> dict: + """Convert an action to the JSON payload expected by the server.""" + raise NotImplementedError + + @abstractmethod + def _parse_result(self, payload: dict) -> StepResult[ObsT]: + """Convert a JSON response into :class:`StepResult`.""" + raise NotImplementedError + + @abstractmethod + def _parse_state(self, payload: dict) -> Any: + """Convert state JSON into a :class:`State` object.""" + raise NotImplementedError + + # ---------- Environment Server Interface Methods ---------- + def reset(self) -> StepResult[ObsT]: + body: Dict[str, Any] = {} + # TODO: later: + # body["seed"] = seed + # body["episode_id"] = episode_id + r = self._http.post( + f"{self._base}/reset", + json=body, + headers=self._headers, + timeout=self._timeout, + ) + r.raise_for_status() + return self._parse_result(r.json()) + + def step(self, action: ActT) -> StepResult[ObsT]: + body: Dict[str, Any] = { + "action": self._step_payload(action), + "timeout_s": int(self._timeout), + } + # TODO: later: + # body["request_id"] = str(uuid.uuid4()) + # body["episode_id"] = current_episode_id + r = self._http.post( + f"{self._base}/step", + json=body, + headers=self._headers, + timeout=self._timeout, + ) + r.raise_for_status() + return self._parse_result(r.json()) + + def state(self) -> Any: + """ + Get the current environment state from the server. + + Returns: + State object with environment state information (e.g., + episode_id, step_count) + + Example: + >>> client = EchoEnv.from_docker_image("echo-env:latest") + >>> result = client.reset() + >>> state = client.state() + >>> print(state.episode_id) + >>> print(state.step_count) + """ + r = self._http.get( + f"{self._base}/state", + headers=self._headers, + timeout=self._timeout, + ) + r.raise_for_status() + return self._parse_state(r.json()) + + def close(self) -> None: + """ + Close the environment and clean up resources. + + If this client was created via from_docker_image(), this will stop + and remove the associated container. + """ + if self._provider is not None: + self._provider.stop_container() diff --git a/src/core/pyproject.toml b/src/core/pyproject.toml new file mode 100644 index 0000000000000000000000000000000000000000..32602f58cb6f066e6874f977f1838483a2ea931b --- /dev/null +++ b/src/core/pyproject.toml @@ -0,0 +1,46 @@ +[build-system] +requires = ["setuptools>=45", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "openenv-core" +version = "0.1.0" +description = "Core components for OpenEnv - HTTP-based agentic environments" +readme = "README.md" +requires-python = ">=3.8" +license = {text = "BSD-3-Clause"} +authors = [ + {name = "Meta Platforms, Inc.", email = "opensource@meta.com"} +] +keywords = ["environment", "agent", "http", "docker", "fastapi"] + +dependencies = [ + "requests>=2.25.0", + "fastapi>=0.104.0", + "uvicorn>=0.24.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=7.0.0", + "black>=23.0.0", + "ruff>=0.1.0", + "mypy>=1.0.0", +] + +[project.urls] +Homepage = "https://github.com/facebookresearch/OpenEnv" +Repository = "https://github.com/facebookresearch/OpenEnv" +Documentation = "https://github.com/facebookresearch/OpenEnv/blob/main/README.md" +"Bug Tracker" = "https://github.com/facebookresearch/OpenEnv/issues" + +[tool.setuptools] +py-modules = ["openenv_core.__init__", "openenv_core.http_env_client", "openenv_core.client_types"] +packages = [ + "openenv_core", + "openenv_core.containers", + "openenv_core.containers.runtime", + "openenv_core.env_server", + "openenv_core.tools" +] +package-dir = {"openenv_core" = "."} diff --git a/src/core/tools/__init__.py b/src/core/tools/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..034e7f0686b81bcdda4f30cf1e32b86cd4fa2bf5 --- /dev/null +++ b/src/core/tools/__init__.py @@ -0,0 +1,16 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +"""Core tools for code execution and other utilities.""" + +from .git_server_client import GitServerClient, RepoInfo +from .local_python_executor import PyExecutor + +__all__ = [ + "PyExecutor", + "GitServerClient", + "RepoInfo", +] \ No newline at end of file diff --git a/src/core/tools/git_server_client.py b/src/core/tools/git_server_client.py new file mode 100644 index 0000000000000000000000000000000000000000..31b1ed4c1806287cffe43ad2f93d24f44329d575 --- /dev/null +++ b/src/core/tools/git_server_client.py @@ -0,0 +1,362 @@ +#!/usr/bin/env python3 +""" +Git Server Client for connecting to external Gitea instance. + +This module provides a lightweight client for interacting with a shared +Gitea service, optimized for task-based isolation where multiple environment +instances share the same Gitea server but have isolated workspaces. +""" + +import json +import os +import shutil +import subprocess +import time +from dataclasses import dataclass +from pathlib import Path +from urllib.parse import urlparse + + +@dataclass +class RepoInfo: + """Information about a repository.""" + + name: str + url: str + commit: str + clone_url: str + + +class GitServerClient: + """ + Client for connecting to an external Gitea server. + + This client is optimized for task-based isolation where: + - Multiple tasks share the same Gitea instance + - Each task has its own isolated workspace + - Fast reset() via git operations (no server restart) + - Repos are pre-migrated to Gitea once + + Args: + gitea_url: URL of the Gitea server (e.g., "http://gitea:3000") + username: Gitea username for authentication + password: Gitea password for authentication + workspace_dir: Local workspace directory for cloning repos + + Example: + >>> # Connect to shared Gitea (credentials from environment) + >>> import os + >>> client = GitServerClient( + ... gitea_url=os.getenv("GITEA_URL"), + ... username=os.getenv("GITEA_USERNAME"), + ... password=os.getenv("GITEA_PASSWORD") + ... ) + >>> client.wait_for_ready() + >>> # Clone repo to workspace + >>> path = client.clone_to_workspace("my-repo", commit="abc123") + >>> # Fast reset to base state + >>> client.reset_workspace("my-repo", commit="abc123") + """ + + def __init__( + self, + gitea_url: str, + username: str, + password: str, + workspace_dir: str = "/workspace", + ): + """Initialize Git Server Client.""" + self.gitea_url = gitea_url.rstrip("/") + self.username = username + self.password = password + self.workspace_dir = Path(workspace_dir) + self.is_ready = False + + # Parse Gitea URL + parsed = urlparse(self.gitea_url) + self.domain = parsed.hostname or "localhost" + self.port = parsed.port or 3000 + + # Ensure workspace exists + os.makedirs(self.workspace_dir, exist_ok=True) + + # Configure git credentials + self._configure_git() + + def _configure_git(self): + """Configure git credentials for automatic authentication.""" + home_dir = Path.home() + + # Git config + git_config = f"""[user] + name = {self.username} + email = {self.username}@local.env +[init] + defaultBranch = main +[credential] + helper = store +""" + gitconfig_path = home_dir / ".gitconfig" + gitconfig_path.write_text(git_config) + + # Git credentials + git_credentials = f"http://{self.username}:{self.password}@{self.domain}:{self.port}\n" + gitcreds_path = home_dir / ".git-credentials" + gitcreds_path.write_text(git_credentials) + gitcreds_path.chmod(0o600) + + def wait_for_ready(self, timeout: int = 30) -> bool: + """ + Wait for Gitea server to be ready. + + Args: + timeout: Maximum seconds to wait + + Returns: + True if server is ready, False otherwise + """ + start_time = time.time() + while time.time() - start_time < timeout: + try: + result = subprocess.run( + ["curl", "-sf", f"{self.gitea_url}/"], + capture_output=True, + timeout=5, + ) + if result.returncode == 0: + self.is_ready = True + return True + except subprocess.TimeoutExpired: + pass + except Exception: + pass + + time.sleep(1) + + return False + + def list_repositories(self) -> list[dict[str, str]]: + """ + List all repositories in Gitea. + + Returns: + List of repository information dictionaries + """ + if not self.is_ready: + raise RuntimeError("Gitea server is not ready") + + result = subprocess.run( + [ + "curl", + "-s", + f"{self.gitea_url}/api/v1/user/repos", + "-u", + f"{self.username}:{self.password}", + ], + capture_output=True, + text=True, + ) + + if result.returncode != 0: + return [] + + try: + repos = json.loads(result.stdout) + return [ + { + "name": repo["name"], + "full_name": repo["full_name"], + "clone_url": repo["clone_url"], + "description": repo.get("description", ""), + } + for repo in repos + ] + except (json.JSONDecodeError, KeyError): + return [] + + def clone_to_workspace( + self, repo_name: str, target_dir: str | None = None, commit: str = "main" + ) -> str: + """ + Clone a repository to the workspace at a specific commit. + + This creates a fresh clone optimized for task isolation. + + Args: + repo_name: Name of repository to clone + target_dir: Target directory name (defaults to repo_name) + commit: Commit hash or branch to checkout + + Returns: + Path to cloned repository + + Raises: + RuntimeError: If clone fails + """ + if not self.is_ready: + raise RuntimeError("Gitea server is not ready") + + target_dir = target_dir or repo_name + target_path = self.workspace_dir / target_dir + + # Remove existing directory if present + if target_path.exists(): + shutil.rmtree(target_path) + + clone_url = f"{self.gitea_url}/{self.username}/{repo_name}.git" + + # Clone repository + result = subprocess.run( + ["git", "clone", clone_url, str(target_path)], + capture_output=True, + text=True, + ) + + if result.returncode != 0: + raise RuntimeError(f"Clone failed: {result.stderr}") + + # Checkout specific commit + if commit != "main": + result = subprocess.run( + ["git", "checkout", commit], + cwd=str(target_path), + capture_output=True, + text=True, + ) + + if result.returncode != 0: + raise RuntimeError(f"Checkout failed: {result.stderr}") + + return str(target_path) + + def reset_workspace(self, repo_name: str, commit: str = "main") -> bool: + """ + Fast reset of workspace to base state (optimized for task resets). + + This is much faster than re-cloning. It: + 1. Checks out the target commit + 2. Resets to that commit (hard) + 3. Cleans untracked files + + Args: + repo_name: Name of repository (directory in workspace) + commit: Commit hash or branch to reset to + + Returns: + True if reset successful + + Raises: + RuntimeError: If reset fails + """ + repo_path = self.workspace_dir / repo_name + + if not repo_path.exists(): + raise RuntimeError(f"Repository not found in workspace: {repo_name}") + + # Fetch latest (in case commit is new) + subprocess.run( + ["git", "fetch", "--all"], + cwd=str(repo_path), + capture_output=True, + ) + + # Checkout and hard reset to commit + result = subprocess.run( + ["git", "checkout", commit], + cwd=str(repo_path), + capture_output=True, + text=True, + ) + + if result.returncode != 0: + raise RuntimeError(f"Checkout failed: {result.stderr}") + + result = subprocess.run( + ["git", "reset", "--hard", f"origin/{commit}" if commit != "main" else commit], + cwd=str(repo_path), + capture_output=True, + text=True, + ) + + if result.returncode != 0: + # Try without origin/ prefix + result = subprocess.run( + ["git", "reset", "--hard", commit], + cwd=str(repo_path), + capture_output=True, + text=True, + ) + if result.returncode != 0: + raise RuntimeError(f"Reset failed: {result.stderr}") + + # Clean untracked files and directories + subprocess.run( + ["git", "clean", "-fdx"], + cwd=str(repo_path), + capture_output=True, + ) + + return True + + def execute_git_command( + self, command: str, working_dir: str = "" + ) -> tuple[int, str, str]: + """ + Execute a git command in the workspace. + + Args: + command: Git command to execute (without 'git' prefix) + working_dir: Working directory relative to workspace + + Returns: + Tuple of (exit_code, stdout, stderr) + """ + work_path = ( + self.workspace_dir / working_dir if working_dir else self.workspace_dir + ) + + if not work_path.exists(): + return (1, "", f"Working directory does not exist: {work_path}") + + # Split command safely + cmd_parts = ["git"] + command.split() + + result = subprocess.run( + cmd_parts, + cwd=str(work_path), + capture_output=True, + text=True, + ) + + return (result.returncode, result.stdout, result.stderr) + + def get_current_commit(self, repo_name: str) -> str: + """ + Get current commit hash of a workspace repository. + + Args: + repo_name: Name of repository in workspace + + Returns: + Commit hash + """ + repo_path = self.workspace_dir / repo_name + + if not repo_path.exists(): + raise RuntimeError(f"Repository not found: {repo_name}") + + result = subprocess.run( + ["git", "rev-parse", "HEAD"], + cwd=str(repo_path), + capture_output=True, + text=True, + ) + + if result.returncode != 0: + raise RuntimeError(f"Failed to get commit: {result.stderr}") + + return result.stdout.strip() + + def workspace_exists(self, repo_name: str) -> bool: + """Check if a repository exists in workspace.""" + return (self.workspace_dir / repo_name).exists() diff --git a/src/core/tools/local_python_executor.py b/src/core/tools/local_python_executor.py new file mode 100644 index 0000000000000000000000000000000000000000..ba4477d52f3f0f295ec568dc21d807a711ea2cc5 --- /dev/null +++ b/src/core/tools/local_python_executor.py @@ -0,0 +1,105 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +""" +Local Python Executor. + +This module provides functionality for executing Python code locally by wrapping +the smolagents LocalPythonExecutor. +""" + +from smolagents import LocalPythonExecutor + +from core.env_server.types import CodeExecResult + + +class PyExecutor: + """ + Wrapper around smolagents LocalPythonExecutor for executing Python code. + + This class provides a simple interface to execute Python code in a subprocess + and capture the results including stdout, stderr, and exit code. + + Args: + additional_imports: List of additional module imports to authorize. + For example: ["numpy", "pandas", "matplotlib"] + These will be added to the base authorized imports. + + Example: + >>> # Basic usage with default imports + >>> executor = PyExecutor() + >>> result = executor.run("print('Hello, World!')") + >>> print(result.stdout) # "Hello, World!\n" + >>> print(result.exit_code) # 0 + >>> + >>> # Usage with additional imports + >>> executor = PyExecutor(additional_imports=["numpy", "pandas"]) + >>> result = executor.run("import numpy as np\\nprint(np.array([1, 2, 3]))") + >>> print(result.stdout) # "[1 2 3]\n" + """ + + def __init__(self, additional_imports: list[str] | None = None): + """ + Initialize the PyExecutor with a LocalPythonExecutor instance. + + Args: + additional_imports: List of additional module names to authorize for import. + Defaults to an empty list if not provided. + """ + if additional_imports is None: + additional_imports = [] + self._executor = LocalPythonExecutor( + additional_authorized_imports=additional_imports + ) + # Initialize tools to make BASE_PYTHON_TOOLS available (including print) + self._executor.send_tools({}) + + def run(self, code: str) -> CodeExecResult: + """ + Execute Python code and return the result. + + Args: + code: Python code string to execute + + Returns: + CodeExecResult containing stdout, stderr, and exit_code + + Example: + >>> executor = PyExecutor() + >>> result = executor.run("x = 5 + 3\\nprint(x)") + >>> print(result.stdout) # "8\n" + >>> print(result.exit_code) # 0 + >>> + >>> # Error handling + >>> result = executor.run("1 / 0") + >>> print(result.exit_code) # 1 + >>> print(result.stderr) # Contains error message + """ + try: + # Execute the code using LocalPythonExecutor + # LocalPythonExecutor returns a CodeOutput object with output, logs, is_final_answer + exec_result = self._executor(code) + + # Extract the logs (which contain print outputs) as stdout + # The output field contains the return value of the code + stdout = exec_result.logs + stderr = "" + exit_code = 0 # Success + + return CodeExecResult( + stdout=stdout, + stderr=stderr, + exit_code=exit_code, + ) + + except Exception as e: + # LocalPythonExecutor raises InterpreterError for various issues + # (syntax errors, forbidden operations, runtime errors, etc.) + return CodeExecResult( + stdout="", + stderr=str(e), + exit_code=1, # Non-zero indicates error + ) diff --git a/src/echo_cli.egg-info/PKG-INFO b/src/echo_cli.egg-info/PKG-INFO new file mode 100644 index 0000000000000000000000000000000000000000..6cbab959152ba6fb623c604b9aa4fcd43e20b063 --- /dev/null +++ b/src/echo_cli.egg-info/PKG-INFO @@ -0,0 +1,153 @@ +Metadata-Version: 2.4 +Name: echo-cli +Version: 0.1.0 +Summary: Add your description here +Requires-Python: >=3.10 +Description-Content-Type: text/markdown + +--- +title: Echo Environment Server +emoji: 🔊 +colorFrom: blue +colorTo: yellow +sdk: docker +pinned: false +app_port: 8000 +base_path: /web +tags: + - openenv +--- + +# Echo Environment + +A simple test environment that echoes back messages. Perfect for testing the env APIs as well as demonstrating environment usage patterns. + +## Quick Start + +The simplest way to use the Echo environment is through the `EchoEnv` class: + +```python +from envs.echo_env import EchoAction, EchoEnv + +try: + # Create environment from Docker image + echo_env = EchoEnv.from_docker_image("echo-env:latest") + + # Reset + result = echo_env.reset() + print(f"Reset: {result.observation.echoed_message}") + + # Send multiple messages + messages = ["Hello, World!", "Testing echo", "Final message"] + + for msg in messages: + result = echo_env.step(EchoAction(message=msg)) + print(f"Sent: '{msg}'") + print(f" → Echoed: '{result.observation.echoed_message}'") + print(f" → Length: {result.observation.message_length}") + print(f" → Reward: {result.reward}") + +finally: + # Always clean up + echo_env.close() +``` + +That's it! The `EchoEnv.from_docker_image()` method handles: +- Starting the Docker container +- Waiting for the server to be ready +- Connecting to the environment +- Container cleanup when you call `close()` + +## Building the Docker Image + +Before using the environment, you need to build the Docker image: + +```bash +# From project root +docker build -t echo-env:latest -f src/envs/echo_env/server/Dockerfile . +``` + +## Environment Details + +### Action +**EchoAction**: Contains a single field +- `message` (str) - The message to echo back + +### Observation +**EchoObservation**: Contains the echo response and metadata +- `echoed_message` (str) - The message echoed back +- `message_length` (int) - Length of the message +- `reward` (float) - Reward based on message length (length × 0.1) +- `done` (bool) - Always False for echo environment +- `metadata` (dict) - Additional info like step count + +### Reward +The reward is calculated as: `message_length × 0.1` +- "Hi" → reward: 0.2 +- "Hello, World!" → reward: 1.3 +- Empty message → reward: 0.0 + +## Advanced Usage + +### Connecting to an Existing Server + +If you already have an Echo environment server running, you can connect directly: + +```python +from envs.echo_env import EchoEnv + +# Connect to existing server +echo_env = EchoEnv(base_url="") + +# Use as normal +result = echo_env.reset() +result = echo_env.step(EchoAction(message="Hello!")) +``` + +Note: When connecting to an existing server, `echo_env.close()` will NOT stop the server. + +## Development & Testing + +### Direct Environment Testing + +Test the environment logic directly without starting the HTTP server: + +```bash +# From the server directory +python3 src/envs/echo_env/server/test_echo_env.py +``` + +This verifies that: +- Environment resets correctly +- Step executes actions properly +- State tracking works +- Rewards are calculated correctly + +### Running the Full Example + +Run the complete example that demonstrates the full workflow: + +```bash +python3 examples/local_echo_env.py +``` + +This example shows: +- Creating an environment from a Docker image +- Resetting and stepping through the environment +- Automatic cleanup with `close()` + +## Project Structure + +``` +echo_env/ +├── __init__.py # Module exports +├── README.md # This file +├── client.py # EchoEnv client implementation +├── models.py # Action and Observation models +└── server/ + ├── __init__.py # Server module exports + ├── echo_environment.py # Core environment logic + ├── app.py # FastAPI application + ├── test_echo_env.py # Direct environment tests + └── Dockerfile # Container image definition +``` diff --git a/src/echo_cli.egg-info/SOURCES.txt b/src/echo_cli.egg-info/SOURCES.txt new file mode 100644 index 0000000000000000000000000000000000000000..2ad1304440d461379f744c58f25a89de1f6e696c --- /dev/null +++ b/src/echo_cli.egg-info/SOURCES.txt @@ -0,0 +1,47 @@ +README.md +pyproject.toml +src/core/__init__.py +src/core/client_types.py +src/core/http_env_client.py +src/core/containers/__init__.py +src/core/containers/test_local_docker_provider.py +src/core/containers/runtime/__init__.py +src/core/containers/runtime/providers.py +src/core/containers/runtime/uv_provider.py +src/core/core/__init__.py +src/core/core/client_types.py +src/core/core/http_env_client.py +src/core/core/containers/__init__.py +src/core/core/containers/test_local_docker_provider.py +src/core/core/containers/runtime/__init__.py +src/core/core/containers/runtime/providers.py +src/core/core/containers/runtime/uv_provider.py +src/core/core/env_server/__init__.py +src/core/core/env_server/base_transforms.py +src/core/core/env_server/http_server.py +src/core/core/env_server/interfaces.py +src/core/core/env_server/types.py +src/core/core/env_server/web_interface.py +src/core/core/tools/__init__.py +src/core/core/tools/git_server_client.py +src/core/core/tools/local_python_executor.py +src/core/env_server/__init__.py +src/core/env_server/base_transforms.py +src/core/env_server/http_server.py +src/core/env_server/interfaces.py +src/core/env_server/types.py +src/core/env_server/web_interface.py +src/core/tools/__init__.py +src/core/tools/git_server_client.py +src/core/tools/local_python_executor.py +src/echo_cli.egg-info/PKG-INFO +src/echo_cli.egg-info/SOURCES.txt +src/echo_cli.egg-info/dependency_links.txt +src/echo_cli.egg-info/entry_points.txt +src/echo_cli.egg-info/top_level.txt +src/envs/echo_env/__init__.py +src/envs/echo_env/client.py +src/envs/echo_env/models.py +src/envs/echo_env/server/__init__.py +src/envs/echo_env/server/app.py +src/envs/echo_env/server/echo_environment.py \ No newline at end of file diff --git a/src/echo_cli.egg-info/dependency_links.txt b/src/echo_cli.egg-info/dependency_links.txt new file mode 100644 index 0000000000000000000000000000000000000000..8b137891791fe96927ad78e64b0aad7bded08bdc --- /dev/null +++ b/src/echo_cli.egg-info/dependency_links.txt @@ -0,0 +1 @@ + diff --git a/src/echo_cli.egg-info/entry_points.txt b/src/echo_cli.egg-info/entry_points.txt new file mode 100644 index 0000000000000000000000000000000000000000..76f6e15bc9f79fbc03cac586011671b0e863748a --- /dev/null +++ b/src/echo_cli.egg-info/entry_points.txt @@ -0,0 +1,2 @@ +[console_scripts] +server = envs.echo_env.server.app:cli diff --git a/src/echo_cli.egg-info/top_level.txt b/src/echo_cli.egg-info/top_level.txt new file mode 100644 index 0000000000000000000000000000000000000000..68bdffaaae3d5ff0d7ba2f9b0f2876e3fc1900b2 --- /dev/null +++ b/src/echo_cli.egg-info/top_level.txt @@ -0,0 +1,2 @@ +core +envs diff --git a/src/envs/echo_env/README.md b/src/envs/echo_env/README.md new file mode 100644 index 0000000000000000000000000000000000000000..79855421eb2b874af3b40b1666d3ba8127cbff35 --- /dev/null +++ b/src/envs/echo_env/README.md @@ -0,0 +1,146 @@ +--- +title: Echo Environment Server +emoji: 🔊 +colorFrom: blue +colorTo: yellow +sdk: docker +pinned: false +app_port: 8000 +base_path: /web +tags: + - openenv +--- + +# Echo Environment + +A simple test environment that echoes back messages. Perfect for testing the env APIs as well as demonstrating environment usage patterns. + +## Quick Start + +The simplest way to use the Echo environment is through the `EchoEnv` class: + +```python +from envs.echo_env import EchoAction, EchoEnv + +try: + # Create environment from Docker image + echo_env = EchoEnv.from_docker_image("echo-env:latest") + + # Reset + result = echo_env.reset() + print(f"Reset: {result.observation.echoed_message}") + + # Send multiple messages + messages = ["Hello, World!", "Testing echo", "Final message"] + + for msg in messages: + result = echo_env.step(EchoAction(message=msg)) + print(f"Sent: '{msg}'") + print(f" → Echoed: '{result.observation.echoed_message}'") + print(f" → Length: {result.observation.message_length}") + print(f" → Reward: {result.reward}") + +finally: + # Always clean up + echo_env.close() +``` + +That's it! The `EchoEnv.from_docker_image()` method handles: +- Starting the Docker container +- Waiting for the server to be ready +- Connecting to the environment +- Container cleanup when you call `close()` + +## Building the Docker Image + +Before using the environment, you need to build the Docker image: + +```bash +# From project root +docker build -t echo-env:latest -f src/envs/echo_env/server/Dockerfile . +``` + +## Environment Details + +### Action +**EchoAction**: Contains a single field +- `message` (str) - The message to echo back + +### Observation +**EchoObservation**: Contains the echo response and metadata +- `echoed_message` (str) - The message echoed back +- `message_length` (int) - Length of the message +- `reward` (float) - Reward based on message length (length × 0.1) +- `done` (bool) - Always False for echo environment +- `metadata` (dict) - Additional info like step count + +### Reward +The reward is calculated as: `message_length × 0.1` +- "Hi" → reward: 0.2 +- "Hello, World!" → reward: 1.3 +- Empty message → reward: 0.0 + +## Advanced Usage + +### Connecting to an Existing Server + +If you already have an Echo environment server running, you can connect directly: + +```python +from envs.echo_env import EchoEnv + +# Connect to existing server +echo_env = EchoEnv(base_url="") + +# Use as normal +result = echo_env.reset() +result = echo_env.step(EchoAction(message="Hello!")) +``` + +Note: When connecting to an existing server, `echo_env.close()` will NOT stop the server. + +## Development & Testing + +### Direct Environment Testing + +Test the environment logic directly without starting the HTTP server: + +```bash +# From the server directory +python3 src/envs/echo_env/server/test_echo_env.py +``` + +This verifies that: +- Environment resets correctly +- Step executes actions properly +- State tracking works +- Rewards are calculated correctly + +### Running the Full Example + +Run the complete example that demonstrates the full workflow: + +```bash +python3 examples/local_echo_env.py +``` + +This example shows: +- Creating an environment from a Docker image +- Resetting and stepping through the environment +- Automatic cleanup with `close()` + +## Project Structure + +``` +echo_env/ +├── __init__.py # Module exports +├── README.md # This file +├── client.py # EchoEnv client implementation +├── models.py # Action and Observation models +└── server/ + ├── __init__.py # Server module exports + ├── echo_environment.py # Core environment logic + ├── app.py # FastAPI application + ├── test_echo_env.py # Direct environment tests + └── Dockerfile # Container image definition +``` diff --git a/src/envs/echo_env/__init__.py b/src/envs/echo_env/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..6da62ba47cf25c76a66ae83bec797329e86771f1 --- /dev/null +++ b/src/envs/echo_env/__init__.py @@ -0,0 +1,12 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +"""Echo Environment - A simple test environment for HTTP server.""" + +from .client import EchoEnv +from .models import EchoAction, EchoObservation + +__all__ = ["EchoAction", "EchoObservation", "EchoEnv"] diff --git a/src/envs/echo_env/__pycache__/__init__.cpython-313.pyc b/src/envs/echo_env/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3064cc5c4d3350e7e401cc2615109a0acf8d2e10 Binary files /dev/null and b/src/envs/echo_env/__pycache__/__init__.cpython-313.pyc differ diff --git a/src/envs/echo_env/__pycache__/client.cpython-313.pyc b/src/envs/echo_env/__pycache__/client.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4cdad2bb4d98e94d8c8d6c3f261516d27799ae40 Binary files /dev/null and b/src/envs/echo_env/__pycache__/client.cpython-313.pyc differ diff --git a/src/envs/echo_env/__pycache__/models.cpython-313.pyc b/src/envs/echo_env/__pycache__/models.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..af92c6369ea3e95e1b80d37d6fcfde18224c1dc1 Binary files /dev/null and b/src/envs/echo_env/__pycache__/models.cpython-313.pyc differ diff --git a/src/envs/echo_env/client.py b/src/envs/echo_env/client.py new file mode 100644 index 0000000000000000000000000000000000000000..0f5bdd4c7d2dcbbf5ad803840b300a4a2d6fa444 --- /dev/null +++ b/src/envs/echo_env/client.py @@ -0,0 +1,101 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +""" +Echo Environment HTTP Client. + +This module provides the client for connecting to an Echo Environment server +over HTTP. +""" + +from typing import Any, Dict + +from core.client_types import StepResult + +from core.env_server.types import State +from core.http_env_client import HTTPEnvClient + +from .models import EchoAction, EchoObservation + + +class EchoEnv(HTTPEnvClient[EchoAction, EchoObservation]): + """ + HTTP client for the Echo Environment. + + This client connects to an EchoEnvironment HTTP server and provides + methods to interact with it: reset(), step(), and state access. + + Example: + >>> # Connect to a running server + >>> client = EchoEnv(base_url="http://localhost:8000") + >>> result = client.reset() + >>> print(result.observation.echoed_message) + >>> + >>> # Send a message + >>> result = client.step(EchoAction(message="Hello!")) + >>> print(result.observation.echoed_message) + >>> print(result.reward) + + Example with Docker: + >>> # Automatically start container and connect + >>> client = EchoEnv.from_docker_image("echo-env:latest") + >>> result = client.reset() + >>> result = client.step(EchoAction(message="Test")) + """ + + def _step_payload(self, action: EchoAction) -> Dict: + """ + Convert EchoAction to JSON payload for step request. + + Args: + action: EchoAction instance + + Returns: + Dictionary representation suitable for JSON encoding + """ + return { + "message": action.message, + } + + def _parse_result(self, payload: Dict) -> StepResult[EchoObservation]: + """ + Parse server response into StepResult[EchoObservation]. + + Args: + payload: JSON response from server + + Returns: + StepResult with EchoObservation + """ + obs_data = payload.get("observation", {}) + observation = EchoObservation( + echoed_message=obs_data.get("echoed_message", ""), + message_length=obs_data.get("message_length", 0), + done=payload.get("done", False), + reward=payload.get("reward"), + metadata=obs_data.get("metadata", {}), + ) + + return StepResult( + observation=observation, + reward=payload.get("reward"), + done=payload.get("done", False), + ) + + def _parse_state(self, payload: Dict) -> State: + """ + Parse server response into State object. + + Args: + payload: JSON response from /state endpoint + + Returns: + State object with episode_id and step_count + """ + return State( + episode_id=payload.get("episode_id"), + step_count=payload.get("step_count", 0), + ) diff --git a/src/envs/echo_env/models.py b/src/envs/echo_env/models.py new file mode 100644 index 0000000000000000000000000000000000000000..d73134bae93061a16586790730cd7dd219e12267 --- /dev/null +++ b/src/envs/echo_env/models.py @@ -0,0 +1,30 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +""" +Data models for the Echo Environment. + +The Echo environment is a simple test environment that echoes back messages. +""" + +from dataclasses import dataclass + +from core.env_server.types import Action, Observation + + +@dataclass(kw_only=True) +class EchoAction(Action): + """Action for the Echo environment - just a message to echo.""" + + message: str + + +@dataclass(kw_only=True) +class EchoObservation(Observation): + """Observation from the Echo environment - the echoed message.""" + + echoed_message: str + message_length: int = 0 \ No newline at end of file diff --git a/src/envs/echo_env/server/Dockerfile b/src/envs/echo_env/server/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..a50efc090cc8adef861afeb55760e1f905b4b18c --- /dev/null +++ b/src/envs/echo_env/server/Dockerfile @@ -0,0 +1,25 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +# Use the standard openenv base image +# Built from: docker build -t openenv-base:latest -f src/core/containers/images/Dockerfile . +# In GitHub Actions, this is overridden to use the GHCR base image +ARG BASE_IMAGE=openenv-base:latest +FROM ${BASE_IMAGE} + +# Copy only what's needed for this environment +COPY src/core/ /app/src/core/ +COPY src/envs/echo_env/ /app/src/envs/echo_env/ + +# Copy README for web interface documentation +COPY src/envs/echo_env/README.md /app/README.md + +# Health check +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD curl -f http://localhost:8000/health || exit 1 + +# Run the FastAPI server +CMD ["uvicorn", "envs.echo_env.server.app:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/src/envs/echo_env/server/__init__.py b/src/envs/echo_env/server/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..f6e24590f13390f98226005f9f059fcafbcec813 --- /dev/null +++ b/src/envs/echo_env/server/__init__.py @@ -0,0 +1,11 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +"""Echo environment server components.""" + +from .echo_environment import EchoEnvironment + +__all__ = ["EchoEnvironment"] \ No newline at end of file diff --git a/src/envs/echo_env/server/__pycache__/__init__.cpython-313.pyc b/src/envs/echo_env/server/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d5643632a8655291fb2c905d1e8e1db526a0b403 Binary files /dev/null and b/src/envs/echo_env/server/__pycache__/__init__.cpython-313.pyc differ diff --git a/src/envs/echo_env/server/__pycache__/app.cpython-313.pyc b/src/envs/echo_env/server/__pycache__/app.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..78ad700ee109fb014c3c8826603bb3ebe4746358 Binary files /dev/null and b/src/envs/echo_env/server/__pycache__/app.cpython-313.pyc differ diff --git a/src/envs/echo_env/server/__pycache__/echo_environment.cpython-313.pyc b/src/envs/echo_env/server/__pycache__/echo_environment.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6db2cc34135591482e37095332bdbe8116195538 Binary files /dev/null and b/src/envs/echo_env/server/__pycache__/echo_environment.cpython-313.pyc differ diff --git a/src/envs/echo_env/server/app.py b/src/envs/echo_env/server/app.py new file mode 100644 index 0000000000000000000000000000000000000000..d5e3fb37364d5e2c60bfd0d3d89d41a624a656f7 --- /dev/null +++ b/src/envs/echo_env/server/app.py @@ -0,0 +1,69 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +""" +FastAPI application for the Echo Environment. + +This module creates an HTTP server that exposes the EchoEnvironment +over HTTP endpoints, making it compatible with HTTPEnvClient. + +Usage: + # Development (with auto-reload): + uvicorn envs.echo_env.server.app:app --reload --host 0.0.0.0 --port 8000 + + # With the packaged CLI (auto-reload enabled via flag): + server --reload --host 0.0.0.0 --port 8000 + + # Production: + uvicorn envs.echo_env.server.app:app --host 0.0.0.0 --port 8000 --workers 4 + + # Or run directly: + python -m envs.echo_env.server.app +""" + +from core.env_server.http_server import create_app + +from ..models import EchoAction, EchoObservation +from .echo_environment import EchoEnvironment + +# Create the environment instance +env = EchoEnvironment() + +# Create the app with web interface and README integration +app = create_app(env, EchoAction, EchoObservation, env_name="echo_env") + + +def main(host: str = "0.0.0.0", port: int = 8000, *, reload: bool = False) -> None: + """Run the Echo environment server with Uvicorn.""" + import uvicorn + + uvicorn.run( + "envs.echo_env.server.app:app", + host=host, + port=port, + reload=reload, + ) + + +def cli(argv: list[str] | None = None) -> None: + """Entry point for the packaged console script.""" + import argparse + parser = argparse.ArgumentParser(description="Run the Echo Environment HTTP server.") + parser.add_argument("--host", default="0.0.0.0", help="Host interface to bind.") + parser.add_argument( + "--port", type=int, default=8000, help="Port number to expose the server on." + ) + parser.add_argument( + "--reload", + action="store_true", + help="Enable the uvicorn reload watcher (development only).", + ) + args = parser.parse_args(argv) + main(host=args.host, port=args.port, reload=args.reload) + + +if __name__ == "__main__": + cli() diff --git a/src/envs/echo_env/server/echo_environment.py b/src/envs/echo_env/server/echo_environment.py new file mode 100644 index 0000000000000000000000000000000000000000..5033a9c146cb00d633ca8f44815b2ff4e5094135 --- /dev/null +++ b/src/envs/echo_env/server/echo_environment.py @@ -0,0 +1,95 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +""" +Echo Environment Implementation. + +A simple test environment that echoes back messages sent to it. +Perfect for testing HTTP server infrastructure. +""" + +from uuid import uuid4 + +from core.env_server.interfaces import Environment +from core.env_server.types import State + +from ..models import EchoAction, EchoObservation + + +class EchoEnvironment(Environment): + """ + A simple echo environment that echoes back messages. + + This environment is designed for testing the HTTP server infrastructure. + It maintains minimal state and simply echoes back whatever message it receives. + + Example: + >>> env = EchoEnvironment() + >>> obs = env.reset() + >>> print(obs.echoed_message) # "Echo environment ready!" + >>> + >>> obs = env.step(EchoAction(message="Hello")) + >>> print(obs.echoed_message) # "Hello" + >>> print(obs.message_length) # 5 + """ + + def __init__(self): + """Initialize the echo environment.""" + self._state = State(episode_id=str(uuid4()), step_count=0) + self._reset_count = 0 + + def reset(self) -> EchoObservation: + """ + Reset the environment. + + Returns: + EchoObservation with a ready message + """ + self._state = State(episode_id=str(uuid4()), step_count=0) + self._reset_count += 1 + + return EchoObservation( + echoed_message="Echo environment ready!", + message_length=0, + done=False, + reward=0.0, + ) + + def step(self, action: EchoAction) -> EchoObservation: # type: ignore[override] + """ + Execute a step in the environment by echoing the message. + + Args: + action: EchoAction containing the message to echo + + Returns: + EchoObservation with the echoed message and its length + """ + self._state.step_count += 1 + + message = action.message + length = len(message) + + # Simple reward: longer messages get higher rewards + reward = length * 0.1 + + return EchoObservation( + echoed_message=message, + message_length=length, + done=False, + reward=reward, + metadata={"original_message": message, "step": self._state.step_count}, + ) + + @property + def state(self) -> State: + """ + Get the current environment state. + + Returns: + Current State with episode_id and step_count + """ + return self._state