diff --git a/.gitattributes b/.gitattributes deleted file mode 100644 index a6344aac8c09253b3b630fb776ae94478aa0275b..0000000000000000000000000000000000000000 --- a/.gitattributes +++ /dev/null @@ -1,35 +0,0 @@ -*.7z filter=lfs diff=lfs merge=lfs -text -*.arrow filter=lfs diff=lfs merge=lfs -text -*.bin filter=lfs diff=lfs merge=lfs -text -*.bz2 filter=lfs diff=lfs merge=lfs -text -*.ckpt filter=lfs diff=lfs merge=lfs -text -*.ftz filter=lfs diff=lfs merge=lfs -text -*.gz filter=lfs diff=lfs merge=lfs -text -*.h5 filter=lfs diff=lfs merge=lfs -text -*.joblib filter=lfs diff=lfs merge=lfs -text -*.lfs.* filter=lfs diff=lfs merge=lfs -text -*.mlmodel filter=lfs diff=lfs merge=lfs -text -*.model filter=lfs diff=lfs merge=lfs -text -*.msgpack filter=lfs diff=lfs merge=lfs -text -*.npy filter=lfs diff=lfs merge=lfs -text -*.npz filter=lfs diff=lfs merge=lfs -text -*.onnx filter=lfs diff=lfs merge=lfs -text -*.ot filter=lfs diff=lfs merge=lfs -text -*.parquet filter=lfs diff=lfs merge=lfs -text -*.pb filter=lfs diff=lfs merge=lfs -text -*.pickle filter=lfs diff=lfs merge=lfs -text -*.pkl filter=lfs diff=lfs merge=lfs -text -*.pt filter=lfs diff=lfs merge=lfs -text -*.pth filter=lfs diff=lfs merge=lfs -text -*.rar filter=lfs diff=lfs merge=lfs -text -*.safetensors filter=lfs diff=lfs merge=lfs -text -saved_model/**/* filter=lfs diff=lfs merge=lfs -text -*.tar.* filter=lfs diff=lfs merge=lfs -text -*.tar filter=lfs diff=lfs merge=lfs -text -*.tflite filter=lfs diff=lfs merge=lfs -text -*.tgz filter=lfs diff=lfs merge=lfs -text -*.wasm filter=lfs diff=lfs merge=lfs -text -*.xz filter=lfs diff=lfs merge=lfs -text -*.zip filter=lfs diff=lfs merge=lfs -text -*.zst filter=lfs diff=lfs merge=lfs -text -*tfevents* filter=lfs diff=lfs merge=lfs -text diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..b9b2aed7ab8ea0aaf078fab37aa0cc39c7d4f6d9 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,43 @@ +# 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. + +# Multi-stage build: First stage builds the base image +FROM python:3.11-slim as base-builder + +# 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 + +# Second stage: Use the built base image and add environment-specific dependencies +FROM base-builder + + +# Copy only what's needed for this environment +COPY src/core/ /app/src/core/ +COPY src/envs/echo_env/ /app/src/envs/echo_env/ + +# 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"] +ENV ENABLE_WEB_INTERFACE=true diff --git a/README.md b/README.md index dfaa2f184bf85c57668627eae94148e8d837d3f2..64ac48dcde67322bcc3fa2454b2c730ff88b81b2 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,48 @@ --- -title: Echo Env -emoji: šŸ’» -colorFrom: purple +title: Echo_env Environment Server +emoji: 🐳 +colorFrom: blue colorTo: green sdk: docker pinned: false +app_port: 8000 +base_path: /web --- -Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference +# Echo_env Environment Server + +FastAPI server for echo_env environment powered by Meta's OpenEnv. + +## About + +This Space provides a containerized environment for echo_env interactions. +Built with FastAPI and OpenEnv framework. + +## Web Interface + +This deployment includes an interactive web interface for exploring the environment: +- **HumanAgent Interface**: Interact with the environment using a web form +- **State Observer**: Real-time view of environment state and action history +- **Live Updates**: WebSocket-based real-time updates + +Access the web interface at: `/web` + +## Echo Environment + +Simple test environment that echoes back messages. Perfect for testing the OpenEnv APIs. + +### Usage +Send a POST request to `/step` with: +```json +{ + "message": "Hello World" +} +``` + +## API Documentation + +Visit `/docs` for interactive API documentation. + +## Health Check + +The environment provides a health check endpoint at `/health`. diff --git a/src/core/__init__.py b/src/core/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..022ecbcca73126e39ab05120503ffa723d1f89cd --- /dev/null +++ b/src/core/__init__.py @@ -0,0 +1,19 @@ +# 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 .http_env_client import HTTPEnvClient +from .types import StepResult + +# Note: MCP module doesn't export anything yet + +__all__ = [ + "HTTPEnvClient", + "StepResult", +] diff --git a/src/core/__pycache__/__init__.cpython-311.pyc b/src/core/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ba8ebcb7f8b8749f8a2b8704d4595ecf0761d681 Binary files /dev/null and b/src/core/__pycache__/__init__.cpython-311.pyc differ diff --git a/src/core/__pycache__/__init__.cpython-313.pyc b/src/core/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..88c44b6dc9955eee654fecbc3691949d1fa85be8 Binary files /dev/null and b/src/core/__pycache__/__init__.cpython-313.pyc differ diff --git a/src/core/__pycache__/http_env_client.cpython-311.pyc b/src/core/__pycache__/http_env_client.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6b6b005f00b0ffcaf273052532ad5435a3173d62 Binary files /dev/null and b/src/core/__pycache__/http_env_client.cpython-311.pyc differ diff --git a/src/core/__pycache__/types.cpython-311.pyc b/src/core/__pycache__/types.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ec35f9f8707f7797eda9cc4c318cf022bf6ebcaf Binary files /dev/null and b/src/core/__pycache__/types.cpython-311.pyc differ 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-311.pyc b/src/core/containers/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..746a81f3bf915ce548b3da69ade840c757702066 Binary files /dev/null and b/src/core/containers/__pycache__/__init__.cpython-311.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..a72b5301017f33e13586dbf86d109c18ef008970 --- /dev/null +++ b/src/core/containers/runtime/__init__.py @@ -0,0 +1,15 @@ +# 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 + +__all__ = [ + "ContainerProvider", + "LocalDockerProvider", + "KubernetesProvider", +] \ No newline at end of file diff --git a/src/core/containers/runtime/__pycache__/__init__.cpython-311.pyc b/src/core/containers/runtime/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..079aaa94902a3c79800183743ce17bff27f898bf Binary files /dev/null and b/src/core/containers/runtime/__pycache__/__init__.cpython-311.pyc differ diff --git a/src/core/containers/runtime/__pycache__/providers.cpython-311.pyc b/src/core/containers/runtime/__pycache__/providers.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e5f923e32dc09b421d57329c92536da89e597910 Binary files /dev/null and b/src/core/containers/runtime/__pycache__/providers.cpython-311.pyc differ diff --git a/src/core/containers/runtime/providers.py b/src/core/containers/runtime/providers.py new file mode 100644 index 0000000000000000000000000000000000000000..637b3be53ed41591c2fdfe40af69f066d6b12d9a --- /dev/null +++ b/src/core/containers/runtime/providers.py @@ -0,0 +1,289 @@ +# 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 + result = subprocess.run(cmd, capture_output=True, text=True, check=True) + self._container_id = result.stdout.strip() + + # 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/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/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-311.pyc b/src/core/env_server/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a0a51ed3a35d5b3a28698f72357ebe03175e0350 Binary files /dev/null and b/src/core/env_server/__pycache__/__init__.cpython-311.pyc differ 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..e262e0f127f67a91c64992ec1182cfdbfe31211c 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-311.pyc b/src/core/env_server/__pycache__/base_transforms.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..606d0f23676904137940a658494d5bff04753a80 Binary files /dev/null and b/src/core/env_server/__pycache__/base_transforms.cpython-311.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..950bba6af0a51ac7a91477d6eb5cbe879e1f45f6 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-311.pyc b/src/core/env_server/__pycache__/http_server.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5fc86f58da2a3444881c55c406199620bcfeb54d Binary files /dev/null and b/src/core/env_server/__pycache__/http_server.cpython-311.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..2810ed5c98e088a6ecd2c4719aa72ed2fbdfdda5 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-311.pyc b/src/core/env_server/__pycache__/interfaces.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..87266363c5c0059b778a30357c3c0b0b9ae18fec Binary files /dev/null and b/src/core/env_server/__pycache__/interfaces.cpython-311.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..093efd15810f772043140b2d80d2b515ed94c244 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-311.pyc b/src/core/env_server/__pycache__/types.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..dc459b2849155a21845eb184dec1f993dc92f4ec Binary files /dev/null and b/src/core/env_server/__pycache__/types.cpython-311.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..c9bcfe411ed0e551b9c849982565cb8626ce4df0 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-311.pyc b/src/core/env_server/__pycache__/web_interface.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..46b3abde314829fec78b6fb7e02426d136ff9aa6 Binary files /dev/null and b/src/core/env_server/__pycache__/web_interface.cpython-311.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..adbcd85d9b7479fb2b2a58c8ae63f2077e18bf73 --- /dev/null +++ b/src/core/env_server/http_server.py @@ -0,0 +1,231 @@ +# 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], +) -> Any: + """ + Create a FastAPI application with web interface enabled for Hugging Face deployments. + + This function checks for the ENABLE_WEB_INTERFACE environment variable to determine + whether to enable the web interface. + + 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 or without web interface based on environment + """ + # 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) + 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..a42ea875781ede4ffbc487a1ab139f62fae0f599 --- /dev/null +++ b/src/core/env_server/types.py @@ -0,0 +1,45 @@ +# 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 diff --git a/src/core/env_server/web_interface.py b/src/core/env_server/web_interface.py new file mode 100644 index 0000000000000000000000000000000000000000..78e50ae5f378984d5c8db2bcf7ecb5d947d64203 --- /dev/null +++ b/src/core/env_server/web_interface.py @@ -0,0 +1,764 @@ +# 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 + + +@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], + ): + self.env = env + self.action_cls = action_cls + self.observation_cls = observation_cls + 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", {}) + action = self.action_cls(**action_data) + action.metadata = metadata + return action + + +def create_web_interface_app( + env: Environment, + action_cls: Type[Action], + observation_cls: Type[Observation], +) -> 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 + + 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) + + # Create web interface manager + web_manager = WebInterfaceManager(env, action_cls, observation_cls) + + # 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) + + @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.""" + 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]) -> str: + """Generate the HTML for the web interface.""" + + # Get action fields for dynamic form generation + action_fields = [] + if hasattr(action_cls, '__dataclass_fields__'): + for field_name, field_info in action_cls.__dataclass_fields__.items(): + if field_name != 'metadata': + field_type = field_info.type + if field_type == str: + input_type = "text" + elif field_type == int: + input_type = "number" + elif field_type == float: + input_type = "number" + elif field_type == bool: + input_type = "checkbox" + else: + input_type = "text" + + action_fields.append({ + 'name': field_name, + 'type': input_type, + 'required': field_info.default is field_info.default_factory + }) + + return f""" + + + + + + OpenEnv Web Interface + + + +
+ +
+
+ + HumanAgent Interface +
+
+ +
+

Take Action

+
+ {_generate_action_form_fields(action_fields)} + +
+
+ + +
+ + +
+ + +
+

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_action_form_fields(action_fields: List[Dict[str, Any]]) -> str: + """Generate HTML form fields for action input.""" + if not action_fields: + return '

No action fields available

' + + fields_html = [] + for field in action_fields: + if field['type'] == 'checkbox': + fields_html.append(f''' +
+ +
+ ''') + elif field['type'] == 'text' and 'message' in field['name'].lower(): + fields_html.append(f''' +
+ + +
+ ''') + else: + fields_html.append(f''' +
+ + +
+ ''') + + return '\n'.join(fields_html) diff --git a/src/core/http_env_client.py b/src/core/http_env_client.py new file mode 100644 index 0000000000000000000000000000000000000000..1f50428e1dd543ed865165cc0f81531221e0528c --- /dev/null +++ b/src/core/http_env_client.py @@ -0,0 +1,175 @@ +""" +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 TYPE_CHECKING, Any, Dict, Generic, Optional, Type, TypeVar +from .containers.runtime import LocalDockerProvider +import requests + +from .types import StepResult + +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, + ) -> 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 container lifecycle management is left to the user or higher-level + orchestration. The container will keep running until manually stopped. + + Args: + image: Docker image name to run (e.g., "echo-env:latest") + provider: Container provider to use (defaults to LocalDockerProvider) + + 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") + >>> + >>> # 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 + base_url = provider.start_container(image) + + # 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) + + @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/tools/__init__.py b/src/core/tools/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..5690ae83f476f6fbb114910ad42def84725494b7 --- /dev/null +++ b/src/core/tools/__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. + +"""Core tools for code execution and other utilities.""" + +from .local_python_executor import PyExecutor + +__all__ = ["PyExecutor"] 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/core/types.py b/src/core/types.py new file mode 100644 index 0000000000000000000000000000000000000000..8808e96bf713e95f94d9bc7f2e743f3fee616306 --- /dev/null +++ b/src/core/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/envs/echo_env/README.md b/src/envs/echo_env/README.md new file mode 100644 index 0000000000000000000000000000000000000000..2daa62590b07cf2b47d554173866ef5b95b19827 --- /dev/null +++ b/src/envs/echo_env/README.md @@ -0,0 +1,133 @@ +# 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-311.pyc b/src/envs/echo_env/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..98cc3257a9069afd5fefb15ea865800a6853d0d9 Binary files /dev/null and b/src/envs/echo_env/__pycache__/__init__.cpython-311.pyc differ 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..9350c8fd61c89e4580f30779a9f5d24cf99a84c7 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-311.pyc b/src/envs/echo_env/__pycache__/client.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..258d4cca2b5b5122cbd89072a7244a4de8259a35 Binary files /dev/null and b/src/envs/echo_env/__pycache__/client.cpython-311.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..5d78fed72bd47fceade25bbd1f0be8b84563b913 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-311.pyc b/src/envs/echo_env/__pycache__/models.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a6a8b6e6b52f63fb0661931f42c295f5099b90b4 Binary files /dev/null and b/src/envs/echo_env/__pycache__/models.cpython-311.pyc differ diff --git a/src/envs/echo_env/client.py b/src/envs/echo_env/client.py new file mode 100644 index 0000000000000000000000000000000000000000..7ad73b28e65dddf4fe2ccf599606cb11543258f7 --- /dev/null +++ b/src/envs/echo_env/client.py @@ -0,0 +1,100 @@ +# 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.env_server.types import State +from core.http_env_client import HTTPEnvClient +from core.types import StepResult + +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..23b9380a61de2168a02b3ac480f9e596cf1bc316 --- /dev/null +++ b/src/envs/echo_env/server/Dockerfile @@ -0,0 +1,22 @@ +# 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/ + +# 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-311.pyc b/src/envs/echo_env/server/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b6fc9e0ad8d9a9525f3b592b31b91add8e22340a Binary files /dev/null and b/src/envs/echo_env/server/__pycache__/__init__.cpython-311.pyc differ diff --git a/src/envs/echo_env/server/__pycache__/app.cpython-311.pyc b/src/envs/echo_env/server/__pycache__/app.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9e06a0b5f0ca5abd9d27ec8d380a5dc0792c6359 Binary files /dev/null and b/src/envs/echo_env/server/__pycache__/app.cpython-311.pyc differ diff --git a/src/envs/echo_env/server/__pycache__/echo_environment.cpython-311.pyc b/src/envs/echo_env/server/__pycache__/echo_environment.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..40642bd909006621ca23377bbbe0064a97b23e5b Binary files /dev/null and b/src/envs/echo_env/server/__pycache__/echo_environment.cpython-311.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..5588f96f92ab3338068d31b376aad4f6549906fe --- /dev/null +++ b/src/envs/echo_env/server/app.py @@ -0,0 +1,39 @@ +# 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 + + # 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 routes (one line!) +app = create_app(env, EchoAction, EchoObservation) + + +if __name__ == "__main__": + import uvicorn + + uvicorn.run(app, host="0.0.0.0", port=8000) \ No newline at end of file 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