Zach Wentz commited on
Commit
8ab8289
Β·
1 Parent(s): 358936f

πŸ€– Deploy chat_env environment - 2025-10-19 22:32:42

Browse files
Files changed (47) hide show
  1. .gitattributes +0 -35
  2. Dockerfile +46 -0
  3. README.md +43 -5
  4. src/core/__init__.py +19 -0
  5. src/core/__pycache__/__init__.cpython-311.pyc +0 -0
  6. src/core/__pycache__/__init__.cpython-313.pyc +0 -0
  7. src/core/__pycache__/http_env_client.cpython-311.pyc +0 -0
  8. src/core/__pycache__/types.cpython-311.pyc +0 -0
  9. src/core/containers/__init__.py +7 -0
  10. src/core/containers/__pycache__/__init__.cpython-311.pyc +0 -0
  11. src/core/containers/images/Dockerfile +46 -0
  12. src/core/containers/images/README.md +92 -0
  13. src/core/containers/runtime/__init__.py +15 -0
  14. src/core/containers/runtime/__pycache__/__init__.cpython-311.pyc +0 -0
  15. src/core/containers/runtime/__pycache__/providers.cpython-311.pyc +0 -0
  16. src/core/containers/runtime/providers.py +289 -0
  17. src/core/containers/test_local_docker_provider.py +258 -0
  18. src/core/env_server/__init__.py +35 -0
  19. src/core/env_server/__pycache__/__init__.cpython-311.pyc +0 -0
  20. src/core/env_server/__pycache__/__init__.cpython-313.pyc +0 -0
  21. src/core/env_server/__pycache__/base_transforms.cpython-311.pyc +0 -0
  22. src/core/env_server/__pycache__/base_transforms.cpython-313.pyc +0 -0
  23. src/core/env_server/__pycache__/http_server.cpython-311.pyc +0 -0
  24. src/core/env_server/__pycache__/http_server.cpython-313.pyc +0 -0
  25. src/core/env_server/__pycache__/interfaces.cpython-311.pyc +0 -0
  26. src/core/env_server/__pycache__/interfaces.cpython-313.pyc +0 -0
  27. src/core/env_server/__pycache__/types.cpython-311.pyc +0 -0
  28. src/core/env_server/__pycache__/types.cpython-313.pyc +0 -0
  29. src/core/env_server/__pycache__/web_interface.cpython-311.pyc +0 -0
  30. src/core/env_server/base_transforms.py +29 -0
  31. src/core/env_server/http_server.py +231 -0
  32. src/core/env_server/interfaces.py +118 -0
  33. src/core/env_server/types.py +45 -0
  34. src/core/env_server/web_interface.py +764 -0
  35. src/core/http_env_client.py +175 -0
  36. src/core/tools/__init__.py +11 -0
  37. src/core/tools/local_python_executor.py +105 -0
  38. src/core/types.py +22 -0
  39. src/envs/chat_env/README.md +268 -0
  40. src/envs/chat_env/__init__.py +12 -0
  41. src/envs/chat_env/client.py +182 -0
  42. src/envs/chat_env/models.py +67 -0
  43. src/envs/chat_env/server/Dockerfile +29 -0
  44. src/envs/chat_env/server/__init__.py +11 -0
  45. src/envs/chat_env/server/app.py +77 -0
  46. src/envs/chat_env/server/chat_environment.py +172 -0
  47. src/envs/chat_env/server/test_chat_env.py +328 -0
.gitattributes DELETED
@@ -1,35 +0,0 @@
1
- *.7z filter=lfs diff=lfs merge=lfs -text
2
- *.arrow filter=lfs diff=lfs merge=lfs -text
3
- *.bin filter=lfs diff=lfs merge=lfs -text
4
- *.bz2 filter=lfs diff=lfs merge=lfs -text
5
- *.ckpt filter=lfs diff=lfs merge=lfs -text
6
- *.ftz filter=lfs diff=lfs merge=lfs -text
7
- *.gz filter=lfs diff=lfs merge=lfs -text
8
- *.h5 filter=lfs diff=lfs merge=lfs -text
9
- *.joblib filter=lfs diff=lfs merge=lfs -text
10
- *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
- *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
- *.model filter=lfs diff=lfs merge=lfs -text
13
- *.msgpack filter=lfs diff=lfs merge=lfs -text
14
- *.npy filter=lfs diff=lfs merge=lfs -text
15
- *.npz filter=lfs diff=lfs merge=lfs -text
16
- *.onnx filter=lfs diff=lfs merge=lfs -text
17
- *.ot filter=lfs diff=lfs merge=lfs -text
18
- *.parquet filter=lfs diff=lfs merge=lfs -text
19
- *.pb filter=lfs diff=lfs merge=lfs -text
20
- *.pickle filter=lfs diff=lfs merge=lfs -text
21
- *.pkl filter=lfs diff=lfs merge=lfs -text
22
- *.pt filter=lfs diff=lfs merge=lfs -text
23
- *.pth filter=lfs diff=lfs merge=lfs -text
24
- *.rar filter=lfs diff=lfs merge=lfs -text
25
- *.safetensors filter=lfs diff=lfs merge=lfs -text
26
- saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
- *.tar.* filter=lfs diff=lfs merge=lfs -text
28
- *.tar filter=lfs diff=lfs merge=lfs -text
29
- *.tflite filter=lfs diff=lfs merge=lfs -text
30
- *.tgz filter=lfs diff=lfs merge=lfs -text
31
- *.wasm filter=lfs diff=lfs merge=lfs -text
32
- *.xz filter=lfs diff=lfs merge=lfs -text
33
- *.zip filter=lfs diff=lfs merge=lfs -text
34
- *.zst filter=lfs diff=lfs merge=lfs -text
35
- *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
Dockerfile ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ # Multi-stage build: First stage builds the base image
8
+ FROM python:3.11-slim as base-builder
9
+
10
+ # Install system dependencies
11
+ RUN apt-get update && apt-get install -y --no-install-recommends \
12
+ curl \
13
+ && rm -rf /var/lib/apt/lists/*
14
+
15
+ # Install Python dependencies that all environments need
16
+ RUN pip install --no-cache-dir \
17
+ fastapi>=0.104.0 \
18
+ "uvicorn[standard]>=0.24.0" \
19
+ requests>=2.25.0 \
20
+ wsproto>=1.0.0
21
+
22
+ # Set working directory
23
+ WORKDIR /app
24
+
25
+ # Default environment variables
26
+ ENV PYTHONPATH=/app/src
27
+ ENV PYTHONUNBUFFERED=1
28
+
29
+ # Second stage: Use the built base image and add environment-specific dependencies
30
+ FROM base-builder
31
+
32
+ # Install additional dependencies for ChatEnvironment
33
+ RUN pip install --no-cache-dir torch transformers
34
+
35
+
36
+ # Copy only what's needed for this environment
37
+ COPY src/core/ /app/src/core/
38
+ COPY src/envs/chat_env/ /app/src/envs/chat_env/
39
+
40
+ # Health check
41
+ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
42
+ CMD curl -f http://localhost:8000/health || exit 1
43
+
44
+ # Run the FastAPI server
45
+ CMD ["uvicorn", "envs.chat_env.server.app:app", "--host", "0.0.0.0", "--port", "8000"]
46
+ ENV ENABLE_WEB_INTERFACE=true
README.md CHANGED
@@ -1,10 +1,48 @@
1
  ---
2
- title: Chat Env
3
- emoji: ⚑
4
- colorFrom: yellow
5
- colorTo: blue
6
  sdk: docker
7
  pinned: false
 
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: Chat_env Environment Server
3
+ emoji: 🐳
4
+ colorFrom: blue
5
+ colorTo: green
6
  sdk: docker
7
  pinned: false
8
+ app_port: 8000
9
+ base_path: /web
10
  ---
11
 
12
+ # Chat_env Environment Server
13
+
14
+ FastAPI server for chat_env environment powered by Meta's OpenEnv.
15
+
16
+ ## About
17
+
18
+ This Space provides a containerized environment for chat_env interactions.
19
+ Built with FastAPI and OpenEnv framework.
20
+
21
+ ## Web Interface
22
+
23
+ This deployment includes an interactive web interface for exploring the environment:
24
+ - **HumanAgent Interface**: Interact with the environment using a web form
25
+ - **State Observer**: Real-time view of environment state and action history
26
+ - **Live Updates**: WebSocket-based real-time updates
27
+
28
+ Access the web interface at: `/web`
29
+
30
+ ## Chat Environment
31
+
32
+ Provides a chat-based interface for LLMs with tokenization support.
33
+
34
+ ### Usage
35
+ Send a POST request to `/step` with tokenized input:
36
+ ```json
37
+ {
38
+ "tokens": [1, 2, 3, 4, 5]
39
+ }
40
+ ```
41
+
42
+ ## API Documentation
43
+
44
+ Visit `/docs` for interactive API documentation.
45
+
46
+ ## Health Check
47
+
48
+ The environment provides a health check endpoint at `/health`.
src/core/__init__.py ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ """Core components for agentic environments."""
8
+
9
+ # Re-export main components from submodules for convenience
10
+ from .env_server import *
11
+ from .http_env_client import HTTPEnvClient
12
+ from .types import StepResult
13
+
14
+ # Note: MCP module doesn't export anything yet
15
+
16
+ __all__ = [
17
+ "HTTPEnvClient",
18
+ "StepResult",
19
+ ]
src/core/__pycache__/__init__.cpython-311.pyc ADDED
Binary file (400 Bytes). View file
 
src/core/__pycache__/__init__.cpython-313.pyc ADDED
Binary file (383 Bytes). View file
 
src/core/__pycache__/http_env_client.cpython-311.pyc ADDED
Binary file (7.68 kB). View file
 
src/core/__pycache__/types.cpython-311.pyc ADDED
Binary file (1.09 kB). View file
 
src/core/containers/__init__.py ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ """Container management for environment servers."""
src/core/containers/__pycache__/__init__.cpython-311.pyc ADDED
Binary file (206 Bytes). View file
 
src/core/containers/images/Dockerfile ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ #
8
+ # OpenEnv Base Image
9
+ #
10
+ # This is the standard base image for all OpenEnv environment servers.
11
+ # It includes the minimal dependencies needed to run HTTP environment servers.
12
+ #
13
+ # Build: docker build -t openenv-base:latest -f src/core/containers/images/Dockerfile .
14
+ # Tag: docker tag openenv-base:latest openenv-base:0.1.0
15
+ #
16
+
17
+ FROM python:3.11-slim
18
+
19
+ # Set metadata
20
+ LABEL maintainer="OpenEnv Team"
21
+ LABEL description="Base image for OpenEnv based environment servers"
22
+ LABEL version="0.1.0"
23
+
24
+ # Install system dependencies
25
+ RUN apt-get update && apt-get install -y --no-install-recommends \
26
+ curl \
27
+ && rm -rf /var/lib/apt/lists/*
28
+
29
+ # Install Python dependencies that all environments need
30
+ RUN pip install --no-cache-dir \
31
+ fastapi>=0.104.0 \
32
+ "uvicorn[standard]>=0.24.0" \
33
+ requests>=2.25.0 \
34
+ wsproto>=1.0.0
35
+
36
+ # Set working directory
37
+ WORKDIR /app
38
+
39
+ # Default environment variables
40
+ ENV PYTHONPATH=/app/src
41
+ ENV PYTHONUNBUFFERED=1
42
+
43
+ # Default expose port (can be overridden)
44
+ EXPOSE 8000
45
+
46
+ # Note: CMD should be specified in child Dockerfiles
src/core/containers/images/README.md ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # OpenEnv Base Image
2
+
3
+ Standard base image for all OpenEnv environment servers.
4
+
5
+ ## What's Included
6
+
7
+ | Layer | Size | Contents |
8
+ |-------|------|----------|
9
+ | python:3.11-slim | 200 MB | Base Python runtime |
10
+ | + Dependencies | 100 MB | FastAPI, uvicorn, requests |
11
+ | **Total** | **~300 MB** | Ready for environment servers |
12
+
13
+ ## Image Sizes
14
+
15
+ ```
16
+ openenv-base:latest 300 MB (python + fastapi + uvicorn)
17
+ ```
18
+ echo-env:latest 500 MB (python + fastapi + uvicorn + app)
19
+ coding-env:latest 520 MB (python + fastapi + uvicorn + app + tools)
20
+ another-env:latest 510 MB (python + fastapi + uvicorn + app)
21
+ ---
22
+ Total: 1.5 GB (with lots of duplication)
23
+ ```
24
+
25
+ ### With Base Images (βœ… Solution)
26
+ ```
27
+ openenv-base:latest 300 MB (python + fastapi + uvicorn)
28
+ echo-env:latest 50 MB (app only, uses base)
29
+ coding-env:latest 70 MB (app + tools, uses base)
30
+ another-env:latest 45 MB (app only, uses base)
31
+ ---
32
+ Total: 465 MB (base shared, minimal duplication)
33
+ ```
34
+
35
+ ## Building the Base Image
36
+
37
+ ```bash
38
+ # From project root
39
+ docker build -t openenv-base:latest -f src/core/containers/images/Dockerfile .
40
+ ```
41
+
42
+ ## Usage in Environment Dockerfiles
43
+
44
+ Each environment Dockerfile should start with:
45
+
46
+ ```dockerfile
47
+ FROM openenv-base:latest
48
+
49
+ # Copy only environment-specific files
50
+ COPY src/core/ /app/src/core/
51
+ COPY src/envs/my_env/ /app/src/envs/my_env/
52
+
53
+ # Run the server
54
+ CMD ["uvicorn", "envs.my_env.server.app:app", "--host", "0.0.0.0", "--port", "8000"]
55
+ ```
56
+
57
+ ## Base Image Contents
58
+
59
+ - Python 3.11-slim
60
+ - FastAPI >= 0.104.0
61
+ - Uvicorn >= 0.24.0
62
+ - Requests >= 2.25.0
63
+ - curl (for health checks)
64
+
65
+ ## Example: Building Echo Environment
66
+
67
+ ```bash
68
+ # Step 1: Build base image (do this once)
69
+ docker build -t openenv-base:latest -f src/core/containers/images/Dockerfile .
70
+
71
+ # Step 2: Build echo environment (uses base)
72
+ docker build -t echo-env:latest -f src/envs/echo_env/server/Dockerfile .
73
+
74
+ # Step 3: Run echo environment
75
+ docker run -p 8000:8000 echo-env:latest
76
+ ```
77
+
78
+ ## Updating the Base
79
+
80
+ When dependencies need updating:
81
+
82
+ 1. Update `src/core/containers/images/Dockerfile`
83
+ 2. Rebuild base image
84
+ 3. Rebuild all environment images (they'll use new base)
85
+
86
+ ```bash
87
+ # Update base
88
+ docker build -t openenv-base:latest -f src/core/containers/images/Dockerfile .
89
+
90
+ # Rebuild environments (they automatically use new base)
91
+ docker build -t echo-env:latest -f src/envs/echo_env/server/Dockerfile .
92
+ ```
src/core/containers/runtime/__init__.py ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ """Container runtime providers."""
8
+
9
+ from .providers import ContainerProvider, KubernetesProvider, LocalDockerProvider
10
+
11
+ __all__ = [
12
+ "ContainerProvider",
13
+ "LocalDockerProvider",
14
+ "KubernetesProvider",
15
+ ]
src/core/containers/runtime/__pycache__/__init__.cpython-311.pyc ADDED
Binary file (389 Bytes). View file
 
src/core/containers/runtime/__pycache__/providers.cpython-311.pyc ADDED
Binary file (10.9 kB). View file
 
src/core/containers/runtime/providers.py ADDED
@@ -0,0 +1,289 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ """
8
+ Container provider abstractions for running environment servers.
9
+
10
+ This module provides a pluggable architecture for different container providers
11
+ (local Docker, Kubernetes, cloud providers, etc.) to be used with HTTPEnvClient.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from abc import ABC, abstractmethod
17
+ from typing import Any, Dict, Optional
18
+
19
+
20
+ class ContainerProvider(ABC):
21
+ """
22
+ Abstract base class for container providers.
23
+
24
+ Providers implement this interface to support different container platforms:
25
+ - LocalDockerProvider: Runs containers on local Docker daemon
26
+ - KubernetesProvider: Runs containers in Kubernetes cluster
27
+ - FargateProvider: Runs containers on AWS Fargate
28
+ - CloudRunProvider: Runs containers on Google Cloud Run
29
+
30
+ The provider manages a single container lifecycle and provides the base URL
31
+ for connecting to it.
32
+
33
+ Example:
34
+ >>> provider = LocalDockerProvider()
35
+ >>> base_url = provider.start_container("echo-env:latest")
36
+ >>> print(base_url) # http://localhost:8000
37
+ >>> # Use the environment via base_url
38
+ >>> provider.stop_container()
39
+ """
40
+
41
+ @abstractmethod
42
+ def start_container(
43
+ self,
44
+ image: str,
45
+ port: Optional[int] = None,
46
+ env_vars: Optional[Dict[str, str]] = None,
47
+ **kwargs: Any,
48
+ ) -> str:
49
+ """
50
+ Start a container from the specified image.
51
+
52
+ Args:
53
+ image: Container image name (e.g., "echo-env:latest")
54
+ port: Port to expose (if None, provider chooses)
55
+ env_vars: Environment variables to pass to container
56
+ **kwargs: Provider-specific options
57
+
58
+ Returns:
59
+ Base URL to connect to the container (e.g., "http://localhost:8000")
60
+
61
+ Raises:
62
+ RuntimeError: If container fails to start
63
+ """
64
+ pass
65
+
66
+ @abstractmethod
67
+ def stop_container(self) -> None:
68
+ """
69
+ Stop and remove the running container.
70
+
71
+ This cleans up the container that was started by start_container().
72
+ """
73
+ pass
74
+
75
+ @abstractmethod
76
+ def wait_for_ready(self, base_url: str, timeout_s: float = 30.0) -> None:
77
+ """
78
+ Wait for the container to be ready to accept requests.
79
+
80
+ This typically polls the /health endpoint until it returns 200.
81
+
82
+ Args:
83
+ base_url: Base URL of the container
84
+ timeout_s: Maximum time to wait
85
+
86
+ Raises:
87
+ TimeoutError: If container doesn't become ready in time
88
+ """
89
+ pass
90
+
91
+
92
+ class LocalDockerProvider(ContainerProvider):
93
+ """
94
+ Container provider for local Docker daemon.
95
+
96
+ This provider runs containers on the local machine using Docker.
97
+ Useful for development and testing.
98
+
99
+ Example:
100
+ >>> provider = LocalDockerProvider()
101
+ >>> base_url = provider.start_container("echo-env:latest")
102
+ >>> # Container running on http://localhost:<random-port>
103
+ >>> provider.stop_container()
104
+ """
105
+
106
+ def __init__(self):
107
+ """Initialize the local Docker provider."""
108
+ self._container_id: Optional[str] = None
109
+ self._container_name: Optional[str] = None
110
+
111
+ # Check if Docker is available
112
+ import subprocess
113
+
114
+ try:
115
+ subprocess.run(
116
+ ["docker", "version"],
117
+ check=True,
118
+ capture_output=True,
119
+ timeout=5,
120
+ )
121
+ except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired):
122
+ raise RuntimeError(
123
+ "Docker is not available. Please install Docker Desktop or Docker Engine."
124
+ )
125
+
126
+ def start_container(
127
+ self,
128
+ image: str,
129
+ port: Optional[int] = None,
130
+ env_vars: Optional[Dict[str, str]] = None,
131
+ **kwargs: Any,
132
+ ) -> str:
133
+ """
134
+ Start a Docker container locally.
135
+
136
+ Args:
137
+ image: Docker image name
138
+ port: Port to expose (if None, finds available port)
139
+ env_vars: Environment variables for the container
140
+ **kwargs: Additional Docker run options
141
+
142
+ Returns:
143
+ Base URL to connect to the container
144
+ """
145
+ import subprocess
146
+ import time
147
+
148
+ # Find available port if not specified
149
+ if port is None:
150
+ port = self._find_available_port()
151
+
152
+ # Generate container name
153
+ self._container_name = self._generate_container_name(image)
154
+
155
+ # Build docker run command
156
+ cmd = [
157
+ "docker", "run",
158
+ "-d", # Detached
159
+ "--name", self._container_name,
160
+ "-p", f"{port}:8000", # Map port
161
+ ]
162
+
163
+ # Add environment variables
164
+ if env_vars:
165
+ for key, value in env_vars.items():
166
+ cmd.extend(["-e", f"{key}={value}"])
167
+
168
+ # Add image
169
+ cmd.append(image)
170
+
171
+ # Run container
172
+ result = subprocess.run(cmd, capture_output=True, text=True, check=True)
173
+ self._container_id = result.stdout.strip()
174
+
175
+ # Wait a moment for container to start
176
+ time.sleep(1)
177
+
178
+ base_url = f"http://localhost:{port}"
179
+ return base_url
180
+
181
+ def stop_container(self) -> None:
182
+ """
183
+ Stop and remove the Docker container.
184
+ """
185
+ if self._container_id is None:
186
+ return
187
+
188
+ import subprocess
189
+
190
+ try:
191
+ # Stop container
192
+ subprocess.run(
193
+ ["docker", "stop", self._container_id],
194
+ capture_output=True,
195
+ check=True,
196
+ timeout=10,
197
+ )
198
+
199
+ # Remove container
200
+ subprocess.run(
201
+ ["docker", "rm", self._container_id],
202
+ capture_output=True,
203
+ check=True,
204
+ timeout=10,
205
+ )
206
+ except subprocess.CalledProcessError:
207
+ # Container might already be stopped/removed
208
+ pass
209
+ finally:
210
+ self._container_id = None
211
+ self._container_name = None
212
+
213
+ def wait_for_ready(self, base_url: str, timeout_s: float = 30.0) -> None:
214
+ """
215
+ Wait for container to be ready by polling /health endpoint.
216
+
217
+ Args:
218
+ base_url: Base URL of the container
219
+ timeout_s: Maximum time to wait
220
+
221
+ Raises:
222
+ TimeoutError: If container doesn't become ready
223
+ """
224
+ import time
225
+ import requests
226
+
227
+ start_time = time.time()
228
+ health_url = f"{base_url}/health"
229
+
230
+ while time.time() - start_time < timeout_s:
231
+ try:
232
+ response = requests.get(health_url, timeout=2.0)
233
+ if response.status_code == 200:
234
+ return
235
+ except requests.RequestException:
236
+ pass
237
+
238
+ time.sleep(0.5)
239
+
240
+ raise TimeoutError(
241
+ f"Container at {base_url} did not become ready within {timeout_s}s"
242
+ )
243
+
244
+ def _find_available_port(self) -> int:
245
+ """
246
+ Find an available port on localhost.
247
+
248
+ Returns:
249
+ An available port number
250
+ """
251
+ import socket
252
+
253
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
254
+ s.bind(("", 0))
255
+ s.listen(1)
256
+ port = s.getsockname()[1]
257
+ return port
258
+
259
+ def _generate_container_name(self, image: str) -> str:
260
+ """
261
+ Generate a unique container name based on image name and timestamp.
262
+
263
+ Args:
264
+ image: Docker image name
265
+
266
+ Returns:
267
+ A unique container name
268
+ """
269
+ import time
270
+
271
+ clean_image = image.split("/")[-1].split(":")[0]
272
+ timestamp = int(time.time() * 1000)
273
+ return f"{clean_image}-{timestamp}"
274
+
275
+
276
+ class KubernetesProvider(ContainerProvider):
277
+ """
278
+ Container provider for Kubernetes clusters.
279
+
280
+ This provider creates pods in a Kubernetes cluster and exposes them
281
+ via services or port-forwarding.
282
+
283
+ Example:
284
+ >>> provider = KubernetesProvider(namespace="envtorch-dev")
285
+ >>> base_url = provider.start_container("echo-env:latest")
286
+ >>> # Pod running in k8s, accessible via service or port-forward
287
+ >>> provider.stop_container()
288
+ """
289
+ pass
src/core/containers/test_local_docker_provider.py ADDED
@@ -0,0 +1,258 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ End-to-end test for LocalDockerProvider.
4
+
5
+ This script tests the complete flow:
6
+ 1. Start a container using LocalDockerProvider
7
+ 2. Wait for it to be ready
8
+ 3. Make HTTP requests to test the environment
9
+ 4. Clean up the container
10
+ """
11
+
12
+ import sys
13
+ from pathlib import Path
14
+
15
+ # Add src to path
16
+ sys.path.insert(0, str(Path(__file__).parent.parent.parent))
17
+
18
+ import requests
19
+
20
+ from core.containers.runtime import LocalDockerProvider
21
+
22
+ # TODO: Remove this test or make it a functional test sicne this will be tested in e2e test for echo env
23
+ def test_local_docker_provider():
24
+ """Test LocalDockerProvider end-to-end."""
25
+ print("=" * 60)
26
+ print("LocalDockerProvider End-to-End Test")
27
+ print("=" * 60)
28
+ print()
29
+
30
+ provider = None
31
+
32
+ try:
33
+ # Step 1: Create provider
34
+ print("Step 1: Creating LocalDockerProvider...")
35
+ provider = LocalDockerProvider()
36
+ print("βœ“ Provider created\n")
37
+
38
+ # Step 2: Start container
39
+ print("Step 2: Starting echo-env container...")
40
+ base_url = provider.start_container("echo-env:latest")
41
+ print(f"βœ“ Container started at: {base_url}")
42
+ if provider._container_id:
43
+ print(f" Container ID: {provider._container_id[:12]}...")
44
+ if provider._container_name:
45
+ print(f" Container name: {provider._container_name}\n")
46
+
47
+ # Step 3: Wait for ready
48
+ print("Step 3: Waiting for container to be ready...")
49
+ provider.wait_for_ready(base_url, timeout_s=30.0)
50
+ print("βœ“ Container is ready!\n")
51
+
52
+ # Step 4: Test health endpoint
53
+ print("Step 4: Testing /health endpoint...")
54
+ response = requests.get(f"{base_url}/health")
55
+ print(f" Status: {response.status_code}")
56
+ print(f" Response: {response.json()}")
57
+ assert response.status_code == 200
58
+ assert response.json()["status"] == "healthy"
59
+ print("βœ“ Health check passed\n")
60
+
61
+ # Step 5: Test reset endpoint
62
+ print("Step 5: Testing /reset endpoint...")
63
+ response = requests.post(
64
+ f"{base_url}/reset",
65
+ json={},
66
+ headers={"Content-Type": "application/json"},
67
+ )
68
+ print(f" Status: {response.status_code}")
69
+ data = response.json()
70
+ print(f" Message: {data['observation']['echoed_message']}")
71
+ print(f" Reward: {data['reward']}")
72
+ print(f" Done: {data['done']}")
73
+ assert response.status_code == 200
74
+ assert data["observation"]["echoed_message"] == "Echo environment ready!"
75
+ print("βœ“ Reset test passed\n")
76
+
77
+ # Step 6: Test step endpoint
78
+ print("Step 6: Testing /step endpoint...")
79
+ response = requests.post(
80
+ f"{base_url}/step",
81
+ json={"action": {"message": "Hello from LocalDockerProvider!"}},
82
+ headers={"Content-Type": "application/json"},
83
+ )
84
+ print(f" Status: {response.status_code}")
85
+ data = response.json()
86
+ print(f" Echoed: {data['observation']['echoed_message']}")
87
+ print(f" Length: {data['observation']['message_length']}")
88
+ print(f" Reward: {data['reward']}")
89
+ assert response.status_code == 200
90
+ assert data["observation"]["echoed_message"] == "Hello from LocalDockerProvider!"
91
+ assert data["observation"]["message_length"] == 31
92
+ print("βœ“ Step test passed\n")
93
+
94
+ # Step 7: Test state endpoint
95
+ print("Step 7: Testing /state endpoint...")
96
+ response = requests.get(f"{base_url}/state")
97
+ print(f" Status: {response.status_code}")
98
+ data = response.json()
99
+ print(f" Episode ID: {data['episode_id']}")
100
+ print(f" Step count: {data['step_count']}")
101
+ assert response.status_code == 200
102
+ assert data["step_count"] == 1 # One step from above
103
+ print("βœ“ State test passed\n")
104
+
105
+ # Step 8: Multiple steps
106
+ print("Step 8: Testing multiple steps...")
107
+ for i in range(3):
108
+ response = requests.post(
109
+ f"{base_url}/step",
110
+ json={"action": {"message": f"Message {i+1}"}},
111
+ headers={"Content-Type": "application/json"},
112
+ )
113
+ assert response.status_code == 200
114
+ print(f" Step {i+1}: βœ“")
115
+
116
+ # Check state updated
117
+ response = requests.get(f"{base_url}/state")
118
+ data = response.json()
119
+ assert data["step_count"] == 4 # 1 + 3 more steps
120
+ print(f" Final step count: {data['step_count']}")
121
+ print("βœ“ Multiple steps test passed\n")
122
+
123
+ print("=" * 60)
124
+ print("βœ“ All tests passed!")
125
+ print("=" * 60)
126
+ print()
127
+
128
+ return True
129
+
130
+ except Exception as e:
131
+ print(f"\n❌ Test failed: {e}")
132
+ import traceback
133
+ traceback.print_exc()
134
+ return False
135
+
136
+ finally:
137
+ # Step 9: Cleanup
138
+ if provider is not None:
139
+ print("\nStep 9: Cleaning up container...")
140
+ try:
141
+ provider.stop_container()
142
+ print("βœ“ Container stopped and removed\n")
143
+ except Exception as e:
144
+ print(f"⚠️ Cleanup warning: {e}\n")
145
+
146
+
147
+ def test_provider_with_custom_port():
148
+ """Test provider with custom port."""
149
+ print("=" * 60)
150
+ print("LocalDockerProvider with Custom Port Test")
151
+ print("=" * 60)
152
+ print()
153
+
154
+ provider = None
155
+
156
+ try:
157
+ provider = LocalDockerProvider()
158
+
159
+ print("Starting container on custom port 8123...")
160
+ base_url = provider.start_container("echo-env:latest", port=8123)
161
+ print(f"βœ“ Started at: {base_url}")
162
+ assert ":8123" in base_url
163
+
164
+ print("Waiting for ready...")
165
+ provider.wait_for_ready(base_url)
166
+ print("βœ“ Ready!")
167
+
168
+ print("Testing health...")
169
+ response = requests.get(f"{base_url}/health")
170
+ assert response.status_code == 200
171
+ print("βœ“ Health check passed")
172
+
173
+ print("\nβœ“ Custom port test passed!\n")
174
+ return True
175
+
176
+ except Exception as e:
177
+ print(f"\n❌ Test failed: {e}")
178
+ return False
179
+
180
+ finally:
181
+ if provider is not None:
182
+ provider.stop_container()
183
+ print("βœ“ Cleaned up\n")
184
+
185
+
186
+ def test_provider_with_env_vars():
187
+ """Test provider with environment variables."""
188
+ print("=" * 60)
189
+ print("LocalDockerProvider with Environment Variables Test")
190
+ print("=" * 60)
191
+ print()
192
+
193
+ provider = None
194
+
195
+ try:
196
+ provider = LocalDockerProvider()
197
+
198
+ print("Starting container with environment variables...")
199
+ base_url = provider.start_container(
200
+ "echo-env:latest",
201
+ env_vars={"DEBUG": "true", "LOG_LEVEL": "info"}
202
+ )
203
+ print(f"βœ“ Started at: {base_url}")
204
+
205
+ print("Waiting for ready...")
206
+ provider.wait_for_ready(base_url)
207
+ print("βœ“ Ready!")
208
+
209
+ print("Testing health...")
210
+ response = requests.get(f"{base_url}/health")
211
+ assert response.status_code == 200
212
+ print("βœ“ Health check passed")
213
+
214
+ print("\nβœ“ Environment variables test passed!\n")
215
+ return True
216
+
217
+ except Exception as e:
218
+ print(f"\n❌ Test failed: {e}")
219
+ return False
220
+
221
+ finally:
222
+ if provider is not None:
223
+ provider.stop_container()
224
+ print("βœ“ Cleaned up\n")
225
+
226
+
227
+ if __name__ == "__main__":
228
+ print()
229
+ print("🐳 LocalDockerProvider Test Suite")
230
+ print()
231
+
232
+ results = []
233
+
234
+ # Run basic test
235
+ results.append(("Basic End-to-End", test_local_docker_provider()))
236
+
237
+ # Run custom port test
238
+ results.append(("Custom Port", test_provider_with_custom_port()))
239
+
240
+ # Run environment variables test
241
+ results.append(("Environment Variables", test_provider_with_env_vars()))
242
+
243
+ # Summary
244
+ print("=" * 60)
245
+ print("Test Summary")
246
+ print("=" * 60)
247
+ for name, passed in results:
248
+ status = "βœ“ PASSED" if passed else "βœ— FAILED"
249
+ print(f"{name:25} {status}")
250
+ print("=" * 60)
251
+
252
+ all_passed = all(result for _, result in results)
253
+ if all_passed:
254
+ print("\nπŸŽ‰ All tests passed!")
255
+ exit(0)
256
+ else:
257
+ print("\n❌ Some tests failed")
258
+ exit(1)
src/core/env_server/__init__.py ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ """Core environment interfaces and types."""
8
+
9
+ from .base_transforms import CompositeTransform, NullTransform
10
+ from .http_server import HTTPEnvServer, create_app, create_fastapi_app
11
+ from .interfaces import Environment, Message, ModelTokenizer, Transform
12
+ from .types import Action, Observation, State
13
+ from .web_interface import create_web_interface_app, WebInterfaceManager
14
+
15
+ __all__ = [
16
+ # Core interfaces
17
+ "Environment",
18
+ "Transform",
19
+ "Message",
20
+ "ModelTokenizer",
21
+ # Types
22
+ "Action",
23
+ "Observation",
24
+ "State",
25
+ # Base transforms
26
+ "CompositeTransform",
27
+ "NullTransform",
28
+ # HTTP Server
29
+ "HTTPEnvServer",
30
+ "create_app",
31
+ "create_fastapi_app",
32
+ # Web Interface
33
+ "create_web_interface_app",
34
+ "WebInterfaceManager",
35
+ ]
src/core/env_server/__pycache__/__init__.cpython-311.pyc ADDED
Binary file (898 Bytes). View file
 
src/core/env_server/__pycache__/__init__.cpython-313.pyc ADDED
Binary file (940 Bytes). View file
 
src/core/env_server/__pycache__/base_transforms.cpython-311.pyc ADDED
Binary file (1.67 kB). View file
 
src/core/env_server/__pycache__/base_transforms.cpython-313.pyc ADDED
Binary file (1.57 kB). View file
 
src/core/env_server/__pycache__/http_server.cpython-311.pyc ADDED
Binary file (9.2 kB). View file
 
src/core/env_server/__pycache__/http_server.cpython-313.pyc ADDED
Binary file (7.14 kB). View file
 
src/core/env_server/__pycache__/interfaces.cpython-311.pyc ADDED
Binary file (5.22 kB). View file
 
src/core/env_server/__pycache__/interfaces.cpython-313.pyc ADDED
Binary file (4.68 kB). View file
 
src/core/env_server/__pycache__/types.cpython-311.pyc ADDED
Binary file (2.39 kB). View file
 
src/core/env_server/__pycache__/types.cpython-313.pyc ADDED
Binary file (2.1 kB). View file
 
src/core/env_server/__pycache__/web_interface.cpython-311.pyc ADDED
Binary file (29.9 kB). View file
 
src/core/env_server/base_transforms.py ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ """Base transform implementations for composing environment-specific transforms."""
8
+
9
+ from .interfaces import Transform
10
+ from .types import Observation
11
+
12
+
13
+ class CompositeTransform(Transform):
14
+ """Combines multiple transforms into a single transform."""
15
+
16
+ def __init__(self, transforms: list[Transform]):
17
+ self.transforms = transforms
18
+
19
+ def __call__(self, observation: Observation) -> Observation:
20
+ for transform in self.transforms:
21
+ observation = transform(observation)
22
+ return observation
23
+
24
+
25
+ class NullTransform(Transform):
26
+ """Default transform that passes through unchanged."""
27
+
28
+ def __call__(self, observation: Observation) -> Observation:
29
+ return observation
src/core/env_server/http_server.py ADDED
@@ -0,0 +1,231 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ """
8
+ HTTP server wrapper for Environment instances.
9
+
10
+ This module provides utilities to wrap any Environment subclass and expose it
11
+ over HTTP endpoints that HTTPEnvClient can consume.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import os
17
+ from dataclasses import asdict
18
+ from typing import Any, Dict, Type
19
+
20
+ from .interfaces import Environment
21
+ from .types import Action, Observation
22
+ from fastapi import Body, FastAPI
23
+
24
+ class HTTPEnvServer:
25
+ """
26
+ HTTP server wrapper for Environment instances.
27
+
28
+ This class wraps an Environment and exposes its reset(), step(), and state
29
+ methods as HTTP endpoints compatible with HTTPEnvClient.
30
+
31
+ The server expects:
32
+ - Action deserialization: Converts JSON dict to Action subclass
33
+ - Observation serialization: Converts Observation subclass to JSON dict
34
+
35
+ Example:
36
+ >>> from core.env_server import HTTPEnvServer
37
+ >>> from envs.coding_env.server import CodeExecutionEnvironment
38
+ >>>
39
+ >>> env = CodeExecutionEnvironment()
40
+ >>> server = HTTPEnvServer(env)
41
+ >>>
42
+ >>> # Register routes with FastAPI
43
+ >>> from fastapi import FastAPI
44
+ >>> app = FastAPI()
45
+ >>> server.register_routes(app)
46
+ """
47
+
48
+ def __init__(
49
+ self,
50
+ env: Environment,
51
+ action_cls: Type[Action],
52
+ observation_cls: Type[Observation],
53
+ ):
54
+ """
55
+ Initialize HTTP server wrapper.
56
+
57
+ Args:
58
+ env: The Environment instance to wrap
59
+ action_cls: The Action subclass this environment expects
60
+ observation_cls: The Observation subclass this environment returns
61
+ """
62
+ self.env = env
63
+ self.action_cls = action_cls
64
+ self.observation_cls = observation_cls
65
+
66
+ def register_routes(self, app: Any) -> None:
67
+ """
68
+ Register HTTP routes on a FastAPI application.
69
+
70
+ Args:
71
+ app: FastAPI application instance
72
+ """
73
+
74
+ if not isinstance(app, FastAPI):
75
+ raise TypeError("app must be a FastAPI instance")
76
+
77
+ @app.post("/reset")
78
+ async def reset(request: Dict[str, Any] = Body(default={})) -> Dict[str, Any]:
79
+ """Reset endpoint - returns initial observation."""
80
+ # TODO: Handle seed, episode_id from request if provided
81
+ observation = self.env.reset()
82
+ return self._serialize_observation(observation)
83
+
84
+ @app.post("/step")
85
+ async def step(request: Dict[str, Any]) -> Dict[str, Any]:
86
+ """Step endpoint - executes action and returns observation."""
87
+ action_data = request.get("action", {})
88
+ # TODO: Handle timeout_s, request_id, episode_id from request if provided
89
+
90
+ # Deserialize action
91
+ action = self._deserialize_action(action_data)
92
+
93
+ # Execute step
94
+ observation = self.env.step(action)
95
+
96
+ # Return serialized observation
97
+ return self._serialize_observation(observation)
98
+
99
+ @app.get("/state")
100
+ async def get_state() -> Dict[str, Any]:
101
+ """State endpoint - returns current environment state."""
102
+ state = self.env.state
103
+ return asdict(state)
104
+
105
+ @app.get("/health")
106
+ async def health() -> Dict[str, str]:
107
+ """Health check endpoint."""
108
+ return {"status": "healthy"}
109
+
110
+
111
+ def _deserialize_action(self, action_data: Dict[str, Any]) -> Action:
112
+ """
113
+ Convert JSON dict to Action instance.
114
+
115
+ Args:
116
+ action_data: Dictionary containing action data
117
+
118
+ Returns:
119
+ Action instance
120
+
121
+ Note:
122
+ This is a simple implementation. Subclasses may need to override
123
+ for more complex deserialization logic.
124
+ """
125
+ # Remove metadata if present (it will be set via kw_only field)
126
+ metadata = action_data.pop("metadata", {})
127
+ action = self.action_cls(**action_data)
128
+ action.metadata = metadata
129
+ return action
130
+
131
+ def _serialize_observation(self, observation: Observation) -> Dict[str, Any]:
132
+ """
133
+ Convert Observation instance to JSON-compatible dict.
134
+
135
+ Args:
136
+ observation: Observation instance
137
+
138
+ Returns:
139
+ Dictionary compatible with HTTPEnvClient._parse_result()
140
+
141
+ The format matches what HTTPEnvClient expects:
142
+ {
143
+ "observation": {...}, # Observation fields
144
+ "reward": float | None,
145
+ "done": bool,
146
+ }
147
+ """
148
+ obs_dict = asdict(observation)
149
+
150
+ # Extract reward and done (these are part of StepResult on client side)
151
+ reward = obs_dict.pop("reward", None)
152
+ done = obs_dict.pop("done", False)
153
+ obs_dict.pop("metadata", None) # Remove metadata from observation
154
+
155
+ # Return in HTTPEnvClient expected format
156
+ return {
157
+ "observation": obs_dict,
158
+ "reward": reward,
159
+ "done": done,
160
+ }
161
+
162
+ def create_app(
163
+ env: Environment,
164
+ action_cls: Type[Action],
165
+ observation_cls: Type[Observation],
166
+ ) -> Any:
167
+ """
168
+ Create a FastAPI application with web interface enabled for Hugging Face deployments.
169
+
170
+ This function checks for the ENABLE_WEB_INTERFACE environment variable to determine
171
+ whether to enable the web interface.
172
+
173
+ Args:
174
+ env: The Environment instance to serve
175
+ action_cls: The Action subclass this environment expects
176
+ observation_cls: The Observation subclass this environment returns
177
+
178
+ Returns:
179
+ FastAPI application instance with or without web interface based on environment
180
+ """
181
+ # Check if web interface should be enabled
182
+ # This can be controlled via environment variable or build argument
183
+ enable_web = (
184
+ os.getenv("ENABLE_WEB_INTERFACE", "false").lower() in ("true", "1", "yes")
185
+ )
186
+
187
+ if enable_web:
188
+ # Import web interface only when needed
189
+ from .web_interface import create_web_interface_app
190
+ return create_web_interface_app(env, action_cls, observation_cls)
191
+ else:
192
+ # Use standard FastAPI app without web interface
193
+ return create_fastapi_app(env, action_cls, observation_cls)
194
+
195
+
196
+ def create_fastapi_app(
197
+ env: Environment,
198
+ action_cls: Type[Action],
199
+ observation_cls: Type[Observation],
200
+ ) -> Any:
201
+ """
202
+ Create a FastAPI application with routes for the given environment.
203
+
204
+ Args:
205
+ env: The Environment instance to serve
206
+ action_cls: The Action subclass this environment expects
207
+ observation_cls: The Observation subclass this environment returns
208
+
209
+ Returns:
210
+ FastAPI application instance with routes registered
211
+
212
+ Example:
213
+ >>> from envs.coding_env.server import CodeExecutionEnvironment
214
+ >>> from envs.coding_env.models import CodeAction, CodeObservation
215
+ >>>
216
+ >>> env = CodeExecutionEnvironment()
217
+ >>> app = create_fastapi_app(env, CodeAction, CodeObservation)
218
+ >>>
219
+ >>> # Run with: uvicorn module:app --host 0.0.0.0 --port 8000
220
+ """
221
+ try:
222
+ from fastapi import FastAPI
223
+ except ImportError:
224
+ raise ImportError(
225
+ "FastAPI is required. Install with: pip install fastapi uvicorn"
226
+ )
227
+
228
+ app = FastAPI(title="Environment HTTP Server")
229
+ server = HTTPEnvServer(env, action_cls, observation_cls)
230
+ server.register_routes(app)
231
+ return app
src/core/env_server/interfaces.py ADDED
@@ -0,0 +1,118 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ from abc import ABC, abstractmethod
8
+ from typing import Any, Protocol, TypedDict
9
+
10
+ from .types import Action, Observation, State
11
+
12
+
13
+ class Message(TypedDict):
14
+ """A message in a conversation.
15
+
16
+ Compatible with Huggingface chat template format.
17
+ """
18
+
19
+ role: str
20
+ content: str
21
+
22
+
23
+ class ModelTokenizer(Protocol):
24
+ """Protocol for tokenizers that support chat templates.
25
+
26
+ This protocol defines the interface that tokenizers must implement
27
+ to work with chat-based environments. It's compatible with
28
+ Huggingface transformers tokenizers.
29
+ """
30
+
31
+ def apply_chat_template(
32
+ self,
33
+ conversation: list[Message],
34
+ tokenize: bool = True,
35
+ return_tensors: str | None = None,
36
+ **kwargs: Any,
37
+ ) -> Any:
38
+ """Apply a chat template to format and optionally tokenize a conversation.
39
+
40
+ Args:
41
+ conversation: List of message dictionaries with 'role' and 'content'
42
+ tokenize: Whether to tokenize the output
43
+ return_tensors: Format for returned tensors ('pt' for PyTorch)
44
+ **kwargs: Additional arguments
45
+
46
+ Returns:
47
+ Formatted and optionally tokenized conversation
48
+ """
49
+ ...
50
+
51
+ def decode(
52
+ self, token_ids: Any, skip_special_tokens: bool = False, **kwargs: Any
53
+ ) -> str:
54
+ """Decode token IDs back to text.
55
+
56
+ Args:
57
+ token_ids: Token IDs to decode
58
+ skip_special_tokens: Whether to skip special tokens in output
59
+ **kwargs: Additional arguments
60
+
61
+ Returns:
62
+ Decoded text string
63
+ """
64
+ ...
65
+
66
+
67
+ class Transform(ABC):
68
+ """Transform observations to add rewards, metrics, or other modifications.
69
+
70
+ Transforms follow the TorchRL pattern where they take an observation
71
+ and return a (potentially modified) observation. This allows for
72
+ flexible reward computation and observation augmentation.
73
+ """
74
+
75
+ @abstractmethod
76
+ def __call__(self, observation: Observation) -> Observation:
77
+ """Transform an observation.
78
+
79
+ Args:
80
+ observation: The input observation
81
+
82
+ Returns:
83
+ The transformed observation
84
+ """
85
+ pass
86
+
87
+
88
+ class Environment(ABC):
89
+ """Base class for all environment servers following Gym/Gymnasium API.
90
+
91
+ Args:
92
+ transform: Optional transform to apply to observations
93
+ """
94
+
95
+ def __init__(self, transform: Transform | None = None):
96
+ self.transform = transform
97
+
98
+ @abstractmethod
99
+ def reset(self) -> Observation:
100
+ """Reset the environment and return initial observation."""
101
+ pass
102
+
103
+ @abstractmethod
104
+ def step(self, action: Action) -> Observation:
105
+ """Take a step in the environment."""
106
+ pass
107
+
108
+ @property
109
+ @abstractmethod
110
+ def state(self) -> State:
111
+ """Get the current environment state."""
112
+ pass
113
+
114
+ def _apply_transform(self, observation: Observation) -> Observation:
115
+ """Apply transform if one is provided."""
116
+ if self.transform is not None:
117
+ return self.transform(observation)
118
+ return observation
src/core/env_server/types.py ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ from dataclasses import dataclass, field
8
+ from typing import Any, Dict, List, Optional, Union
9
+
10
+
11
+ # Type aliases
12
+ Scalar = Union[int, float, bool]
13
+
14
+
15
+ @dataclass(kw_only=True)
16
+ class Action:
17
+ """Base class for all environment actions."""
18
+
19
+ metadata: Dict[str, Any] = field(default_factory=dict)
20
+
21
+
22
+ @dataclass(kw_only=True)
23
+ class Observation:
24
+ """Base class for all environment observations."""
25
+
26
+ done: bool = False
27
+ reward: Union[bool, int, float, None] = None
28
+ metadata: Dict[str, Any] = field(default_factory=dict)
29
+
30
+
31
+ @dataclass
32
+ class State:
33
+ """Base class for environment state."""
34
+
35
+ episode_id: Optional[str] = None
36
+ step_count: int = 0
37
+
38
+
39
+ @dataclass
40
+ class CodeExecResult:
41
+ """Result of code execution containing stdout, stderr, and exit code."""
42
+
43
+ stdout: str
44
+ stderr: str
45
+ exit_code: int
src/core/env_server/web_interface.py ADDED
@@ -0,0 +1,764 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ """
8
+ Web interface for OpenEnv environments.
9
+
10
+ This module provides a web-based interface for interacting with OpenEnv environments,
11
+ including a two-pane layout for HumanAgent interaction and state observation.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import time
18
+ from dataclasses import asdict, dataclass
19
+ from typing import Any, Dict, List, Optional, Type
20
+ from datetime import datetime
21
+
22
+ from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Request
23
+ from fastapi.responses import HTMLResponse, FileResponse
24
+ from fastapi.staticfiles import StaticFiles
25
+ from pydantic import BaseModel
26
+
27
+ from .interfaces import Environment
28
+ from .types import Action, Observation, State
29
+
30
+
31
+ @dataclass
32
+ class ActionLog:
33
+ """Log entry for an action taken."""
34
+ timestamp: str
35
+ action: Dict[str, Any]
36
+ observation: Dict[str, Any]
37
+ reward: Optional[float]
38
+ done: bool
39
+ step_count: int
40
+
41
+
42
+ @dataclass
43
+ class EpisodeState:
44
+ """Current episode state for the web interface."""
45
+ episode_id: Optional[str]
46
+ step_count: int
47
+ current_observation: Optional[Dict[str, Any]]
48
+ action_logs: List[ActionLog]
49
+ is_reset: bool = True
50
+
51
+
52
+ class WebInterfaceManager:
53
+ """Manages the web interface for an environment."""
54
+
55
+ def __init__(
56
+ self,
57
+ env: Environment,
58
+ action_cls: Type[Action],
59
+ observation_cls: Type[Observation],
60
+ ):
61
+ self.env = env
62
+ self.action_cls = action_cls
63
+ self.observation_cls = observation_cls
64
+ self.episode_state = EpisodeState(
65
+ episode_id=None,
66
+ step_count=0,
67
+ current_observation=None,
68
+ action_logs=[]
69
+ )
70
+ self.connected_clients: List[WebSocket] = []
71
+
72
+ async def connect_websocket(self, websocket: WebSocket):
73
+ """Connect a new WebSocket client."""
74
+ await websocket.accept()
75
+ self.connected_clients.append(websocket)
76
+
77
+ # Send current state to the new client
78
+ await self._send_state_update()
79
+
80
+ async def disconnect_websocket(self, websocket: WebSocket):
81
+ """Disconnect a WebSocket client."""
82
+ if websocket in self.connected_clients:
83
+ self.connected_clients.remove(websocket)
84
+
85
+ async def _send_state_update(self):
86
+ """Send current state to all connected clients."""
87
+ if not self.connected_clients:
88
+ return
89
+
90
+ state_data = {
91
+ "type": "state_update",
92
+ "episode_state": asdict(self.episode_state)
93
+ }
94
+
95
+ # Send to all connected clients
96
+ disconnected_clients = []
97
+ for client in self.connected_clients:
98
+ try:
99
+ await client.send_text(json.dumps(state_data))
100
+ except:
101
+ disconnected_clients.append(client)
102
+
103
+ # Remove disconnected clients
104
+ for client in disconnected_clients:
105
+ self.connected_clients.remove(client)
106
+
107
+ async def reset_environment(self) -> Dict[str, Any]:
108
+ """Reset the environment and update state."""
109
+ observation = self.env.reset()
110
+ state = self.env.state
111
+
112
+ # Update episode state
113
+ self.episode_state.episode_id = state.episode_id
114
+ self.episode_state.step_count = 0
115
+ self.episode_state.current_observation = asdict(observation)
116
+ self.episode_state.action_logs = []
117
+ self.episode_state.is_reset = True
118
+
119
+ # Send state update
120
+ await self._send_state_update()
121
+
122
+ return {
123
+ "observation": asdict(observation),
124
+ "reward": observation.reward,
125
+ "done": observation.done,
126
+ }
127
+
128
+ async def step_environment(self, action_data: Dict[str, Any]) -> Dict[str, Any]:
129
+ """Execute a step in the environment and update state."""
130
+ # Deserialize action
131
+ action = self._deserialize_action(action_data)
132
+
133
+ # Execute step
134
+ observation = self.env.step(action)
135
+ state = self.env.state
136
+
137
+ # Create action log
138
+ action_log = ActionLog(
139
+ timestamp=datetime.now().isoformat(),
140
+ action=asdict(action),
141
+ observation=asdict(observation),
142
+ reward=observation.reward,
143
+ done=observation.done,
144
+ step_count=state.step_count
145
+ )
146
+
147
+ # Update episode state
148
+ self.episode_state.episode_id = state.episode_id
149
+ self.episode_state.step_count = state.step_count
150
+ self.episode_state.current_observation = asdict(observation)
151
+ self.episode_state.action_logs.append(action_log)
152
+ self.episode_state.is_reset = False
153
+
154
+ # Send state update
155
+ await self._send_state_update()
156
+
157
+ return {
158
+ "observation": asdict(observation),
159
+ "reward": observation.reward,
160
+ "done": observation.done,
161
+ }
162
+
163
+ def get_state(self) -> Dict[str, Any]:
164
+ """Get current environment state."""
165
+ state = self.env.state
166
+ return asdict(state)
167
+
168
+ def _deserialize_action(self, action_data: Dict[str, Any]) -> Action:
169
+ """Convert JSON dict to Action instance."""
170
+ metadata = action_data.pop("metadata", {})
171
+ action = self.action_cls(**action_data)
172
+ action.metadata = metadata
173
+ return action
174
+
175
+
176
+ def create_web_interface_app(
177
+ env: Environment,
178
+ action_cls: Type[Action],
179
+ observation_cls: Type[Observation],
180
+ ) -> FastAPI:
181
+ """
182
+ Create a FastAPI application with web interface for the given environment.
183
+
184
+ Args:
185
+ env: The Environment instance to serve
186
+ action_cls: The Action subclass this environment expects
187
+ observation_cls: The Observation subclass this environment returns
188
+
189
+ Returns:
190
+ FastAPI application instance with web interface
191
+ """
192
+ from .http_server import create_fastapi_app
193
+
194
+ # Create the base environment app
195
+ app = create_fastapi_app(env, action_cls, observation_cls)
196
+
197
+ # Create web interface manager
198
+ web_manager = WebInterfaceManager(env, action_cls, observation_cls)
199
+
200
+ # Add web interface routes
201
+ @app.get("/web", response_class=HTMLResponse)
202
+ async def web_interface():
203
+ """Serve the web interface."""
204
+ return get_web_interface_html(action_cls)
205
+
206
+ @app.websocket("/ws")
207
+ async def websocket_endpoint(websocket: WebSocket):
208
+ """WebSocket endpoint for real-time updates."""
209
+ await web_manager.connect_websocket(websocket)
210
+ try:
211
+ while True:
212
+ # Keep connection alive
213
+ await websocket.receive_text()
214
+ except WebSocketDisconnect:
215
+ await web_manager.disconnect_websocket(websocket)
216
+
217
+ @app.post("/web/reset")
218
+ async def web_reset():
219
+ """Reset endpoint for web interface."""
220
+ return await web_manager.reset_environment()
221
+
222
+ @app.post("/web/step")
223
+ async def web_step(request: Dict[str, Any]):
224
+ """Step endpoint for web interface."""
225
+ action_data = request.get("action", {})
226
+ return await web_manager.step_environment(action_data)
227
+
228
+ @app.get("/web/state")
229
+ async def web_state():
230
+ """State endpoint for web interface."""
231
+ return web_manager.get_state()
232
+
233
+ return app
234
+
235
+
236
+ def get_web_interface_html(action_cls: Type[Action]) -> str:
237
+ """Generate the HTML for the web interface."""
238
+
239
+ # Get action fields for dynamic form generation
240
+ action_fields = []
241
+ if hasattr(action_cls, '__dataclass_fields__'):
242
+ for field_name, field_info in action_cls.__dataclass_fields__.items():
243
+ if field_name != 'metadata':
244
+ field_type = field_info.type
245
+ if field_type == str:
246
+ input_type = "text"
247
+ elif field_type == int:
248
+ input_type = "number"
249
+ elif field_type == float:
250
+ input_type = "number"
251
+ elif field_type == bool:
252
+ input_type = "checkbox"
253
+ else:
254
+ input_type = "text"
255
+
256
+ action_fields.append({
257
+ 'name': field_name,
258
+ 'type': input_type,
259
+ 'required': field_info.default is field_info.default_factory
260
+ })
261
+
262
+ return f"""
263
+ <!DOCTYPE html>
264
+ <html lang="en">
265
+ <head>
266
+ <meta charset="UTF-8">
267
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
268
+ <title>OpenEnv Web Interface</title>
269
+ <style>
270
+ * {{
271
+ margin: 0;
272
+ padding: 0;
273
+ box-sizing: border-box;
274
+ }}
275
+
276
+ body {{
277
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
278
+ background-color: #f5f5f5;
279
+ height: 100vh;
280
+ overflow: hidden;
281
+ }}
282
+
283
+ .container {{
284
+ display: flex;
285
+ height: 100vh;
286
+ }}
287
+
288
+ .left-pane {{
289
+ width: 50%;
290
+ background: white;
291
+ border-right: 1px solid #e0e0e0;
292
+ display: flex;
293
+ flex-direction: column;
294
+ }}
295
+
296
+ .right-pane {{
297
+ width: 50%;
298
+ background: #fafafa;
299
+ display: flex;
300
+ flex-direction: column;
301
+ }}
302
+
303
+ .pane-header {{
304
+ padding: 20px;
305
+ border-bottom: 1px solid #e0e0e0;
306
+ background: #f8f9fa;
307
+ font-weight: 600;
308
+ font-size: 16px;
309
+ }}
310
+
311
+ .pane-content {{
312
+ flex: 1;
313
+ padding: 20px;
314
+ overflow-y: auto;
315
+ }}
316
+
317
+ .action-form {{
318
+ background: white;
319
+ border: 1px solid #e0e0e0;
320
+ border-radius: 8px;
321
+ padding: 20px;
322
+ margin-bottom: 20px;
323
+ }}
324
+
325
+ .form-group {{
326
+ margin-bottom: 15px;
327
+ }}
328
+
329
+ .form-group label {{
330
+ display: block;
331
+ margin-bottom: 5px;
332
+ font-weight: 500;
333
+ color: #333;
334
+ }}
335
+
336
+ .form-group input, .form-group textarea {{
337
+ width: 100%;
338
+ padding: 8px 12px;
339
+ border: 1px solid #ddd;
340
+ border-radius: 4px;
341
+ font-size: 14px;
342
+ }}
343
+
344
+ .form-group input:focus, .form-group textarea:focus {{
345
+ outline: none;
346
+ border-color: #007bff;
347
+ box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
348
+ }}
349
+
350
+ .btn {{
351
+ background: #007bff;
352
+ color: white;
353
+ border: none;
354
+ padding: 10px 20px;
355
+ border-radius: 4px;
356
+ cursor: pointer;
357
+ font-size: 14px;
358
+ margin-right: 10px;
359
+ margin-bottom: 10px;
360
+ }}
361
+
362
+ .btn:hover {{
363
+ background: #0056b3;
364
+ }}
365
+
366
+ .btn:disabled {{
367
+ background: #6c757d;
368
+ cursor: not-allowed;
369
+ }}
370
+
371
+ .btn-secondary {{
372
+ background: #6c757d;
373
+ }}
374
+
375
+ .btn-secondary:hover {{
376
+ background: #545b62;
377
+ }}
378
+
379
+ .state-display {{
380
+ background: white;
381
+ border: 1px solid #e0e0e0;
382
+ border-radius: 8px;
383
+ padding: 15px;
384
+ margin-bottom: 20px;
385
+ }}
386
+
387
+ .state-item {{
388
+ margin-bottom: 8px;
389
+ }}
390
+
391
+ .state-label {{
392
+ font-weight: 500;
393
+ color: #666;
394
+ }}
395
+
396
+ .state-value {{
397
+ color: #333;
398
+ font-family: monospace;
399
+ }}
400
+
401
+ .logs-container {{
402
+ background: white;
403
+ border: 1px solid #e0e0e0;
404
+ border-radius: 8px;
405
+ padding: 15px;
406
+ max-height: 400px;
407
+ overflow-y: auto;
408
+ }}
409
+
410
+ .log-entry {{
411
+ border-bottom: 1px solid #f0f0f0;
412
+ padding: 10px 0;
413
+ }}
414
+
415
+ .log-entry:last-child {{
416
+ border-bottom: none;
417
+ }}
418
+
419
+ .log-timestamp {{
420
+ font-size: 12px;
421
+ color: #666;
422
+ margin-bottom: 5px;
423
+ }}
424
+
425
+ .log-action {{
426
+ background: #e3f2fd;
427
+ padding: 8px;
428
+ border-radius: 4px;
429
+ margin-bottom: 5px;
430
+ font-family: monospace;
431
+ font-size: 12px;
432
+ }}
433
+
434
+ .log-observation {{
435
+ background: #f3e5f5;
436
+ padding: 8px;
437
+ border-radius: 4px;
438
+ font-family: monospace;
439
+ font-size: 12px;
440
+ }}
441
+
442
+ .log-reward {{
443
+ font-weight: 600;
444
+ color: #28a745;
445
+ }}
446
+
447
+ .log-done {{
448
+ font-weight: 600;
449
+ color: #dc3545;
450
+ }}
451
+
452
+ .status-indicator {{
453
+ display: inline-block;
454
+ width: 8px;
455
+ height: 8px;
456
+ border-radius: 50%;
457
+ margin-right: 8px;
458
+ }}
459
+
460
+ .status-connected {{
461
+ background: #28a745;
462
+ }}
463
+
464
+ .status-disconnected {{
465
+ background: #dc3545;
466
+ }}
467
+
468
+ .json-display {{
469
+ background: #f8f9fa;
470
+ border: 1px solid #e9ecef;
471
+ border-radius: 4px;
472
+ padding: 10px;
473
+ font-family: monospace;
474
+ font-size: 12px;
475
+ white-space: pre-wrap;
476
+ max-height: 200px;
477
+ overflow-y: auto;
478
+ }}
479
+ </style>
480
+ </head>
481
+ <body>
482
+ <div class="container">
483
+ <!-- Left Pane: HumanAgent Interface -->
484
+ <div class="left-pane">
485
+ <div class="pane-header">
486
+ <span class="status-indicator status-disconnected" id="connection-status"></span>
487
+ HumanAgent Interface
488
+ </div>
489
+ <div class="pane-content">
490
+ <!-- Action Form -->
491
+ <div class="action-form">
492
+ <h3>Take Action</h3>
493
+ <form id="action-form">
494
+ {_generate_action_form_fields(action_fields)}
495
+ <button type="submit" class="btn" id="step-btn">Step</button>
496
+ </form>
497
+ </div>
498
+
499
+ <!-- Control Buttons -->
500
+ <div style="margin-bottom: 20px;">
501
+ <button class="btn btn-secondary" id="reset-btn">Reset Environment</button>
502
+ <button class="btn btn-secondary" id="state-btn">Get State</button>
503
+ </div>
504
+
505
+ <!-- Current State Display -->
506
+ <div class="state-display">
507
+ <h3>Current State</h3>
508
+ <div id="current-state">
509
+ <div class="state-item">
510
+ <span class="state-label">Status:</span>
511
+ <span class="state-value" id="env-status">Not initialized</span>
512
+ </div>
513
+ <div class="state-item">
514
+ <span class="state-label">Episode ID:</span>
515
+ <span class="state-value" id="episode-id">-</span>
516
+ </div>
517
+ <div class="state-item">
518
+ <span class="state-label">Step Count:</span>
519
+ <span class="state-value" id="step-count">0</span>
520
+ </div>
521
+ </div>
522
+ </div>
523
+ </div>
524
+ </div>
525
+
526
+ <!-- Right Pane: State Observer -->
527
+ <div class="right-pane">
528
+ <div class="pane-header">
529
+ State Observer
530
+ </div>
531
+ <div class="pane-content">
532
+ <!-- Current Observation -->
533
+ <div class="state-display">
534
+ <h3>Current Observation</h3>
535
+ <div id="current-observation" class="json-display">
536
+ No observation yet
537
+ </div>
538
+ </div>
539
+
540
+ <!-- Action Logs -->
541
+ <div class="logs-container">
542
+ <h3>Action History</h3>
543
+ <div id="action-logs">
544
+ No actions taken yet
545
+ </div>
546
+ </div>
547
+ </div>
548
+ </div>
549
+ </div>
550
+
551
+ <script>
552
+ class OpenEnvWebInterface {{
553
+ constructor() {{
554
+ this.ws = null;
555
+ this.isConnected = false;
556
+ this.init();
557
+ }}
558
+
559
+ init() {{
560
+ this.connectWebSocket();
561
+ this.setupEventListeners();
562
+ }}
563
+
564
+ connectWebSocket() {{
565
+ const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
566
+ const wsUrl = `${{protocol}}//${{window.location.host}}/ws`;
567
+
568
+ this.ws = new WebSocket(wsUrl);
569
+
570
+ this.ws.onopen = () => {{
571
+ this.isConnected = true;
572
+ this.updateConnectionStatus(true);
573
+ console.log('WebSocket connected');
574
+ }};
575
+
576
+ this.ws.onmessage = (event) => {{
577
+ const data = JSON.parse(event.data);
578
+ if (data.type === 'state_update') {{
579
+ this.updateUI(data.episode_state);
580
+ }}
581
+ }};
582
+
583
+ this.ws.onclose = () => {{
584
+ this.isConnected = false;
585
+ this.updateConnectionStatus(false);
586
+ console.log('WebSocket disconnected');
587
+ // Attempt to reconnect after 3 seconds
588
+ setTimeout(() => this.connectWebSocket(), 3000);
589
+ }};
590
+
591
+ this.ws.onerror = (error) => {{
592
+ console.error('WebSocket error:', error);
593
+ }};
594
+ }}
595
+
596
+ setupEventListeners() {{
597
+ // Action form submission
598
+ document.getElementById('action-form').addEventListener('submit', (e) => {{
599
+ e.preventDefault();
600
+ this.submitAction();
601
+ }});
602
+
603
+ // Reset button
604
+ document.getElementById('reset-btn').addEventListener('click', () => {{
605
+ this.resetEnvironment();
606
+ }});
607
+
608
+ // State button
609
+ document.getElementById('state-btn').addEventListener('click', () => {{
610
+ this.getState();
611
+ }});
612
+ }}
613
+
614
+ async submitAction() {{
615
+ const formData = new FormData(document.getElementById('action-form'));
616
+ const action = {{}};
617
+
618
+ // Collect form data
619
+ for (const [key, value] of formData.entries()) {{
620
+ if (value !== '') {{
621
+ action[key] = value;
622
+ }}
623
+ }}
624
+
625
+ try {{
626
+ const response = await fetch('/web/step', {{
627
+ method: 'POST',
628
+ headers: {{ 'Content-Type': 'application/json' }},
629
+ body: JSON.stringify({{ action }})
630
+ }});
631
+
632
+ if (!response.ok) {{
633
+ throw new Error(`HTTP error! status: ${{response.status}}`);
634
+ }}
635
+
636
+ const result = await response.json();
637
+ console.log('Step result:', result);
638
+ }} catch (error) {{
639
+ console.error('Error submitting action:', error);
640
+ alert('Error submitting action: ' + error.message);
641
+ }}
642
+ }}
643
+
644
+ async resetEnvironment() {{
645
+ try {{
646
+ const response = await fetch('/web/reset', {{
647
+ method: 'POST',
648
+ headers: {{ 'Content-Type': 'application/json' }}
649
+ }});
650
+
651
+ if (!response.ok) {{
652
+ throw new Error(`HTTP error! status: ${{response.status}}`);
653
+ }}
654
+
655
+ const result = await response.json();
656
+ console.log('Reset result:', result);
657
+ }} catch (error) {{
658
+ console.error('Error resetting environment:', error);
659
+ alert('Error resetting environment: ' + error.message);
660
+ }}
661
+ }}
662
+
663
+ async getState() {{
664
+ try {{
665
+ const response = await fetch('/web/state');
666
+ const state = await response.json();
667
+ console.log('Current state:', state);
668
+ alert('Current state: ' + JSON.stringify(state, null, 2));
669
+ }} catch (error) {{
670
+ console.error('Error getting state:', error);
671
+ alert('Error getting state: ' + error.message);
672
+ }}
673
+ }}
674
+
675
+ updateConnectionStatus(connected) {{
676
+ const indicator = document.getElementById('connection-status');
677
+ if (connected) {{
678
+ indicator.className = 'status-indicator status-connected';
679
+ }} else {{
680
+ indicator.className = 'status-indicator status-disconnected';
681
+ }}
682
+ }}
683
+
684
+ updateUI(episodeState) {{
685
+ // Update current state
686
+ document.getElementById('env-status').textContent =
687
+ episodeState.is_reset ? 'Reset' : 'Running';
688
+ document.getElementById('episode-id').textContent =
689
+ episodeState.episode_id || '-';
690
+ document.getElementById('step-count').textContent =
691
+ episodeState.step_count.toString();
692
+
693
+ // Update current observation
694
+ const observationDiv = document.getElementById('current-observation');
695
+ if (episodeState.current_observation) {{
696
+ observationDiv.textContent = JSON.stringify(
697
+ episodeState.current_observation, null, 2
698
+ );
699
+ }} else {{
700
+ observationDiv.textContent = 'No observation yet';
701
+ }}
702
+
703
+ // Update action logs
704
+ const logsDiv = document.getElementById('action-logs');
705
+ if (episodeState.action_logs.length === 0) {{
706
+ logsDiv.innerHTML = 'No actions taken yet';
707
+ }} else {{
708
+ logsDiv.innerHTML = episodeState.action_logs.map(log => `
709
+ <div class="log-entry">
710
+ <div class="log-timestamp">${{log.timestamp}} (Step ${{log.step_count}})</div>
711
+ <div class="log-action">Action: ${{JSON.stringify(log.action, null, 2)}}</div>
712
+ <div class="log-observation">Observation: ${{JSON.stringify(log.observation, null, 2)}}</div>
713
+ <div>
714
+ <span class="log-reward">Reward: ${{log.reward !== null ? log.reward : 'None'}}</span>
715
+ ${{log.done ? '<span class="log-done">DONE</span>' : ''}}
716
+ </div>
717
+ </div>
718
+ `).join('');
719
+ }}
720
+ }}
721
+ }}
722
+
723
+ // Initialize the web interface when the page loads
724
+ document.addEventListener('DOMContentLoaded', () => {{
725
+ new OpenEnvWebInterface();
726
+ }});
727
+ </script>
728
+ </body>
729
+ </html>
730
+ """.replace('{_generate_action_form_fields(action_fields)}', _generate_action_form_fields(action_fields))
731
+
732
+
733
+ def _generate_action_form_fields(action_fields: List[Dict[str, Any]]) -> str:
734
+ """Generate HTML form fields for action input."""
735
+ if not action_fields:
736
+ return '<p>No action fields available</p>'
737
+
738
+ fields_html = []
739
+ for field in action_fields:
740
+ if field['type'] == 'checkbox':
741
+ fields_html.append(f'''
742
+ <div class="form-group">
743
+ <label>
744
+ <input type="checkbox" name="{field['name']}" value="true">
745
+ {field['name']}
746
+ </label>
747
+ </div>
748
+ ''')
749
+ elif field['type'] == 'text' and 'message' in field['name'].lower():
750
+ fields_html.append(f'''
751
+ <div class="form-group">
752
+ <label for="{field['name']}">{field['name']}:</label>
753
+ <textarea name="{field['name']}" id="{field['name']}" rows="3" placeholder="Enter {field['name']}..."></textarea>
754
+ </div>
755
+ ''')
756
+ else:
757
+ fields_html.append(f'''
758
+ <div class="form-group">
759
+ <label for="{field['name']}">{field['name']}:</label>
760
+ <input type="{field['type']}" name="{field['name']}" id="{field['name']}" placeholder="Enter {field['name']}..." {"required" if field['required'] else ""}>
761
+ </div>
762
+ ''')
763
+
764
+ return '\n'.join(fields_html)
src/core/http_env_client.py ADDED
@@ -0,0 +1,175 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ core/runner_env.py
3
+ Minimal HTTP-based environment client.
4
+ - Talks to a single env worker exposing: POST /reset, POST /step
5
+
6
+ Future hooks (commented below) for:
7
+ - episode_id, seed on reset
8
+ - request_id on step
9
+ - custom headers (auth/trace)
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from abc import ABC, abstractmethod
15
+ from typing import TYPE_CHECKING, Any, Dict, Generic, Optional, Type, TypeVar
16
+ from .containers.runtime import LocalDockerProvider
17
+ import requests
18
+
19
+ from .types import StepResult
20
+
21
+ if TYPE_CHECKING:
22
+ from .containers.runtime import ContainerProvider
23
+
24
+ ActT = TypeVar("ActT")
25
+ ObsT = TypeVar("ObsT")
26
+ EnvClientT = TypeVar("EnvClientT", bound="HTTPEnvClient")
27
+
28
+
29
+ class HTTPEnvClient(ABC, Generic[ActT, ObsT]):
30
+ def __init__(
31
+ self,
32
+ base_url: str,
33
+ request_timeout_s: float = 15.0,
34
+ default_headers: Optional[Dict[str, str]] = None,
35
+ provider: Optional["ContainerProvider"] = None,
36
+ ):
37
+ self._base = base_url.rstrip("/")
38
+ self._timeout = float(request_timeout_s)
39
+ self._http = requests.Session()
40
+ self._headers = default_headers or {}
41
+ self._provider = provider
42
+
43
+ @classmethod
44
+ def from_docker_image(
45
+ cls: Type[EnvClientT],
46
+ image: str,
47
+ provider: Optional["ContainerProvider"] = None,
48
+ ) -> EnvClientT:
49
+ """
50
+ Create an environment client by spinning up a Docker container locally.
51
+
52
+ This is a development utility that:
53
+ 1. Starts a Docker container from the specified image
54
+ 2. Waits for the server to be ready
55
+ 3. Creates and returns a client instance connected to the container
56
+
57
+ Note: The container lifecycle management is left to the user or higher-level
58
+ orchestration. The container will keep running until manually stopped.
59
+
60
+ Args:
61
+ image: Docker image name to run (e.g., "echo-env:latest")
62
+ provider: Container provider to use (defaults to LocalDockerProvider)
63
+
64
+ Returns:
65
+ An instance of the client class connected to the running container
66
+
67
+ Example:
68
+ >>> from envs.coding_env.client import CodingEnv
69
+ >>> from envs.coding_env.models import CodeAction
70
+ >>>
71
+ >>> # Create environment from image
72
+ >>> env = CodingEnv.from_docker_image("coding-env:latest")
73
+ >>>
74
+ >>> # Use the environment
75
+ >>> result = env.reset()
76
+ >>> print(result.observation)
77
+ >>>
78
+ >>> step_result = env.step(CodeAction(code="print('hello')"))
79
+ >>> print(step_result.observation.stdout)
80
+ >>>
81
+ >>> # Cleanup (optional)
82
+ >>> env.close()
83
+ """
84
+
85
+ # Use default provider if none provided
86
+ if provider is None:
87
+ provider = LocalDockerProvider()
88
+
89
+ # 1. Start container
90
+ base_url = provider.start_container(image)
91
+
92
+ # 2. Wait for server to be ready
93
+ provider.wait_for_ready(base_url)
94
+
95
+ # 3. Create and return client instance with provider reference
96
+ return cls(base_url=base_url, provider=provider)
97
+
98
+ @abstractmethod
99
+ def _step_payload(self, action: ActT) -> dict:
100
+ """Convert an Action object to the JSON body expected by the env server."""
101
+ raise NotImplementedError
102
+
103
+ @abstractmethod
104
+ def _parse_result(self, payload: dict) -> StepResult[ObsT]:
105
+ """Convert a JSON response from the env server to StepResult[ObsT]."""
106
+ raise NotImplementedError
107
+
108
+ @abstractmethod
109
+ def _parse_state(self, payload: dict) -> Any:
110
+ """Convert a JSON response from the state endpoint to a State object."""
111
+ raise NotImplementedError
112
+
113
+ # ---------- Environment Server Interface Methods ----------
114
+ def reset(self) -> StepResult[ObsT]:
115
+ body: Dict[str, Any] = {}
116
+ # TODO: later:
117
+ # body["seed"] = seed
118
+ # body["episode_id"] = episode_id
119
+ r = self._http.post(
120
+ f"{self._base}/reset",
121
+ json=body,
122
+ headers=self._headers,
123
+ timeout=self._timeout,
124
+ )
125
+ r.raise_for_status()
126
+ return self._parse_result(r.json())
127
+
128
+ def step(self, action: ActT) -> StepResult[ObsT]:
129
+ body: Dict[str, Any] = {
130
+ "action": self._step_payload(action),
131
+ "timeout_s": int(self._timeout),
132
+ }
133
+ # TODO: later:
134
+ # body["request_id"] = str(uuid.uuid4())
135
+ # body["episode_id"] = current_episode_id
136
+ r = self._http.post(
137
+ f"{self._base}/step",
138
+ json=body,
139
+ headers=self._headers,
140
+ timeout=self._timeout,
141
+ )
142
+ r.raise_for_status()
143
+ return self._parse_result(r.json())
144
+
145
+ def state(self) -> Any:
146
+ """
147
+ Get the current environment state from the server.
148
+
149
+ Returns:
150
+ State object with environment state information (e.g., episode_id, step_count)
151
+
152
+ Example:
153
+ >>> client = EchoEnv.from_docker_image("echo-env:latest")
154
+ >>> result = client.reset()
155
+ >>> state = client.state()
156
+ >>> print(state.episode_id)
157
+ >>> print(state.step_count)
158
+ """
159
+ r = self._http.get(
160
+ f"{self._base}/state",
161
+ headers=self._headers,
162
+ timeout=self._timeout,
163
+ )
164
+ r.raise_for_status()
165
+ return self._parse_state(r.json())
166
+
167
+ def close(self) -> None:
168
+ """
169
+ Close the environment and clean up resources.
170
+
171
+ If this client was created via from_docker_image(), this will stop
172
+ and remove the associated container.
173
+ """
174
+ if self._provider is not None:
175
+ self._provider.stop_container()
src/core/tools/__init__.py ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ """Core tools for code execution and other utilities."""
8
+
9
+ from .local_python_executor import PyExecutor
10
+
11
+ __all__ = ["PyExecutor"]
src/core/tools/local_python_executor.py ADDED
@@ -0,0 +1,105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ """
8
+ Local Python Executor.
9
+
10
+ This module provides functionality for executing Python code locally by wrapping
11
+ the smolagents LocalPythonExecutor.
12
+ """
13
+
14
+ from smolagents import LocalPythonExecutor
15
+
16
+ from core.env_server.types import CodeExecResult
17
+
18
+
19
+ class PyExecutor:
20
+ """
21
+ Wrapper around smolagents LocalPythonExecutor for executing Python code.
22
+
23
+ This class provides a simple interface to execute Python code in a subprocess
24
+ and capture the results including stdout, stderr, and exit code.
25
+
26
+ Args:
27
+ additional_imports: List of additional module imports to authorize.
28
+ For example: ["numpy", "pandas", "matplotlib"]
29
+ These will be added to the base authorized imports.
30
+
31
+ Example:
32
+ >>> # Basic usage with default imports
33
+ >>> executor = PyExecutor()
34
+ >>> result = executor.run("print('Hello, World!')")
35
+ >>> print(result.stdout) # "Hello, World!\n"
36
+ >>> print(result.exit_code) # 0
37
+ >>>
38
+ >>> # Usage with additional imports
39
+ >>> executor = PyExecutor(additional_imports=["numpy", "pandas"])
40
+ >>> result = executor.run("import numpy as np\\nprint(np.array([1, 2, 3]))")
41
+ >>> print(result.stdout) # "[1 2 3]\n"
42
+ """
43
+
44
+ def __init__(self, additional_imports: list[str] | None = None):
45
+ """
46
+ Initialize the PyExecutor with a LocalPythonExecutor instance.
47
+
48
+ Args:
49
+ additional_imports: List of additional module names to authorize for import.
50
+ Defaults to an empty list if not provided.
51
+ """
52
+ if additional_imports is None:
53
+ additional_imports = []
54
+ self._executor = LocalPythonExecutor(
55
+ additional_authorized_imports=additional_imports
56
+ )
57
+ # Initialize tools to make BASE_PYTHON_TOOLS available (including print)
58
+ self._executor.send_tools({})
59
+
60
+ def run(self, code: str) -> CodeExecResult:
61
+ """
62
+ Execute Python code and return the result.
63
+
64
+ Args:
65
+ code: Python code string to execute
66
+
67
+ Returns:
68
+ CodeExecResult containing stdout, stderr, and exit_code
69
+
70
+ Example:
71
+ >>> executor = PyExecutor()
72
+ >>> result = executor.run("x = 5 + 3\\nprint(x)")
73
+ >>> print(result.stdout) # "8\n"
74
+ >>> print(result.exit_code) # 0
75
+ >>>
76
+ >>> # Error handling
77
+ >>> result = executor.run("1 / 0")
78
+ >>> print(result.exit_code) # 1
79
+ >>> print(result.stderr) # Contains error message
80
+ """
81
+ try:
82
+ # Execute the code using LocalPythonExecutor
83
+ # LocalPythonExecutor returns a CodeOutput object with output, logs, is_final_answer
84
+ exec_result = self._executor(code)
85
+
86
+ # Extract the logs (which contain print outputs) as stdout
87
+ # The output field contains the return value of the code
88
+ stdout = exec_result.logs
89
+ stderr = ""
90
+ exit_code = 0 # Success
91
+
92
+ return CodeExecResult(
93
+ stdout=stdout,
94
+ stderr=stderr,
95
+ exit_code=exit_code,
96
+ )
97
+
98
+ except Exception as e:
99
+ # LocalPythonExecutor raises InterpreterError for various issues
100
+ # (syntax errors, forbidden operations, runtime errors, etc.)
101
+ return CodeExecResult(
102
+ stdout="",
103
+ stderr=str(e),
104
+ exit_code=1, # Non-zero indicates error
105
+ )
src/core/types.py ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Type definitions for EnvTorch
2
+ from dataclasses import dataclass
3
+ from typing import Any, Generic, Optional, TypeVar
4
+
5
+ # Generic type for observations
6
+ ObsT = TypeVar("ObsT") # TypeVar for typehinting in IDEs
7
+
8
+
9
+ @dataclass
10
+ class StepResult(Generic[ObsT]):
11
+ """
12
+ Represents the result of one environment step.
13
+
14
+ Attributes:
15
+ observation: The environment's observation after the action.
16
+ reward: Scalar reward for this step (optional).
17
+ done: Whether the episode is finished.
18
+ """
19
+
20
+ observation: ObsT
21
+ reward: Optional[float] = None
22
+ done: bool = False
src/envs/chat_env/README.md ADDED
@@ -0,0 +1,268 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Chat Environment
2
+
3
+ A chat-based environment for LLMs with built-in tokenization and message history management. This environment is designed to work directly with language models and provides a minimal, flexible foundation for conversation-based RL training.
4
+
5
+ ## Overview
6
+
7
+ ChatEnvironment is a lightweight environment that:
8
+ - Manages conversation history in Huggingface chat format
9
+ - Handles tokenization internally using any compatible tokenizer
10
+ - Stores both messages and tokens for efficient model interaction
11
+ - Provides a clean interface for building chat-based RL agents
12
+
13
+ ChatEnvironment can be used in **two ways**:
14
+ 1. **Direct usage**: Import and use ChatEnvironment directly in your Python code (best for local development)
15
+ 2. **HTTP client**: Use ChatEnv client to connect to a ChatEnvironment server (best for distributed/containerized deployments)
16
+
17
+ ## Quick Start
18
+
19
+ ### Option 1: Direct Usage (Local)
20
+
21
+ ```python
22
+ from transformers import AutoTokenizer
23
+ from envs.chat_env import ChatAction, ChatObservation
24
+ from envs.chat_env.server import ChatEnvironment
25
+ from core.env_server import Message
26
+
27
+ # Initialize with a tokenizer and optional system prompt
28
+ tokenizer = AutoTokenizer.from_pretrained("gpt2")
29
+ env = ChatEnvironment(
30
+ tokenizer=tokenizer,
31
+ system_prompt="You are a helpful assistant.",
32
+ system_role="system"
33
+ )
34
+
35
+ # Reset the environment
36
+ obs = env.reset()
37
+ print(f"Messages: {obs.messages}")
38
+ print(f"Tokens shape: {obs.tokens.shape}")
39
+
40
+ # Create an action from a message
41
+ user_message: Message = {"role": "user", "content": "Hello!"}
42
+ action = env.message_to_action(user_message)
43
+
44
+ # Step the environment
45
+ obs = env.step(action)
46
+ print(f"Updated messages: {obs.messages}")
47
+ print(f"Updated tokens shape: {obs.tokens.shape}")
48
+ ```
49
+
50
+ ### Option 2: HTTP Client (Distributed)
51
+
52
+ ```python
53
+ from transformers import AutoTokenizer
54
+ from envs.chat_env import ChatEnv, ChatAction
55
+ import torch
56
+
57
+ # Create environment from Docker image
58
+ client = ChatEnv.from_docker_image("chat-env:latest")
59
+
60
+ # Or connect to existing server
61
+ # client = ChatEnv(base_url="http://localhost:8000")
62
+
63
+ # Reset
64
+ result = client.reset()
65
+ print(f"Initial messages: {result.observation.messages}")
66
+
67
+ # Send an action with tokens
68
+ tokenizer = AutoTokenizer.from_pretrained("gpt2")
69
+ message = {"role": "user", "content": "Hello!"}
70
+ action = client.message_to_action(message, tokenizer)
71
+
72
+ result = client.step(action)
73
+ print(f"Messages: {result.observation.messages}")
74
+ print(f"Reward: {result.reward}")
75
+
76
+ # Cleanup
77
+ client.close()
78
+ ```
79
+
80
+ ### Building the Docker Image
81
+
82
+ Before using the HTTP client, build the Docker image:
83
+
84
+ ```bash
85
+ # From project root
86
+ docker build -t chat-env:latest -f src/envs/chat_env/server/Dockerfile .
87
+
88
+ # Optionally specify a different tokenizer
89
+ docker build -t chat-env:latest \
90
+ --build-arg TOKENIZER_NAME=meta-llama/Llama-2-7b-chat-hf \
91
+ -f src/envs/chat_env/server/Dockerfile .
92
+ ```
93
+
94
+ ## Architecture
95
+
96
+ ### Data Models
97
+
98
+ #### ChatAction
99
+ Actions contain only tokens (PyTorch tensors) that interface directly with models:
100
+ ```python
101
+ @dataclass
102
+ class ChatAction(Action):
103
+ tokens: torch.Tensor # Required, cannot be empty
104
+ ```
105
+
106
+ #### ChatObservation
107
+ Observations contain both the message history and flattened tokens:
108
+ ```python
109
+ @dataclass
110
+ class ChatObservation(Observation):
111
+ messages: list[Message] # List of {"role": str, "content": str}
112
+ tokens: torch.Tensor # Flattened tensor of all conversation tokens
113
+ # Inherited: done, reward, metadata
114
+ ```
115
+
116
+ #### ChatState
117
+ Internal state tracking message and token history:
118
+ ```python
119
+ @dataclass
120
+ class ChatState(State):
121
+ history_messages: list[Message]
122
+ history_tokens: list[torch.Tensor]
123
+ # Inherited: episode_id, step_count
124
+ ```
125
+
126
+ ### Key Methods
127
+
128
+ #### `reset() -> ChatObservation`
129
+ Resets the environment to initial state with optional system prompt.
130
+
131
+ #### `step(action: ChatAction) -> ChatObservation`
132
+ Takes an action (tokens), decodes to text, adds to history, returns updated observation.
133
+
134
+ #### `message_to_action(message: Message) -> ChatAction`
135
+ Convenience method to convert a message dict to a tokenized ChatAction.
136
+
137
+ ## Usage Patterns
138
+
139
+ ### Basic Conversation
140
+
141
+ ```python
142
+ from transformers import AutoTokenizer
143
+ from envs.chat_env.server import ChatEnvironment
144
+ from core.env_server import Message
145
+
146
+ tokenizer = AutoTokenizer.from_pretrained("gpt2")
147
+ env = ChatEnvironment(tokenizer=tokenizer)
148
+
149
+ # Reset
150
+ obs = env.reset()
151
+
152
+ # User turn
153
+ user_msg: Message = {"role": "user", "content": "What is 2+2?"}
154
+ action = env.message_to_action(user_msg)
155
+ obs = env.step(action)
156
+
157
+ # Assistant turn
158
+ assistant_msg: Message = {"role": "assistant", "content": "2+2 equals 4."}
159
+ action = env.message_to_action(assistant_msg)
160
+ obs = env.step(action)
161
+
162
+ # Access conversation history
163
+ print(f"Full conversation: {obs.messages}")
164
+ print(f"All tokens: {obs.tokens}")
165
+ ```
166
+
167
+ ### With Transforms
168
+
169
+ You can add transforms to compute rewards or modify observations:
170
+
171
+ ```python
172
+ from core.env_server import Transform, Observation
173
+
174
+ class LengthRewardTransform(Transform):
175
+ """Reward based on response length."""
176
+
177
+ def __call__(self, observation: Observation) -> Observation:
178
+ if hasattr(observation, 'messages') and observation.messages:
179
+ last_message = observation.messages[-1]
180
+ observation.reward = len(last_message['content']) * 0.1
181
+ return observation
182
+
183
+ env = ChatEnvironment(
184
+ tokenizer=tokenizer,
185
+ transform=LengthRewardTransform()
186
+ )
187
+ ```
188
+
189
+ ### Direct Token Usage
190
+
191
+ If you're generating tokens from a model, you can create actions directly:
192
+
193
+ ```python
194
+ import torch
195
+ from envs.chat_env import ChatAction
196
+
197
+ # Assume you have tokens from your model
198
+ generated_tokens = torch.tensor([[1, 2, 3, 4, 5]])
199
+
200
+ # Create action directly
201
+ action = ChatAction(tokens=generated_tokens)
202
+
203
+ # Step environment
204
+ obs = env.step(action)
205
+ ```
206
+
207
+ ## Design Philosophy
208
+
209
+ ChatEnvironment is intentionally minimal and flexible:
210
+
211
+ 1. **No HTTP overhead**: Works directly with Python objects and tensors
212
+ 2. **Tokenizer ownership**: Environment handles tokenization consistently
213
+ 3. **Dual representation**: Maintains both human-readable messages and model-ready tokens
214
+ 4. **Transform support**: Extensible reward computation and observation modification
215
+ 5. **Type-safe**: Uses typed Messages compatible with Huggingface format
216
+
217
+ ## Integration with Models
218
+
219
+ ChatEnvironment pairs naturally with language models:
220
+
221
+ ```python
222
+ # Pseudo-code for RL training loop
223
+ model = YourLanguageModel()
224
+ env = ChatEnvironment(tokenizer=model.tokenizer)
225
+
226
+ for episode in range(num_episodes):
227
+ obs = env.reset()
228
+
229
+ while not obs.done:
230
+ # Model generates response tokens
231
+ action_tokens = model.generate(obs.tokens)
232
+ action = ChatAction(tokens=action_tokens)
233
+
234
+ # Step environment
235
+ obs = env.step(action)
236
+
237
+ # Use obs.reward for RL updates
238
+ model.update(obs.reward)
239
+ ```
240
+
241
+ ## Project Structure
242
+
243
+ ```
244
+ chat_env/
245
+ β”œβ”€β”€ __init__.py # Module exports (ChatEnv, ChatAction, etc.)
246
+ β”œβ”€β”€ README.md # This file
247
+ β”œβ”€β”€ client.py # ChatEnv HTTP client
248
+ β”œβ”€β”€ models.py # ChatAction, ChatObservation, ChatState
249
+ └── server/
250
+ β”œβ”€β”€ __init__.py # Server module exports
251
+ β”œβ”€β”€ chat_environment.py # Core ChatEnvironment implementation
252
+ β”œβ”€β”€ app.py # FastAPI server application
253
+ β”œβ”€β”€ test_chat_env.py # Unit tests
254
+ └── Dockerfile # Container image for HTTP server
255
+ ```
256
+
257
+ ## Requirements
258
+
259
+ - Python 3.10+
260
+ - PyTorch
261
+ - A tokenizer with `apply_chat_template` method (e.g., Huggingface transformers)
262
+
263
+ ## Notes
264
+
265
+ - ChatEnvironment does **not** generate responses - it only manages conversation state
266
+ - You need to provide tokens from your model or other source
267
+ - The environment is thread-safe for single-threaded use only
268
+ - For multi-turn conversations, alternate between user and assistant messages
src/envs/chat_env/__init__.py ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ """Chat Environment - A chat-based environment for LLMs with tokenization support."""
8
+
9
+ from .client import ChatEnv
10
+ from .models import ChatAction, ChatObservation, ChatState
11
+
12
+ __all__ = ["ChatAction", "ChatObservation", "ChatState", "ChatEnv"]
src/envs/chat_env/client.py ADDED
@@ -0,0 +1,182 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ """
8
+ Chat Environment HTTP Client.
9
+
10
+ This module provides the client for connecting to a Chat Environment server
11
+ over HTTP.
12
+ """
13
+
14
+ from typing import Any, Dict
15
+
16
+ import torch
17
+
18
+ from core.env_server.interfaces import Message
19
+ from core.env_server.types import State
20
+ from core.http_env_client import HTTPEnvClient
21
+ from core.types import StepResult
22
+
23
+ from .models import ChatAction, ChatObservation, ChatState
24
+
25
+
26
+ class ChatEnv(HTTPEnvClient[ChatAction, ChatObservation]):
27
+ """
28
+ HTTP client for the Chat Environment.
29
+
30
+ This client connects to a ChatEnvironment HTTP server and provides
31
+ methods to interact with it: reset(), step(), and state access.
32
+
33
+ Note: Since ChatEnvironment works with PyTorch tensors, the HTTP layer
34
+ serializes tokens as lists for transport and deserializes them back to tensors.
35
+
36
+ Example:
37
+ >>> # Connect to a running server
38
+ >>> client = ChatEnv(base_url="http://localhost:8000")
39
+ >>> result = client.reset()
40
+ >>> print(result.observation.messages)
41
+ >>>
42
+ >>> # Send an action with tokens
43
+ >>> import torch
44
+ >>> tokens = torch.tensor([[1, 2, 3, 4, 5]])
45
+ >>> result = client.step(ChatAction(tokens=tokens))
46
+ >>> print(result.observation.messages)
47
+ >>> print(result.reward)
48
+
49
+ Example with Docker:
50
+ >>> # Automatically start container and connect
51
+ >>> client = ChatEnv.from_docker_image("chat-env:latest")
52
+ >>> result = client.reset()
53
+ >>> result = client.step(ChatAction(tokens=torch.tensor([[1, 2, 3]])))
54
+ """
55
+
56
+ def _step_payload(self, action: ChatAction) -> Dict:
57
+ """
58
+ Convert ChatAction to JSON payload for step request.
59
+
60
+ Since PyTorch tensors can't be directly serialized to JSON,
61
+ we convert them to nested lists.
62
+
63
+ Args:
64
+ action: ChatAction instance with tokens
65
+
66
+ Returns:
67
+ Dictionary representation suitable for JSON encoding
68
+ """
69
+ # Convert tensor to list for JSON serialization
70
+ if isinstance(action.tokens, torch.Tensor):
71
+ tokens_list = action.tokens.tolist()
72
+ else:
73
+ tokens_list = action.tokens
74
+
75
+ return {
76
+ "tokens": tokens_list,
77
+ "metadata": action.metadata,
78
+ }
79
+
80
+ def _parse_result(self, payload: Dict) -> StepResult[ChatObservation]:
81
+ """
82
+ Parse server response into StepResult[ChatObservation].
83
+
84
+ Args:
85
+ payload: JSON response from server
86
+
87
+ Returns:
88
+ StepResult with ChatObservation
89
+ """
90
+ obs_data = payload.get("observation", {})
91
+
92
+ # Convert tokens list back to tensor
93
+ tokens_data = obs_data.get("tokens", [])
94
+ if isinstance(tokens_data, list):
95
+ if tokens_data:
96
+ tokens = torch.tensor(tokens_data)
97
+ else:
98
+ tokens = torch.tensor([])
99
+ else:
100
+ tokens = torch.tensor([])
101
+
102
+ # Parse messages
103
+ messages = obs_data.get("messages", [])
104
+
105
+ observation = ChatObservation(
106
+ messages=messages,
107
+ tokens=tokens,
108
+ done=payload.get("done", False),
109
+ reward=payload.get("reward"),
110
+ metadata=obs_data.get("metadata", {}),
111
+ )
112
+
113
+ return StepResult(
114
+ observation=observation,
115
+ reward=payload.get("reward"),
116
+ done=payload.get("done", False),
117
+ )
118
+
119
+ def _parse_state(self, payload: Dict) -> ChatState:
120
+ """
121
+ Parse server response into ChatState object.
122
+
123
+ Args:
124
+ payload: JSON response from /state endpoint
125
+
126
+ Returns:
127
+ ChatState object with conversation history
128
+ """
129
+ # Parse history messages
130
+ history_messages = payload.get("history_messages", [])
131
+
132
+ # Parse history tokens - convert lists back to tensors
133
+ history_tokens_data = payload.get("history_tokens", [])
134
+ history_tokens = []
135
+ for token_list in history_tokens_data:
136
+ if token_list:
137
+ history_tokens.append(torch.tensor(token_list))
138
+ else:
139
+ history_tokens.append(torch.tensor([]))
140
+
141
+ return ChatState(
142
+ episode_id=payload.get("episode_id"),
143
+ step_count=payload.get("step_count", 0),
144
+ history_messages=history_messages,
145
+ history_tokens=history_tokens,
146
+ )
147
+
148
+ def message_to_action(self, message: Message, tokenizer: Any) -> ChatAction:
149
+ """
150
+ Helper method to convert a message to a ChatAction using a tokenizer.
151
+
152
+ This is a client-side convenience method for users who have a tokenizer
153
+ and want to create actions from messages.
154
+
155
+ Args:
156
+ message: Message dict with 'role' and 'content'
157
+ tokenizer: Tokenizer with apply_chat_template method
158
+
159
+ Returns:
160
+ ChatAction with tokenized message
161
+
162
+ Example:
163
+ >>> from transformers import AutoTokenizer
164
+ >>> tokenizer = AutoTokenizer.from_pretrained("gpt2")
165
+ >>> client = ChatEnv(base_url="http://localhost:8000")
166
+ >>> message = {"role": "user", "content": "Hello!"}
167
+ >>> action = client.message_to_action(message, tokenizer)
168
+ >>> result = client.step(action)
169
+ """
170
+ if "role" not in message:
171
+ raise ValueError("Message must contain a 'role' key")
172
+ if "content" not in message:
173
+ raise ValueError("Message must contain a 'content' key")
174
+ if message["content"] is None:
175
+ raise ValueError("Message content cannot be None")
176
+
177
+ # Tokenize the message
178
+ tokens = tokenizer.apply_chat_template(
179
+ conversation=[message], tokenize=True, return_tensors="pt"
180
+ )
181
+
182
+ return ChatAction(tokens=tokens)
src/envs/chat_env/models.py ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ """
8
+ Data models for the Chat Environment.
9
+
10
+ The Chat environment provides a chat-based interface for LLMs with support
11
+ for tokenization and message history management.
12
+ """
13
+
14
+ from dataclasses import dataclass, field
15
+
16
+ import torch
17
+
18
+ from core.env_server.interfaces import Message
19
+ from core.env_server.types import Action, Observation, State
20
+
21
+
22
+ @dataclass
23
+ class ChatAction(Action):
24
+ """Action for chat environments.
25
+
26
+ Contains tokens that represent the action to be taken.
27
+ This interfaces directly with models.
28
+ """
29
+
30
+ tokens: torch.Tensor = field(default_factory=lambda: torch.tensor([]))
31
+
32
+ def __post_init__(self):
33
+ """Validate required fields after initialization."""
34
+ if self.tokens.numel() == 0:
35
+ raise ValueError("tokens is required and cannot be empty")
36
+
37
+
38
+ @dataclass
39
+ class ChatState(State):
40
+ """State of the ChatEnvironment containing message history."""
41
+
42
+ history_messages: list[Message] = field(default_factory=list)
43
+ history_tokens: list[torch.Tensor] = field(
44
+ default_factory=list
45
+ ) # Same len as messages
46
+
47
+
48
+ @dataclass(kw_only=True)
49
+ class ChatObservation(Observation):
50
+ """Observation returned by ChatEnvironment.
51
+
52
+ Contains the message history in Huggingface format (list of dicts with role/content)
53
+ and the tokenized representation of the entire conversation.
54
+
55
+ The environment owns the tokenizer and generates the tokens from the messages.
56
+
57
+ Example:
58
+ messages = [
59
+ {"role": "system", "content": "You are a helpful assistant"},
60
+ {"role": "user", "content": "How tall is the Eiffel Tower?"},
61
+ ]
62
+ tokens = tensor([1, 2, 3, 4, 5, ...]) # tokenized entire conversation
63
+ """
64
+
65
+ messages: list[Message] = field(default_factory=list)
66
+ tokens: torch.Tensor = field(default_factory=lambda: torch.tensor([]))
67
+ # Inherited fields from Observation ABC: reward, done, metadata
src/envs/chat_env/server/Dockerfile ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ # Use the standard openenv base image
8
+ # Built from: docker build -t openenv-base:latest -f src/core/containers/images/Dockerfile .
9
+ # In GitHub Actions, this is overridden to use the GHCR base image
10
+ ARG BASE_IMAGE=openenv-base:latest
11
+ FROM ${BASE_IMAGE}
12
+
13
+ # Install additional dependencies for ChatEnvironment
14
+ RUN pip install torch transformers
15
+
16
+ # Copy only what's needed for this environment
17
+ COPY src/core/ /app/src/core/
18
+ COPY src/envs/chat_env/ /app/src/envs/chat_env/
19
+
20
+ # Environment variables that can be overridden at runtime
21
+ ENV TOKENIZER_NAME=gpt2
22
+ ENV SYSTEM_PROMPT="You are a helpful AI assistant."
23
+
24
+ # Health check
25
+ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
26
+ CMD curl -f http://localhost:8000/health || exit 1
27
+
28
+ # Run the FastAPI server
29
+ CMD ["uvicorn", "envs.chat_env.server.app:app", "--host", "0.0.0.0", "--port", "8000"]
src/envs/chat_env/server/__init__.py ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ """Chat environment server components."""
8
+
9
+ from .chat_environment import ChatEnvironment
10
+
11
+ __all__ = ["ChatEnvironment"]
src/envs/chat_env/server/app.py ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ """
8
+ FastAPI application for the Chat Environment.
9
+
10
+ This module creates an HTTP server that exposes the ChatEnvironment
11
+ over HTTP endpoints, making it compatible with HTTPEnvClient.
12
+
13
+ Note: This server requires a tokenizer to be initialized. The tokenizer
14
+ must be specified when starting the server.
15
+
16
+ Usage:
17
+ # Development (with auto-reload):
18
+ uvicorn envs.chat_env.server.app:app --reload --host 0.0.0.0 --port 8000
19
+
20
+ # Production:
21
+ uvicorn envs.chat_env.server.app:app --host 0.0.0.0 --port 8000 --workers 4
22
+
23
+ # Or run directly:
24
+ python -m envs.chat_env.server.app
25
+ """
26
+
27
+ import os
28
+
29
+ from core.env_server import create_app
30
+
31
+ from ..models import ChatAction, ChatObservation
32
+ from .chat_environment import ChatEnvironment
33
+
34
+
35
+ # Initialize tokenizer based on environment variable
36
+ def get_tokenizer():
37
+ """Get tokenizer from environment or use a mock for testing."""
38
+ tokenizer_name = os.environ.get("TOKENIZER_NAME", "gpt2")
39
+
40
+ try:
41
+ from transformers import AutoTokenizer
42
+
43
+ tokenizer = AutoTokenizer.from_pretrained(tokenizer_name)
44
+ print(f"Loaded tokenizer: {tokenizer_name}")
45
+ return tokenizer
46
+ except ImportError:
47
+ print(
48
+ "Warning: transformers not installed, using mock tokenizer for testing only"
49
+ )
50
+ # Use mock tokenizer from tests
51
+ import sys
52
+ from pathlib import Path
53
+
54
+ # Add parent directory to path to import test utilities
55
+ test_path = Path(__file__).parent
56
+ sys.path.insert(0, str(test_path))
57
+
58
+ from test_chat_env import MockTokenizer
59
+
60
+ return MockTokenizer()
61
+
62
+
63
+ # Get system prompt from environment
64
+ system_prompt = os.environ.get("SYSTEM_PROMPT", None)
65
+
66
+ # Create the environment instance with tokenizer
67
+ tokenizer = get_tokenizer()
68
+ env = ChatEnvironment(tokenizer=tokenizer, system_prompt=system_prompt)
69
+
70
+ # Create the FastAPI app with routes
71
+ app = create_app(env, ChatAction, ChatObservation)
72
+
73
+
74
+ if __name__ == "__main__":
75
+ import uvicorn
76
+
77
+ uvicorn.run(app, host="0.0.0.0", port=8000)
src/envs/chat_env/server/chat_environment.py ADDED
@@ -0,0 +1,172 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ """
8
+ Chat Environment Implementation.
9
+
10
+ A chat-based environment for LLMs, designed as a blank canvas for conversation and RL.
11
+ """
12
+
13
+ import torch
14
+
15
+ from core.env_server.interfaces import Environment, Message, ModelTokenizer, Transform
16
+
17
+ from ..models import ChatAction, ChatObservation, ChatState
18
+
19
+
20
+ class ChatEnvironment(Environment):
21
+ """A chat-based environment for LLMs, designed as a blank canvas for conversation and RL.
22
+
23
+ This environment is designed to work with language models. It provides the fundamental structure
24
+ for managing conversation state but is intentionally minimal to allow maximum flexibility.
25
+
26
+ The environment owns the tokenizer and is responsible for managing both message history and tokens.
27
+ Actions contain only tokens that interface directly with models.
28
+
29
+ Args:
30
+ tokenizer: A tokenizer that will be used to tokenize the conversation
31
+ system_prompt: An optional system prompt string to use during reset calls (optional)
32
+ system_role: The role of the system (at reset time). Defaults to "system"
33
+ transform: Optional transform to apply to observations
34
+ """
35
+
36
+ def __init__(
37
+ self,
38
+ tokenizer: ModelTokenizer,
39
+ system_prompt: str | None = None,
40
+ system_role: str = "system",
41
+ transform: Transform | None = None,
42
+ ):
43
+ super().__init__(transform=transform)
44
+
45
+ if not hasattr(tokenizer, "apply_chat_template"):
46
+ raise ValueError("Tokenizer must have 'apply_chat_template' method")
47
+ self.tokenizer = tokenizer
48
+ self.system_prompt = system_prompt
49
+ self.system_role = system_role
50
+
51
+ self._state = ChatState()
52
+
53
+ if system_prompt:
54
+ system_message: Message = {"role": system_role, "content": system_prompt}
55
+ self._state.history_messages.append(system_message)
56
+ # Tokenize the system message
57
+ system_tokens = self.tokenizer.apply_chat_template(
58
+ conversation=[system_message], tokenize=True, return_tensors="pt" # type: ignore
59
+ )
60
+ self._state.history_tokens.append(system_tokens)
61
+
62
+ def reset(self) -> ChatObservation:
63
+ """Reset the environment to initial state.
64
+
65
+ Returns:
66
+ ChatObservation: Initial observation with system prompt (if any)
67
+ """
68
+ self._state.history_messages = []
69
+ self._state.history_tokens = []
70
+ if self.system_prompt:
71
+ system_message: Message = {
72
+ "role": self.system_role,
73
+ "content": self.system_prompt,
74
+ }
75
+ self._state.history_messages = [system_message]
76
+ # Tokenize the system message
77
+ system_tokens = self.tokenizer.apply_chat_template(
78
+ conversation=[system_message], tokenize=True, return_tensors="pt" # type: ignore
79
+ )
80
+ self._state.history_tokens = [system_tokens]
81
+
82
+ return self._create_observation()
83
+
84
+ def step(self, action: ChatAction) -> ChatObservation: # type: ignore[override]
85
+ """Take a step in the environment by adding tokens to the chat history.
86
+
87
+ Args:
88
+ action: A ChatAction object containing tokens.
89
+
90
+ Returns:
91
+ ChatObservation: The updated observation with the new tokens added.
92
+ """
93
+ # Store the tokens directly from the action
94
+ self._state.history_tokens.append(action.tokens)
95
+
96
+ # Decode tokens to text and add as a message to history
97
+ decoded_text = self.tokenizer.decode(
98
+ action.tokens.squeeze(), skip_special_tokens=True
99
+ )
100
+ assistant_message: Message = {"role": "assistant", "content": decoded_text}
101
+ self._state.history_messages.append(assistant_message)
102
+
103
+ return self._create_observation()
104
+
105
+ def _create_observation(self) -> ChatObservation:
106
+ """Create a ChatObservation from the current state.
107
+
108
+ Returns both the message history and the tokens flattened as a single tensor
109
+ ready to be used by models.
110
+
111
+ Returns:
112
+ ChatObservation: Observation with messages and flattened tokens
113
+ """
114
+ if self._state.history_tokens:
115
+ # Flatten all tokens into a single 1D tensor
116
+ flattened_tokens = torch.cat(
117
+ (t.flatten() for t in self._state.history_tokens), dim=0
118
+ )
119
+ else:
120
+ flattened_tokens = torch.tensor([])
121
+
122
+ observation = ChatObservation(
123
+ messages=self._state.history_messages.copy(), # Copy to prevent external mutation
124
+ tokens=flattened_tokens,
125
+ )
126
+
127
+ transformed = self._apply_transform(observation)
128
+ if isinstance(transformed, ChatObservation):
129
+ return transformed
130
+ else:
131
+ # If transform returns base Observation, convert back to ChatObservation
132
+ return ChatObservation(
133
+ messages=getattr(transformed, "messages", []),
134
+ tokens=getattr(transformed, "tokens", torch.tensor([])),
135
+ done=transformed.done,
136
+ reward=transformed.reward,
137
+ )
138
+
139
+ @property
140
+ def state(self) -> ChatState:
141
+ """Get the current state of the environment.
142
+
143
+ Returns:
144
+ ChatState: The current state.
145
+ """
146
+ return self._state
147
+
148
+ def message_to_action(self, message: Message) -> ChatAction:
149
+ """Convert a message dictionary to a ChatAction with tokens.
150
+
151
+ Args:
152
+ message: Dictionary with 'role' and 'content' keys
153
+
154
+ Returns:
155
+ ChatAction: A new ChatAction instance with tokenized content
156
+
157
+ Raises:
158
+ ValueError: If required keys are missing
159
+ """
160
+ if "role" not in message:
161
+ raise ValueError("Message must contain a 'role' key")
162
+ if "content" not in message:
163
+ raise ValueError("Message must contain a 'content' key")
164
+ if message["content"] is None:
165
+ raise ValueError("Message content cannot be None")
166
+
167
+ # Tokenize the single message
168
+ tokens = self.tokenizer.apply_chat_template(
169
+ conversation=[message], tokenize=True, return_tensors="pt" # type: ignore
170
+ )
171
+
172
+ return ChatAction(tokens=tokens)
src/envs/chat_env/server/test_chat_env.py ADDED
@@ -0,0 +1,328 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ """
8
+ Test suite for ChatEnvironment.
9
+
10
+ Proper unit tests with assertions to verify correct behavior.
11
+ """
12
+
13
+ import torch
14
+
15
+ from core.env_server.interfaces import Message
16
+
17
+ from ..models import ChatAction
18
+ from .chat_environment import ChatEnvironment
19
+
20
+
21
+ class MockTokenizer:
22
+ """Mock tokenizer for testing without requiring transformers library."""
23
+
24
+ def apply_chat_template(
25
+ self,
26
+ conversation: list[Message],
27
+ tokenize: bool = True,
28
+ return_tensors: str | None = None,
29
+ **kwargs,
30
+ ):
31
+ """Mock implementation that creates deterministic token tensors from text."""
32
+ # Concatenate all message content
33
+ text = " ".join([msg["content"] for msg in conversation])
34
+
35
+ # Create deterministic tokens based on text content
36
+ # Use character codes modulo 256 to get valid token IDs
37
+ tokens = [ord(c) % 256 for c in text]
38
+
39
+ if return_tensors == "pt":
40
+ return torch.tensor([tokens])
41
+ return tokens
42
+
43
+ def decode(self, token_ids, skip_special_tokens: bool = False, **kwargs) -> str:
44
+ """Mock decode that reverses the encoding process."""
45
+ if isinstance(token_ids, torch.Tensor):
46
+ token_ids = token_ids.tolist()
47
+
48
+ # Reverse the encoding: convert tokens back to characters
49
+ chars = [chr(t) for t in token_ids]
50
+ return "".join(chars)
51
+
52
+
53
+ def test_tokenization_consistency():
54
+ """Test that tokenizing the same string produces the same tokens."""
55
+ tokenizer = MockTokenizer()
56
+ env = ChatEnvironment(tokenizer=tokenizer)
57
+
58
+ # Create the same message twice
59
+ message1: Message = {"role": "user", "content": "Hello, world!"}
60
+ message2: Message = {"role": "user", "content": "Hello, world!"}
61
+
62
+ # Convert to actions
63
+ action1 = env.message_to_action(message1)
64
+ action2 = env.message_to_action(message2)
65
+
66
+ # Verify tokens are identical
67
+ assert torch.equal(
68
+ action1.tokens, action2.tokens
69
+ ), "Same message should produce identical tokens"
70
+
71
+ # Verify tokens are not empty
72
+ assert action1.tokens.numel() > 0, "Tokens should not be empty"
73
+
74
+ print("βœ“ test_tokenization_consistency passed")
75
+
76
+
77
+ def test_message_content_preservation():
78
+ """Test that message content is preserved in the observation."""
79
+ tokenizer = MockTokenizer()
80
+ env = ChatEnvironment(tokenizer=tokenizer)
81
+
82
+ env.reset()
83
+
84
+ # Test with user message
85
+ user_content = "What is the capital of France?"
86
+ user_message: Message = {"role": "user", "content": user_content}
87
+ action = env.message_to_action(user_message)
88
+ obs = env.step(action)
89
+
90
+ # The last message should have the decoded content
91
+ assert len(obs.messages) > 0, "Observation should have at least one message"
92
+ last_message = obs.messages[-1]
93
+
94
+ # Verify the decoded content matches what we sent
95
+ # Note: The environment decodes the tokens, so we verify the round-trip
96
+ decoded_content = last_message["content"]
97
+ assert decoded_content == user_content, (
98
+ f"Message content should be preserved. "
99
+ f"Expected: {user_content}, Got: {decoded_content}"
100
+ )
101
+
102
+ # Test with assistant message
103
+ assistant_content = "The capital of France is Paris."
104
+ assistant_message: Message = {"role": "assistant", "content": assistant_content}
105
+ action = env.message_to_action(assistant_message)
106
+ obs = env.step(action)
107
+
108
+ # Verify the last message has the assistant content
109
+ assert len(obs.messages) >= 2, "Should have at least 2 messages now"
110
+ last_message = obs.messages[-1]
111
+ decoded_content = last_message["content"]
112
+ assert decoded_content == assistant_content, (
113
+ f"Assistant message content should be preserved. "
114
+ f"Expected: {assistant_content}, Got: {decoded_content}"
115
+ )
116
+
117
+ print("βœ“ test_message_content_preservation passed")
118
+
119
+
120
+ def test_system_prompt_preserved():
121
+ """Test that system prompt is preserved after reset."""
122
+ tokenizer = MockTokenizer()
123
+ system_prompt = "You are a helpful assistant."
124
+
125
+ env = ChatEnvironment(tokenizer=tokenizer, system_prompt=system_prompt)
126
+
127
+ # Check after initialization
128
+ obs = env.reset()
129
+ assert len(obs.messages) == 1, "Should have exactly one message (system prompt)"
130
+ assert obs.messages[0]["role"] == "system", "First message should have system role"
131
+ assert (
132
+ obs.messages[0]["content"] == system_prompt
133
+ ), "System prompt content should match"
134
+
135
+ # Add some messages
136
+ action = env.message_to_action({"role": "user", "content": "Hello"})
137
+ env.step(action)
138
+
139
+ # Reset and verify system prompt is still there
140
+ obs = env.reset()
141
+ assert len(obs.messages) == 1, "After reset, should only have system prompt"
142
+ assert (
143
+ obs.messages[0]["content"] == system_prompt
144
+ ), "System prompt should be preserved after reset"
145
+
146
+ print("βœ“ test_system_prompt_preserved passed")
147
+
148
+
149
+ def test_token_history_accumulation():
150
+ """Test that tokens accumulate correctly in the observation."""
151
+ tokenizer = MockTokenizer()
152
+ env = ChatEnvironment(tokenizer=tokenizer)
153
+
154
+ obs = env.reset()
155
+ initial_token_count = obs.tokens.numel()
156
+
157
+ # Step with first message
158
+ message1 = {"role": "user", "content": "Hi"}
159
+ action1 = env.message_to_action(message1)
160
+ obs1 = env.step(action1)
161
+ token_count_1 = obs1.tokens.numel()
162
+
163
+ # Tokens should increase
164
+ assert token_count_1 > initial_token_count, "Token count should increase after step"
165
+
166
+ # Step with second message
167
+ message2 = {"role": "assistant", "content": "Hello there"}
168
+ action2 = env.message_to_action(message2)
169
+ obs2 = env.step(action2)
170
+ token_count_2 = obs2.tokens.numel()
171
+
172
+ # Tokens should continue to accumulate
173
+ assert (
174
+ token_count_2 > token_count_1
175
+ ), "Token count should keep increasing with more messages"
176
+
177
+ # Verify tokens are the concatenation of both messages
178
+ expected_tokens = torch.cat([action1.tokens.flatten(), action2.tokens.flatten()])
179
+ assert torch.equal(
180
+ obs2.tokens, expected_tokens
181
+ ), "Tokens should be concatenation of all actions"
182
+
183
+ print("βœ“ test_token_history_accumulation passed")
184
+
185
+
186
+ def test_direct_token_action():
187
+ """Test creating actions directly from tokens."""
188
+ tokenizer = MockTokenizer()
189
+ env = ChatEnvironment(tokenizer=tokenizer)
190
+
191
+ env.reset()
192
+
193
+ # Create raw tokens
194
+ raw_tokens = torch.tensor([[72, 101, 108, 108, 111]]) # ASCII for "Hello"
195
+ action = ChatAction(tokens=raw_tokens)
196
+
197
+ # Step with raw tokens
198
+ obs = env.step(action)
199
+
200
+ # Verify message was added
201
+ assert len(obs.messages) == 1, "Should have one message"
202
+ assert obs.messages[0]["role"] == "assistant", "Should default to assistant role"
203
+
204
+ # Verify tokens match what we sent (flattened)
205
+ assert torch.equal(
206
+ obs.tokens, raw_tokens.flatten()
207
+ ), "Observation tokens should match input tokens"
208
+
209
+ print("βœ“ test_direct_token_action passed")
210
+
211
+
212
+ def test_empty_tokens_validation():
213
+ """Test that empty tokens raise a ValueError."""
214
+ try:
215
+ action = ChatAction(tokens=torch.tensor([]))
216
+ assert False, "Should have raised ValueError for empty tokens"
217
+ except ValueError as e:
218
+ assert "empty" in str(e).lower(), "Error message should mention empty tokens"
219
+
220
+ print("βœ“ test_empty_tokens_validation passed")
221
+
222
+
223
+ def test_message_validation():
224
+ """Test that invalid messages raise appropriate errors."""
225
+ tokenizer = MockTokenizer()
226
+ env = ChatEnvironment(tokenizer=tokenizer)
227
+
228
+ # Test missing 'role' key
229
+ try:
230
+ env.message_to_action({"content": "test"}) # type: ignore
231
+ assert False, "Should have raised error for missing 'role' key"
232
+ except (ValueError, KeyError):
233
+ pass
234
+
235
+ # Test missing 'content' key
236
+ try:
237
+ env.message_to_action({"role": "user"}) # type: ignore
238
+ assert False, "Should have raised error for missing 'content' key"
239
+ except (ValueError, KeyError):
240
+ pass
241
+
242
+ # Test None content
243
+ try:
244
+ env.message_to_action({"role": "user", "content": None}) # type: ignore
245
+ assert False, "Should have raised error for None content"
246
+ except ValueError:
247
+ pass
248
+
249
+ print("βœ“ test_message_validation passed")
250
+
251
+
252
+ def test_reset_clears_history():
253
+ """Test that reset properly clears all message and token history."""
254
+ tokenizer = MockTokenizer()
255
+ env = ChatEnvironment(tokenizer=tokenizer, system_prompt="System message")
256
+
257
+ # Add some messages
258
+ obs1 = env.reset()
259
+ initial_messages = len(obs1.messages)
260
+
261
+ action = env.message_to_action({"role": "user", "content": "Test message"})
262
+ obs2 = env.step(action)
263
+
264
+ # Verify message was added
265
+ assert (
266
+ len(obs2.messages) > initial_messages
267
+ ), "Message should be added after step"
268
+
269
+ # Reset
270
+ obs3 = env.reset()
271
+
272
+ # Verify we're back to just the system prompt
273
+ assert (
274
+ len(obs3.messages) == initial_messages
275
+ ), "Reset should clear history back to initial state"
276
+ assert (
277
+ obs3.messages[0]["content"] == "System message"
278
+ ), "System prompt should be preserved"
279
+
280
+ print("βœ“ test_reset_clears_history passed")
281
+
282
+
283
+ def main():
284
+ """Run all tests."""
285
+ print("\n" + "=" * 60)
286
+ print("ChatEnvironment Test Suite")
287
+ print("=" * 60 + "\n")
288
+
289
+ tests = [
290
+ test_tokenization_consistency,
291
+ test_message_content_preservation,
292
+ test_system_prompt_preserved,
293
+ test_token_history_accumulation,
294
+ test_direct_token_action,
295
+ test_empty_tokens_validation,
296
+ test_message_validation,
297
+ test_reset_clears_history,
298
+ ]
299
+
300
+ failed = []
301
+ for test in tests:
302
+ try:
303
+ test()
304
+ except AssertionError as e:
305
+ print(f"βœ— {test.__name__} failed: {e}")
306
+ failed.append(test.__name__)
307
+ except Exception as e:
308
+ print(f"βœ— {test.__name__} errored: {e}")
309
+ import traceback
310
+
311
+ traceback.print_exc()
312
+ failed.append(test.__name__)
313
+
314
+ print("\n" + "=" * 60)
315
+ if not failed:
316
+ print(f"βœ“ All {len(tests)} tests passed!")
317
+ print("=" * 60)
318
+ return 0
319
+ else:
320
+ print(f"βœ— {len(failed)}/{len(tests)} tests failed:")
321
+ for name in failed:
322
+ print(f" - {name}")
323
+ print("=" * 60)
324
+ return 1
325
+
326
+
327
+ if __name__ == "__main__":
328
+ exit(main())