burtenshaw HF Staff commited on
Commit
be32845
·
verified ·
1 Parent(s): 1c99cba

Upload folder using huggingface_hub

Browse files
Dockerfile ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 specified openenv-base image
8
+ FROM ghcr.io/meta-pytorch/openenv-base:latest
9
+
10
+ # Copy only what's needed for this environment
11
+ COPY src/core/ /app/src/core/
12
+ COPY src/envs/julia_env/ /app/src/envs/julia_env/
13
+
14
+ # Health check
15
+ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
16
+ CMD curl -f http://localhost:8000/health || exit 1
17
+
18
+ # Run the FastAPI server
19
+ CMD ["uvicorn", "envs.julia_env.server.app:app", "--host", "0.0.0.0", "--port", "8000"]
20
+ ENV ENABLE_WEB_INTERFACE=true
README.md CHANGED
@@ -1,10 +1,39 @@
1
  ---
2
- title: Julia Env-pr-170
3
- emoji: 🐨
4
- colorFrom: red
5
- colorTo: purple
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: Julia_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
+ tags:
11
+ - openenv-pr
12
  ---
13
 
14
+ # Julia_env Environment Server
15
+
16
+ FastAPI server for julia_env environment powered by Meta's OpenEnv.
17
+
18
+ ## About
19
+
20
+ This Space provides a containerized environment for julia_env interactions.
21
+ Built with FastAPI and OpenEnv framework.
22
+
23
+ ## Web Interface
24
+
25
+ This deployment includes an interactive web interface for exploring the environment:
26
+ - **HumanAgent Interface**: Interact with the environment using a web form
27
+ - **State Observer**: Real-time view of environment state and action history
28
+ - **Live Updates**: WebSocket-based real-time updates
29
+
30
+ Access the web interface at: `/web`
31
+
32
+
33
+ ## API Documentation
34
+
35
+ Visit `/docs` for interactive API documentation.
36
+
37
+ ## Health Check
38
+
39
+ The environment provides a health check endpoint at `/health`.
src/core/README.md ADDED
@@ -0,0 +1,180 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # <img width="35" height="35" alt="image" src="https://github.com/user-attachments/assets/2700a971-e5d6-4036-b03f-2f89c9791609" /> OpenEnv: Agentic Execution Environments
2
+
3
+ An e2e framework for creating, deploying and using isolated execution environments for agentic RL training, built using Gymnasium style simple APIs. OpenEnv provides a standard for interacting with agentic execution environments via simple Gymnasium style APIs - step(), reset(), state(). Users of agentic execution environments can interact with the environment during RL training loops using these simple APIs.
4
+
5
+ In addition to making it easier for researchers and RL framework writers, we also provide tools for environment creators making it easier for them to create richer environments and make them available over familiar protocols like HTTP and packaged using canonical technologies like docker. Environment creators can use the OpenEnv framework to create environments that are isolated, secure, and easy to deploy and use.
6
+
7
+
8
+ ## Overview
9
+ `openenv-core` provides the foundational building blocks for creating and interacting with containerized environments over HTTP. It enables you to build agent environments that can be deployed as Docker containers and accessed via a simple HTTP API.
10
+
11
+ > ⚠️ **Early Development Warning** OpenEnv is currently in an experimental
12
+ > stage. You should expect bugs, incomplete features, and APIs that may change
13
+ > in future versions. The project welcomes bugfixes, but to make sure things are
14
+ > well coordinated you should discuss any significant change before starting the
15
+ > work. It's recommended that you signal your intention to contribute in the
16
+ > issue tracker, either by filing a new issue or by claiming an existing one.
17
+
18
+
19
+ # OpenEnv Core
20
+
21
+ Core components for OpenEnv - a framework for building HTTP-based agentic environments.
22
+
23
+ ## Features
24
+
25
+ - **HTTPEnvClient**: Generic HTTP client for interacting with remote environments
26
+ - **HTTPEnvServer**: FastAPI-based server wrapper for exposing environments over HTTP
27
+ - **Container Providers**: Pluggable architecture for running containers (Docker, Kubernetes, etc.)
28
+ - **Type System**: Strongly-typed Action/Observation/State interfaces
29
+ - **Web Interface**: Optional web UI for interacting with environments
30
+
31
+ ## Installation
32
+
33
+ ```bash
34
+ pip install openenv-core
35
+ ```
36
+
37
+ For development:
38
+ ```bash
39
+ pip install openenv-core[dev]
40
+ ```
41
+
42
+ ## Quick Start
43
+
44
+ ### Creating an Environment Client
45
+
46
+ ```python
47
+ from openenv_core import HTTPEnvClient, StepResult
48
+ from dataclasses import dataclass
49
+
50
+ @dataclass
51
+ class MyAction:
52
+ text: str
53
+
54
+ @dataclass
55
+ class MyObservation:
56
+ response: str
57
+
58
+ class MyEnvClient(HTTPEnvClient[MyAction, MyObservation]):
59
+ def _step_payload(self, action: MyAction) -> dict:
60
+ return {"text": action.text}
61
+
62
+ def _parse_result(self, payload: dict) -> StepResult[MyObservation]:
63
+ obs_data = payload["observation"]
64
+ return StepResult(
65
+ observation=MyObservation(**obs_data),
66
+ reward=payload.get("reward"),
67
+ done=payload.get("done", False)
68
+ )
69
+
70
+ def _parse_state(self, payload: dict) -> Any:
71
+ return payload
72
+
73
+ # Use with Docker
74
+ env = MyEnvClient.from_docker_image("my-env:latest")
75
+ result = env.reset()
76
+ step_result = env.step(MyAction(text="hello"))
77
+ env.close()
78
+ ```
79
+
80
+ ### Creating an Environment Server
81
+
82
+ ```python
83
+ from openenv_core.env_server import Environment, HTTPEnvServer, create_app
84
+ from dataclasses import dataclass
85
+
86
+ @dataclass
87
+ class MyAction:
88
+ text: str
89
+
90
+ @dataclass
91
+ class MyObservation:
92
+ response: str
93
+ reward: float = 0.0
94
+ done: bool = False
95
+
96
+ class MyEnvironment(Environment):
97
+ def reset(self) -> MyObservation:
98
+ return MyObservation(response="Ready")
99
+
100
+ def step(self, action: MyAction) -> MyObservation:
101
+ return MyObservation(
102
+ response=f"Echo: {action.text}",
103
+ reward=1.0,
104
+ done=False
105
+ )
106
+
107
+ # Create FastAPI app
108
+ env = MyEnvironment()
109
+ app = create_app(env, MyAction, MyObservation)
110
+
111
+ # Run with: uvicorn module:app --host 0.0.0.0 --port 8000
112
+ ```
113
+
114
+ ## Container Providers
115
+
116
+ OpenEnv Core supports multiple container providers:
117
+
118
+ ### Local Docker Provider
119
+
120
+ ```python
121
+ from openenv_core.containers.runtime import LocalDockerProvider
122
+
123
+ provider = LocalDockerProvider()
124
+ base_url = provider.start_container("my-env:latest")
125
+ provider.wait_for_ready(base_url)
126
+ # Use environment...
127
+ provider.stop_container()
128
+ ```
129
+
130
+ ### Kubernetes Provider (Coming Soon)
131
+
132
+ ```python
133
+ from openenv_core.containers.runtime import KubernetesProvider
134
+
135
+ provider = KubernetesProvider(namespace="envs")
136
+ base_url = provider.start_container("my-env:latest")
137
+ # Use environment...
138
+ provider.stop_container()
139
+ ```
140
+
141
+
142
+ ## API Reference
143
+
144
+ ### HTTPEnvClient
145
+
146
+ Base class for environment clients with these abstract methods:
147
+
148
+ - `_step_payload(action)`: Convert action to JSON
149
+ - `_parse_result(payload)`: Parse response to StepResult
150
+ - `_parse_state(payload)`: Parse state response
151
+
152
+ ### HTTPEnvServer
153
+
154
+ Server wrapper with these methods:
155
+
156
+ - `register_routes(app)`: Register endpoints on FastAPI app
157
+ - `_deserialize_action(data)`: Convert JSON to Action
158
+ - `_serialize_observation(obs)`: Convert Observation to JSON
159
+
160
+ ### Environment Interface
161
+
162
+ Base interface for environment implementations:
163
+
164
+ - `reset()`: Reset environment and return initial observation
165
+ - `step(action)`: Execute action and return observation
166
+ - `state`: Property returning current environment state
167
+
168
+ ## License
169
+
170
+ This project is licensed under the BSD-3-Clause License - see the LICENSE file for details.
171
+
172
+ ## Contributing
173
+
174
+ Contributions are welcome! Please see the main OpenEnv repository for contribution guidelines.
175
+
176
+ ## Links
177
+
178
+ - **Homepage**: https://github.com/meta-pytorch/OpenEnv
179
+ - **Documentation**: https://github.com/meta-pytorch/OpenEnv/blob/main/README.md
180
+ - **Bug Tracker**: https://github.com/meta-pytorch/OpenEnv/issues
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 .client_types import StepResult
12
+ from .http_env_client import HTTPEnvClient
13
+
14
+ # Note: MCP module doesn't export anything yet
15
+
16
+ __all__ = [
17
+ "HTTPEnvClient",
18
+ "StepResult",
19
+ ]
src/core/client_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/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/images/Dockerfile ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ smolagents
36
+
37
+ # Set working directory
38
+ WORKDIR /app
39
+
40
+ # Default environment variables
41
+ ENV PYTHONPATH=/app/src
42
+ ENV PYTHONUNBUFFERED=1
43
+
44
+ # Default expose port (can be overridden)
45
+ EXPOSE 8000
46
+
47
+ # 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/providers.py ADDED
@@ -0,0 +1,359 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ - memory_gb: Memory limit in GB (default: 4GB)
142
+ - command_override: List of command args to override container CMD
143
+
144
+ Returns:
145
+ Base URL to connect to the container
146
+ """
147
+ import subprocess
148
+ import time
149
+ import logging
150
+
151
+ logger = logging.getLogger(__name__)
152
+
153
+ # Find available port if not specified
154
+ if port is None:
155
+ port = self._find_available_port()
156
+
157
+ # Use default memory limit if not specified
158
+ memory_gb = kwargs.get("memory_gb", 16)
159
+
160
+ # Generate container name
161
+ self._container_name = self._generate_container_name(image)
162
+
163
+ # Build docker run command
164
+ # Use host networking for better performance and consistency with podman
165
+ # NOTE: Do NOT use --rm initially - if container fails to start, we need logs
166
+ cmd = [
167
+ "docker", "run",
168
+ "-d", # Detached
169
+ "--name", self._container_name,
170
+ "--network", "host", # Use host network
171
+ "--memory", f"{memory_gb}g", # Limit container memory
172
+ "--memory-swap", f"{memory_gb}g", # Prevent swap usage (set equal to --memory)
173
+ "--oom-kill-disable=false", # Allow OOM killer (exit gracefully)
174
+ ]
175
+
176
+ # Add environment variables
177
+ if env_vars:
178
+ for key, value in env_vars.items():
179
+ cmd.extend(["-e", f"{key}={value}"])
180
+
181
+ # Pass custom port via environment variable instead of overriding command
182
+ # This allows the container to use its proper entrypoint/CMD
183
+ if port != 8000:
184
+ cmd.extend(["-e", f"PORT={port}"])
185
+
186
+ # Add image
187
+ cmd.append(image)
188
+
189
+ # Add command override if provided (explicit override by user)
190
+ if "command_override" in kwargs:
191
+ cmd.extend(kwargs["command_override"])
192
+
193
+ # Run container
194
+ try:
195
+ logger.debug(f"Starting container with command: {' '.join(cmd)}")
196
+ result = subprocess.run(cmd, capture_output=True, text=True, check=True)
197
+ self._container_id = result.stdout.strip()
198
+ logger.debug(f"Container started with ID: {self._container_id}")
199
+ except subprocess.CalledProcessError as e:
200
+ error_msg = f"Failed to start Docker container.\nCommand: {' '.join(cmd)}\nExit code: {e.returncode}\nStderr: {e.stderr}\nStdout: {e.stdout}"
201
+ raise RuntimeError(error_msg) from e
202
+
203
+ # Wait a moment for container to start
204
+ time.sleep(1)
205
+
206
+ base_url = f"http://127.0.0.1:{port}"
207
+ return base_url
208
+
209
+ def stop_container(self) -> None:
210
+ """
211
+ Stop and remove the Docker container.
212
+ """
213
+ if self._container_id is None:
214
+ return
215
+
216
+ import subprocess
217
+
218
+ try:
219
+ # Stop container
220
+ subprocess.run(
221
+ ["docker", "stop", self._container_id],
222
+ capture_output=True,
223
+ check=True,
224
+ timeout=10,
225
+ )
226
+
227
+ # Remove container
228
+ subprocess.run(
229
+ ["docker", "rm", self._container_id],
230
+ capture_output=True,
231
+ check=True,
232
+ timeout=10,
233
+ )
234
+ except subprocess.CalledProcessError:
235
+ # Container might already be stopped/removed
236
+ pass
237
+ finally:
238
+ self._container_id = None
239
+ self._container_name = None
240
+
241
+ def wait_for_ready(self, base_url: str, timeout_s: float = 30.0) -> None:
242
+ """
243
+ Wait for container to be ready by polling /health endpoint.
244
+
245
+ Args:
246
+ base_url: Base URL of the container
247
+ timeout_s: Maximum time to wait
248
+
249
+ Raises:
250
+ TimeoutError: If container doesn't become ready
251
+ """
252
+ import time
253
+ import requests
254
+ import subprocess
255
+ import logging
256
+
257
+ start_time = time.time()
258
+ health_url = f"{base_url}/health"
259
+ last_error = None
260
+
261
+ while time.time() - start_time < timeout_s:
262
+ try:
263
+ response = requests.get(health_url, timeout=2.0)
264
+ if response.status_code == 200:
265
+ return
266
+ except requests.RequestException as e:
267
+ last_error = str(e)
268
+
269
+ time.sleep(0.5)
270
+
271
+ # If we timeout, provide diagnostic information
272
+ error_msg = f"Container at {base_url} did not become ready within {timeout_s}s"
273
+
274
+ if self._container_id:
275
+ try:
276
+ # First check if container exists
277
+ inspect_result = subprocess.run(
278
+ ["docker", "inspect", self._container_id],
279
+ capture_output=True,
280
+ text=True,
281
+ timeout=5,
282
+ )
283
+
284
+ if inspect_result.returncode != 0:
285
+ # Container doesn't exist - likely exited and auto-removed due to --rm flag
286
+ error_msg += f"\n\nContainer was auto-removed (likely exited immediately)."
287
+ error_msg += f"\nThis typically means:"
288
+ error_msg += f"\n 1. The container image has an error in its startup script"
289
+ error_msg += f"\n 2. Required dependencies are missing in the container"
290
+ error_msg += f"\n 3. Port {base_url.split(':')[-1]} might be in use by another process"
291
+ error_msg += f"\n 4. Container command/entrypoint is misconfigured"
292
+ error_msg += f"\nTry running the container manually to debug:"
293
+ error_msg += f"\n docker run -it --rm <IMAGE_NAME>"
294
+ else:
295
+ # Container exists, try to get logs
296
+ result = subprocess.run(
297
+ ["docker", "logs", "--tail", "50", self._container_id],
298
+ capture_output=True,
299
+ text=True,
300
+ timeout=5,
301
+ )
302
+ if result.stdout or result.stderr:
303
+ error_msg += f"\n\nContainer logs (last 50 lines):\n{result.stdout}\n{result.stderr}"
304
+ except subprocess.TimeoutExpired:
305
+ error_msg += f"\n\nTimeout while trying to inspect container"
306
+ except Exception as e:
307
+ error_msg += f"\n\nFailed to get container diagnostics: {e}"
308
+
309
+ if last_error:
310
+ error_msg += f"\n\nLast connection error: {last_error}"
311
+
312
+ raise TimeoutError(error_msg)
313
+
314
+ def _find_available_port(self) -> int:
315
+ """
316
+ Find an available port on localhost.
317
+
318
+ Returns:
319
+ An available port number
320
+ """
321
+ import socket
322
+
323
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
324
+ s.bind(("", 0))
325
+ s.listen(1)
326
+ port = s.getsockname()[1]
327
+ return port
328
+
329
+ def _generate_container_name(self, image: str) -> str:
330
+ """
331
+ Generate a unique container name based on image name and timestamp.
332
+
333
+ Args:
334
+ image: Docker image name
335
+
336
+ Returns:
337
+ A unique container name
338
+ """
339
+ import time
340
+
341
+ clean_image = image.split("/")[-1].split(":")[0]
342
+ timestamp = int(time.time() * 1000)
343
+ return f"{clean_image}-{timestamp}"
344
+
345
+
346
+ class KubernetesProvider(ContainerProvider):
347
+ """
348
+ Container provider for Kubernetes clusters.
349
+
350
+ This provider creates pods in a Kubernetes cluster and exposes them
351
+ via services or port-forwarding.
352
+
353
+ Example:
354
+ >>> provider = KubernetesProvider(namespace="envtorch-dev")
355
+ >>> base_url = provider.start_container("echo-env:latest")
356
+ >>> # Pod running in k8s, accessible via service or port-forward
357
+ >>> provider.stop_container()
358
+ """
359
+ 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/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,233 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ env_name: Optional[str] = None,
167
+ ) -> Any:
168
+ """
169
+ Create a FastAPI application with or without web interface.
170
+
171
+ This function creates a FastAPI app with the web interface enabled by default,
172
+ including README integration for better user experience.
173
+
174
+ Args:
175
+ env: The Environment instance to serve
176
+ action_cls: The Action subclass this environment expects
177
+ observation_cls: The Observation subclass this environment returns
178
+ env_name: Optional environment name for README loading
179
+
180
+ Returns:
181
+ FastAPI application instance with or without web interface and README integration
182
+ """
183
+ # Check if web interface should be enabled
184
+ # This can be controlled via environment variable or build argument
185
+ enable_web = (
186
+ os.getenv("ENABLE_WEB_INTERFACE", "false").lower() in ("true", "1", "yes")
187
+ )
188
+
189
+ if enable_web:
190
+ # Import web interface only when needed
191
+ from .web_interface import create_web_interface_app
192
+ return create_web_interface_app(env, action_cls, observation_cls, env_name)
193
+ else:
194
+ # Use standard FastAPI app without web interface
195
+ return create_fastapi_app(env, action_cls, observation_cls)
196
+
197
+
198
+ def create_fastapi_app(
199
+ env: Environment,
200
+ action_cls: Type[Action],
201
+ observation_cls: Type[Observation],
202
+ ) -> Any:
203
+ """
204
+ Create a FastAPI application with routes for the given environment.
205
+
206
+ Args:
207
+ env: The Environment instance to serve
208
+ action_cls: The Action subclass this environment expects
209
+ observation_cls: The Observation subclass this environment returns
210
+
211
+ Returns:
212
+ FastAPI application instance with routes registered
213
+
214
+ Example:
215
+ >>> from envs.coding_env.server import CodeExecutionEnvironment
216
+ >>> from envs.coding_env.models import CodeAction, CodeObservation
217
+ >>>
218
+ >>> env = CodeExecutionEnvironment()
219
+ >>> app = create_fastapi_app(env, CodeAction, CodeObservation)
220
+ >>>
221
+ >>> # Run with: uvicorn module:app --host 0.0.0.0 --port 8000
222
+ """
223
+ try:
224
+ from fastapi import FastAPI
225
+ except ImportError:
226
+ raise ImportError(
227
+ "FastAPI is required. Install with: pip install fastapi uvicorn"
228
+ )
229
+
230
+ app = FastAPI(title="Environment HTTP Server")
231
+ server = HTTPEnvServer(env, action_cls, observation_cls)
232
+ server.register_routes(app)
233
+ 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,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
46
+
47
+
48
+ @dataclass
49
+ class EnvironmentMetadata:
50
+ """Metadata about an environment for documentation and UI purposes."""
51
+
52
+ name: str
53
+ description: str
54
+ readme_content: Optional[str] = None
55
+ version: Optional[str] = None
56
+ author: Optional[str] = None
57
+ documentation_url: Optional[str] = None
src/core/env_server/web_interface.py ADDED
@@ -0,0 +1,1613 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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, EnvironmentMetadata
29
+
30
+
31
+ def load_environment_metadata(env: Environment, env_name: Optional[str] = None) -> EnvironmentMetadata:
32
+ """
33
+ Load environment metadata including README content.
34
+
35
+ Args:
36
+ env: The environment instance
37
+ env_name: Optional environment name for README file lookup
38
+
39
+ Returns:
40
+ EnvironmentMetadata with loaded information
41
+ """
42
+ # Try to get metadata from environment if it has a method for it
43
+ if hasattr(env, 'get_metadata'):
44
+ return env.get_metadata()
45
+
46
+ # Default metadata
47
+ metadata = EnvironmentMetadata(
48
+ name=env_name or env.__class__.__name__,
49
+ description=f"{env.__class__.__name__} environment",
50
+ version="1.0.0"
51
+ )
52
+
53
+ # Try to load README from file system
54
+ readme_content = _load_readme_from_filesystem(env_name)
55
+ if readme_content:
56
+ metadata.readme_content = readme_content
57
+
58
+ return metadata
59
+
60
+
61
+ def _load_readme_from_filesystem(env_name: Optional[str]) -> Optional[str]:
62
+ """
63
+ Load README content from the filesystem.
64
+
65
+ Tries multiple locations:
66
+ 1. Container filesystem: /app/README.md
67
+ 2. Local development: src/envs/{env_name}/README.md
68
+ 3. Environment variable: ENV_README_PATH
69
+ """
70
+ import os
71
+ from pathlib import Path
72
+
73
+ # Try container filesystem first
74
+ container_readme = Path("/app/README.md")
75
+ if container_readme.exists():
76
+ try:
77
+ return container_readme.read_text(encoding='utf-8')
78
+ except Exception:
79
+ pass
80
+
81
+ # Try environment variable path
82
+ custom_path = os.environ.get("ENV_README_PATH")
83
+ if custom_path and Path(custom_path).exists():
84
+ try:
85
+ return Path(custom_path).read_text(encoding='utf-8')
86
+ except Exception:
87
+ pass
88
+
89
+ # Try local development path
90
+ if env_name:
91
+ local_readme = Path(f"src/envs/{env_name}/README.md")
92
+ if local_readme.exists():
93
+ try:
94
+ return local_readme.read_text(encoding='utf-8')
95
+ except Exception:
96
+ pass
97
+
98
+ return None
99
+
100
+
101
+ @dataclass
102
+ class ActionLog:
103
+ """Log entry for an action taken."""
104
+ timestamp: str
105
+ action: Dict[str, Any]
106
+ observation: Dict[str, Any]
107
+ reward: Optional[float]
108
+ done: bool
109
+ step_count: int
110
+
111
+
112
+ @dataclass
113
+ class EpisodeState:
114
+ """Current episode state for the web interface."""
115
+ episode_id: Optional[str]
116
+ step_count: int
117
+ current_observation: Optional[Dict[str, Any]]
118
+ action_logs: List[ActionLog]
119
+ is_reset: bool = True
120
+
121
+
122
+ class WebInterfaceManager:
123
+ """Manages the web interface for an environment."""
124
+
125
+ def __init__(
126
+ self,
127
+ env: Environment,
128
+ action_cls: Type[Action],
129
+ observation_cls: Type[Observation],
130
+ metadata: Optional[EnvironmentMetadata] = None,
131
+ ):
132
+ self.env = env
133
+ self.action_cls = action_cls
134
+ self.observation_cls = observation_cls
135
+ self.metadata = metadata or EnvironmentMetadata(
136
+ name=env.__class__.__name__,
137
+ description=f"{env.__class__.__name__} environment"
138
+ )
139
+ self.episode_state = EpisodeState(
140
+ episode_id=None,
141
+ step_count=0,
142
+ current_observation=None,
143
+ action_logs=[]
144
+ )
145
+ self.connected_clients: List[WebSocket] = []
146
+
147
+ async def connect_websocket(self, websocket: WebSocket):
148
+ """Connect a new WebSocket client."""
149
+ await websocket.accept()
150
+ self.connected_clients.append(websocket)
151
+
152
+ # Send current state to the new client
153
+ await self._send_state_update()
154
+
155
+ async def disconnect_websocket(self, websocket: WebSocket):
156
+ """Disconnect a WebSocket client."""
157
+ if websocket in self.connected_clients:
158
+ self.connected_clients.remove(websocket)
159
+
160
+ async def _send_state_update(self):
161
+ """Send current state to all connected clients."""
162
+ if not self.connected_clients:
163
+ return
164
+
165
+ state_data = {
166
+ "type": "state_update",
167
+ "episode_state": asdict(self.episode_state)
168
+ }
169
+
170
+ # Send to all connected clients
171
+ disconnected_clients = []
172
+ for client in self.connected_clients:
173
+ try:
174
+ await client.send_text(json.dumps(state_data))
175
+ except:
176
+ disconnected_clients.append(client)
177
+
178
+ # Remove disconnected clients
179
+ for client in disconnected_clients:
180
+ self.connected_clients.remove(client)
181
+
182
+ async def reset_environment(self) -> Dict[str, Any]:
183
+ """Reset the environment and update state."""
184
+ observation = self.env.reset()
185
+ state = self.env.state
186
+
187
+ # Update episode state
188
+ self.episode_state.episode_id = state.episode_id
189
+ self.episode_state.step_count = 0
190
+ self.episode_state.current_observation = asdict(observation)
191
+ self.episode_state.action_logs = []
192
+ self.episode_state.is_reset = True
193
+
194
+ # Send state update
195
+ await self._send_state_update()
196
+
197
+ return {
198
+ "observation": asdict(observation),
199
+ "reward": observation.reward,
200
+ "done": observation.done,
201
+ }
202
+
203
+ async def step_environment(self, action_data: Dict[str, Any]) -> Dict[str, Any]:
204
+ """Execute a step in the environment and update state."""
205
+ # Deserialize action
206
+ action = self._deserialize_action(action_data)
207
+
208
+ # Execute step
209
+ observation = self.env.step(action)
210
+ state = self.env.state
211
+
212
+ # Create action log
213
+ action_log = ActionLog(
214
+ timestamp=datetime.now().isoformat(),
215
+ action=asdict(action),
216
+ observation=asdict(observation),
217
+ reward=observation.reward,
218
+ done=observation.done,
219
+ step_count=state.step_count
220
+ )
221
+
222
+ # Update episode state
223
+ self.episode_state.episode_id = state.episode_id
224
+ self.episode_state.step_count = state.step_count
225
+ self.episode_state.current_observation = asdict(observation)
226
+ self.episode_state.action_logs.append(action_log)
227
+ self.episode_state.is_reset = False
228
+
229
+ # Send state update
230
+ await self._send_state_update()
231
+
232
+ return {
233
+ "observation": asdict(observation),
234
+ "reward": observation.reward,
235
+ "done": observation.done,
236
+ }
237
+
238
+ def get_state(self) -> Dict[str, Any]:
239
+ """Get current environment state."""
240
+ state = self.env.state
241
+ return asdict(state)
242
+
243
+ def _deserialize_action(self, action_data: Dict[str, Any]) -> Action:
244
+ """Convert JSON dict to Action instance."""
245
+ metadata = action_data.pop("metadata", {})
246
+
247
+ # Handle tensor fields that come from JSON as lists
248
+ processed_data = {}
249
+ for key, value in action_data.items():
250
+ if key == "tokens" and isinstance(value, (list, str)):
251
+ # Convert list or string to tensor
252
+ if isinstance(value, str):
253
+ # If it's a string, try to parse it as a list of numbers
254
+ try:
255
+ import json
256
+ value = json.loads(value)
257
+ except:
258
+ # If parsing fails, treat as empty list
259
+ value = []
260
+ if isinstance(value, list):
261
+ import torch
262
+ processed_data[key] = torch.tensor(value, dtype=torch.long)
263
+ else:
264
+ processed_data[key] = value
265
+ elif key == "action_id" and isinstance(value, str):
266
+ # Convert action_id from string to int
267
+ try:
268
+ processed_data[key] = int(value)
269
+ except ValueError:
270
+ # If conversion fails, keep original value
271
+ processed_data[key] = value
272
+ else:
273
+ processed_data[key] = value
274
+
275
+ action = self.action_cls(**processed_data)
276
+ action.metadata = metadata
277
+ return action
278
+
279
+
280
+ def create_web_interface_app(
281
+ env: Environment,
282
+ action_cls: Type[Action],
283
+ observation_cls: Type[Observation],
284
+ env_name: Optional[str] = None,
285
+ ) -> FastAPI:
286
+ """
287
+ Create a FastAPI application with web interface for the given environment.
288
+
289
+ Args:
290
+ env: The Environment instance to serve
291
+ action_cls: The Action subclass this environment expects
292
+ observation_cls: The Observation subclass this environment returns
293
+ env_name: Optional environment name for README loading
294
+
295
+ Returns:
296
+ FastAPI application instance with web interface
297
+ """
298
+ from .http_server import create_fastapi_app
299
+
300
+ # Create the base environment app
301
+ app = create_fastapi_app(env, action_cls, observation_cls)
302
+
303
+ # Load environment metadata
304
+ metadata = load_environment_metadata(env, env_name)
305
+
306
+ # Create web interface manager
307
+ web_manager = WebInterfaceManager(env, action_cls, observation_cls, metadata)
308
+
309
+ # Add web interface routes
310
+ @app.get("/web", response_class=HTMLResponse)
311
+ async def web_interface():
312
+ """Serve the web interface."""
313
+ return get_web_interface_html(action_cls, web_manager.metadata)
314
+
315
+ @app.get("/web/metadata")
316
+ async def web_metadata():
317
+ """Get environment metadata."""
318
+ return asdict(web_manager.metadata)
319
+
320
+ @app.websocket("/ws")
321
+ async def websocket_endpoint(websocket: WebSocket):
322
+ """WebSocket endpoint for real-time updates."""
323
+ await web_manager.connect_websocket(websocket)
324
+ try:
325
+ while True:
326
+ # Keep connection alive
327
+ await websocket.receive_text()
328
+ except WebSocketDisconnect:
329
+ await web_manager.disconnect_websocket(websocket)
330
+
331
+ @app.post("/web/reset")
332
+ async def web_reset():
333
+ """Reset endpoint for web interface."""
334
+ return await web_manager.reset_environment()
335
+
336
+ @app.post("/web/step")
337
+ async def web_step(request: Dict[str, Any]):
338
+ """Step endpoint for web interface."""
339
+ # Check if this is a message-based request (chat environment)
340
+ if "message" in request:
341
+ message = request["message"]
342
+ # Convert message to action using the environment's message_to_action method
343
+ action = web_manager.env.message_to_action(message)
344
+ action_data = {"tokens": action.tokens.tolist()}
345
+ else:
346
+ action_data = request.get("action", {})
347
+
348
+ return await web_manager.step_environment(action_data)
349
+
350
+ @app.get("/web/state")
351
+ async def web_state():
352
+ """State endpoint for web interface."""
353
+ return web_manager.get_state()
354
+
355
+ return app
356
+
357
+
358
+ def get_web_interface_html(action_cls: Type[Action], metadata: Optional[EnvironmentMetadata] = None) -> str:
359
+ """Generate the HTML for the web interface."""
360
+
361
+ # Check if this is a chat environment by looking for tokens field
362
+ is_chat_env = False
363
+ if hasattr(action_cls, '__dataclass_fields__'):
364
+ for field_name, field_info in action_cls.__dataclass_fields__.items():
365
+ if field_name == 'tokens' and hasattr(field_info.type, '__name__') and 'Tensor' in field_info.type.__name__:
366
+ is_chat_env = True
367
+ break
368
+
369
+ # Get action fields for dynamic form generation with enhanced metadata
370
+ action_fields = _extract_action_fields(action_cls)
371
+
372
+ return f"""
373
+ <!DOCTYPE html>
374
+ <html lang="en">
375
+ <head>
376
+ <meta charset="UTF-8">
377
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
378
+ <title>OpenEnv Web Interface</title>
379
+ <style>
380
+ * {{
381
+ margin: 0;
382
+ padding: 0;
383
+ box-sizing: border-box;
384
+ }}
385
+
386
+ body {{
387
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
388
+ background-color: #f5f5f5;
389
+ height: 100vh;
390
+ overflow: hidden;
391
+ }}
392
+
393
+ .container {{
394
+ display: flex;
395
+ height: 100vh;
396
+ }}
397
+
398
+ .left-pane {{
399
+ width: 50%;
400
+ background: white;
401
+ border-right: 1px solid #e0e0e0;
402
+ display: flex;
403
+ flex-direction: column;
404
+ }}
405
+
406
+ .right-pane {{
407
+ width: 50%;
408
+ background: #fafafa;
409
+ display: flex;
410
+ flex-direction: column;
411
+ }}
412
+
413
+ .pane-header {{
414
+ padding: 20px;
415
+ border-bottom: 1px solid #e0e0e0;
416
+ background: #f8f9fa;
417
+ font-weight: 600;
418
+ font-size: 16px;
419
+ }}
420
+
421
+ .pane-content {{
422
+ flex: 1;
423
+ padding: 20px;
424
+ overflow-y: auto;
425
+ }}
426
+
427
+ .action-form {{
428
+ background: white;
429
+ border: 1px solid #e0e0e0;
430
+ border-radius: 8px;
431
+ padding: 20px;
432
+ margin-bottom: 20px;
433
+ }}
434
+
435
+ .form-group {{
436
+ margin-bottom: 15px;
437
+ }}
438
+
439
+ .form-group label {{
440
+ display: block;
441
+ margin-bottom: 5px;
442
+ font-weight: 500;
443
+ color: #333;
444
+ }}
445
+
446
+ .form-group input, .form-group textarea {{
447
+ width: 100%;
448
+ padding: 8px 12px;
449
+ border: 1px solid #ddd;
450
+ border-radius: 4px;
451
+ font-size: 14px;
452
+ }}
453
+
454
+ .form-group input:focus, .form-group textarea:focus {{
455
+ outline: none;
456
+ border-color: #007bff;
457
+ box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
458
+ }}
459
+
460
+ .btn {{
461
+ background: #007bff;
462
+ color: white;
463
+ border: none;
464
+ padding: 10px 20px;
465
+ border-radius: 4px;
466
+ cursor: pointer;
467
+ font-size: 14px;
468
+ margin-right: 10px;
469
+ margin-bottom: 10px;
470
+ }}
471
+
472
+ .btn:hover {{
473
+ background: #0056b3;
474
+ }}
475
+
476
+ .btn:disabled {{
477
+ background: #6c757d;
478
+ cursor: not-allowed;
479
+ }}
480
+
481
+ .btn-secondary {{
482
+ background: #6c757d;
483
+ }}
484
+
485
+ .btn-secondary:hover {{
486
+ background: #545b62;
487
+ }}
488
+
489
+ .state-display {{
490
+ background: white;
491
+ border: 1px solid #e0e0e0;
492
+ border-radius: 8px;
493
+ padding: 15px;
494
+ margin-bottom: 20px;
495
+ }}
496
+
497
+ .state-item {{
498
+ margin-bottom: 8px;
499
+ }}
500
+
501
+ .state-label {{
502
+ font-weight: 500;
503
+ color: #666;
504
+ }}
505
+
506
+ .state-value {{
507
+ color: #333;
508
+ font-family: monospace;
509
+ }}
510
+
511
+ .logs-container {{
512
+ background: white;
513
+ border: 1px solid #e0e0e0;
514
+ border-radius: 8px;
515
+ padding: 15px;
516
+ max-height: 400px;
517
+ overflow-y: auto;
518
+ }}
519
+
520
+ .log-entry {{
521
+ border-bottom: 1px solid #f0f0f0;
522
+ padding: 10px 0;
523
+ }}
524
+
525
+ .log-entry:last-child {{
526
+ border-bottom: none;
527
+ }}
528
+
529
+ .log-timestamp {{
530
+ font-size: 12px;
531
+ color: #666;
532
+ margin-bottom: 5px;
533
+ }}
534
+
535
+ .log-action {{
536
+ background: #e3f2fd;
537
+ padding: 8px;
538
+ border-radius: 4px;
539
+ margin-bottom: 5px;
540
+ font-family: monospace;
541
+ font-size: 12px;
542
+ }}
543
+
544
+ .log-observation {{
545
+ background: #f3e5f5;
546
+ padding: 8px;
547
+ border-radius: 4px;
548
+ font-family: monospace;
549
+ font-size: 12px;
550
+ }}
551
+
552
+ .log-reward {{
553
+ font-weight: 600;
554
+ color: #28a745;
555
+ }}
556
+
557
+ .log-done {{
558
+ font-weight: 600;
559
+ color: #dc3545;
560
+ }}
561
+
562
+ .status-indicator {{
563
+ display: inline-block;
564
+ width: 8px;
565
+ height: 8px;
566
+ border-radius: 50%;
567
+ margin-right: 8px;
568
+ }}
569
+
570
+ .status-connected {{
571
+ background: #28a745;
572
+ }}
573
+
574
+ .status-disconnected {{
575
+ background: #dc3545;
576
+ }}
577
+
578
+ .json-display {{
579
+ background: #f8f9fa;
580
+ border: 1px solid #e9ecef;
581
+ border-radius: 4px;
582
+ padding: 10px;
583
+ font-family: monospace;
584
+ font-size: 12px;
585
+ white-space: pre-wrap;
586
+ max-height: 200px;
587
+ overflow-y: auto;
588
+ }}
589
+
590
+ /* Chat Interface Styles */
591
+ .chat-interface {{
592
+ background: white;
593
+ border: 1px solid #e0e0e0;
594
+ border-radius: 8px;
595
+ padding: 20px;
596
+ margin-bottom: 20px;
597
+ }}
598
+
599
+ .chat-messages {{
600
+ background: #f8f9fa;
601
+ border: 1px solid #e0e0e0;
602
+ border-radius: 8px;
603
+ padding: 15px;
604
+ margin-bottom: 15px;
605
+ max-height: 400px;
606
+ overflow-y: auto;
607
+ }}
608
+
609
+ .chat-message {{
610
+ margin-bottom: 15px;
611
+ padding: 10px;
612
+ border-radius: 8px;
613
+ }}
614
+
615
+ .chat-message:last-child {{
616
+ margin-bottom: 0;
617
+ }}
618
+
619
+ .chat-message.user {{
620
+ background: #e3f2fd;
621
+ margin-left: 20px;
622
+ }}
623
+
624
+ .chat-message.assistant {{
625
+ background: #f3e5f5;
626
+ margin-right: 20px;
627
+ }}
628
+
629
+ .chat-message.system {{
630
+ background: #e8f5e8;
631
+ font-style: italic;
632
+ }}
633
+
634
+ .message-role {{
635
+ font-weight: 600;
636
+ font-size: 12px;
637
+ color: #666;
638
+ margin-bottom: 5px;
639
+ }}
640
+
641
+ .message-content {{
642
+ font-size: 14px;
643
+ line-height: 1.4;
644
+ }}
645
+
646
+ .chat-input-container {{
647
+ border-top: 1px solid #e0e0e0;
648
+ padding-top: 15px;
649
+ }}
650
+
651
+ .role-selector {{
652
+ margin-bottom: 10px;
653
+ }}
654
+
655
+ .role-selector label {{
656
+ font-weight: 500;
657
+ margin-right: 10px;
658
+ }}
659
+
660
+ .role-selector select {{
661
+ padding: 5px 10px;
662
+ border: 1px solid #ddd;
663
+ border-radius: 4px;
664
+ }}
665
+
666
+ .message-input {{
667
+ display: flex;
668
+ gap: 10px;
669
+ align-items: flex-end;
670
+ }}
671
+
672
+ .message-input textarea {{
673
+ flex: 1;
674
+ padding: 10px;
675
+ border: 1px solid #ddd;
676
+ border-radius: 4px;
677
+ resize: vertical;
678
+ font-family: inherit;
679
+ }}
680
+
681
+ .message-input textarea:focus {{
682
+ outline: none;
683
+ border-color: #007bff;
684
+ box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
685
+ }}
686
+
687
+ /* Instructions Section Styles */
688
+ .instructions-section {{
689
+ background: white;
690
+ border: 1px solid #e0e0e0;
691
+ border-radius: 8px;
692
+ padding: 20px;
693
+ margin-bottom: 20px;
694
+ }}
695
+
696
+ .instructions-header {{
697
+ display: flex;
698
+ justify-content: space-between;
699
+ align-items: center;
700
+ margin-bottom: 15px;
701
+ }}
702
+
703
+ .instructions-title {{
704
+ font-size: 18px;
705
+ font-weight: 600;
706
+ color: #333;
707
+ margin: 0;
708
+ }}
709
+
710
+ .instructions-toggle {{
711
+ background: #f8f9fa;
712
+ border: 1px solid #dee2e6;
713
+ border-radius: 4px;
714
+ padding: 5px 10px;
715
+ cursor: pointer;
716
+ font-size: 12px;
717
+ color: #6c757d;
718
+ }}
719
+
720
+ .instructions-toggle:hover {{
721
+ background: #e9ecef;
722
+ }}
723
+
724
+ .instructions-content {{
725
+ display: none;
726
+ max-height: 400px;
727
+ overflow-y: auto;
728
+ border-top: 1px solid #e0e0e0;
729
+ padding-top: 15px;
730
+ }}
731
+
732
+ .instructions-content.expanded {{
733
+ display: block;
734
+ }}
735
+
736
+ .instructions-content h1,
737
+ .instructions-content h2,
738
+ .instructions-content h3 {{
739
+ color: #333;
740
+ margin-top: 20px;
741
+ margin-bottom: 10px;
742
+ }}
743
+
744
+ .instructions-content h1 {{
745
+ font-size: 24px;
746
+ border-bottom: 2px solid #007bff;
747
+ padding-bottom: 10px;
748
+ }}
749
+
750
+ .instructions-content h2 {{
751
+ font-size: 20px;
752
+ }}
753
+
754
+ .instructions-content h3 {{
755
+ font-size: 16px;
756
+ }}
757
+
758
+ .instructions-content p {{
759
+ margin-bottom: 10px;
760
+ line-height: 1.6;
761
+ }}
762
+
763
+ .instructions-content code {{
764
+ background: #f8f9fa;
765
+ padding: 2px 4px;
766
+ border-radius: 3px;
767
+ font-family: monospace;
768
+ font-size: 14px;
769
+ }}
770
+
771
+ .instructions-content pre {{
772
+ background: #f8f9fa;
773
+ border: 1px solid #e9ecef;
774
+ border-radius: 4px;
775
+ padding: 15px;
776
+ overflow-x: auto;
777
+ margin: 10px 0;
778
+ }}
779
+
780
+ .instructions-content pre code {{
781
+ background: none;
782
+ padding: 0;
783
+ }}
784
+
785
+ .instructions-content ul,
786
+ .instructions-content ol {{
787
+ margin: 10px 0;
788
+ padding-left: 20px;
789
+ }}
790
+
791
+ .instructions-content li {{
792
+ margin-bottom: 5px;
793
+ }}
794
+
795
+ .instructions-content table {{
796
+ border-collapse: collapse;
797
+ width: 100%;
798
+ margin: 15px 0;
799
+ }}
800
+
801
+ .instructions-content th,
802
+ .instructions-content td {{
803
+ border: 1px solid #dee2e6;
804
+ padding: 8px 12px;
805
+ text-align: left;
806
+ }}
807
+
808
+ .instructions-content th {{
809
+ background: #f8f9fa;
810
+ font-weight: 600;
811
+ }}
812
+
813
+ /* Enhanced Form Styles */
814
+ .help-text {{
815
+ display: block;
816
+ margin-top: 5px;
817
+ font-size: 12px;
818
+ color: #6c757d;
819
+ font-style: italic;
820
+ }}
821
+
822
+ .form-group label {{
823
+ font-weight: 500;
824
+ color: #333;
825
+ margin-bottom: 5px;
826
+ }}
827
+
828
+ .form-group select {{
829
+ width: 100%;
830
+ padding: 8px 12px;
831
+ border: 1px solid #ddd;
832
+ border-radius: 4px;
833
+ font-size: 14px;
834
+ background-color: white;
835
+ }}
836
+
837
+ .form-group select:focus {{
838
+ outline: none;
839
+ border-color: #007bff;
840
+ box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
841
+ }}
842
+
843
+ .form-group textarea {{
844
+ width: 100%;
845
+ padding: 8px 12px;
846
+ border: 1px solid #ddd;
847
+ border-radius: 4px;
848
+ font-size: 14px;
849
+ font-family: inherit;
850
+ resize: vertical;
851
+ }}
852
+
853
+ .form-group textarea:focus {{
854
+ outline: none;
855
+ border-color: #007bff;
856
+ box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
857
+ }}
858
+
859
+ .form-group input[type="number"] {{
860
+ width: 100%;
861
+ padding: 8px 12px;
862
+ border: 1px solid #ddd;
863
+ border-radius: 4px;
864
+ font-size: 14px;
865
+ }}
866
+
867
+ .form-group input[type="number"]:focus {{
868
+ outline: none;
869
+ border-color: #007bff;
870
+ box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
871
+ }}
872
+
873
+ .form-group input[type="text"]:focus {{
874
+ outline: none;
875
+ border-color: #007bff;
876
+ box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
877
+ }}
878
+
879
+ .required-indicator {{
880
+ color: #dc3545;
881
+ font-weight: bold;
882
+ }}
883
+
884
+ .form-group .field-description {{
885
+ font-size: 11px;
886
+ color: #666;
887
+ margin-top: 2px;
888
+ font-style: italic;
889
+ }}
890
+ </style>
891
+ </head>
892
+ <body>
893
+ <div class="container">
894
+ <!-- Left Pane: HumanAgent Interface -->
895
+ <div class="left-pane">
896
+ <div class="pane-header">
897
+ <span class="status-indicator status-disconnected" id="connection-status"></span>
898
+ HumanAgent Interface
899
+ </div>
900
+ <div class="pane-content">
901
+ <!-- Instructions Section -->
902
+ {_generate_instructions_section(metadata)}
903
+
904
+ <!-- Action Form or Chat Interface -->
905
+ {_generate_action_interface(action_fields, is_chat_env)}
906
+
907
+ <!-- Control Buttons -->
908
+ <div style="margin-bottom: 20px;">
909
+ <button class="btn btn-secondary" id="reset-btn">Reset Environment</button>
910
+ <button class="btn btn-secondary" id="state-btn">Get State</button>
911
+ </div>
912
+
913
+ <!-- Current State Display -->
914
+ <div class="state-display">
915
+ <h3>Current State</h3>
916
+ <div id="current-state">
917
+ <div class="state-item">
918
+ <span class="state-label">Status:</span>
919
+ <span class="state-value" id="env-status">Not initialized</span>
920
+ </div>
921
+ <div class="state-item">
922
+ <span class="state-label">Episode ID:</span>
923
+ <span class="state-value" id="episode-id">-</span>
924
+ </div>
925
+ <div class="state-item">
926
+ <span class="state-label">Step Count:</span>
927
+ <span class="state-value" id="step-count">0</span>
928
+ </div>
929
+ </div>
930
+ </div>
931
+ </div>
932
+ </div>
933
+
934
+ <!-- Right Pane: State Observer -->
935
+ <div class="right-pane">
936
+ <div class="pane-header">
937
+ State Observer
938
+ </div>
939
+ <div class="pane-content">
940
+ <!-- Current Observation -->
941
+ <div class="state-display">
942
+ <h3>Current Observation</h3>
943
+ <div id="current-observation" class="json-display">
944
+ No observation yet
945
+ </div>
946
+ </div>
947
+
948
+ <!-- Action Logs -->
949
+ <div class="logs-container">
950
+ <h3>Action History</h3>
951
+ <div id="action-logs">
952
+ No actions taken yet
953
+ </div>
954
+ </div>
955
+ </div>
956
+ </div>
957
+ </div>
958
+
959
+ <script>
960
+ class OpenEnvWebInterface {{
961
+ constructor() {{
962
+ this.ws = null;
963
+ this.isConnected = false;
964
+ this.init();
965
+ }}
966
+
967
+ init() {{
968
+ this.connectWebSocket();
969
+ this.setupEventListeners();
970
+ }}
971
+
972
+ connectWebSocket() {{
973
+ const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
974
+ const wsUrl = `${{protocol}}//${{window.location.host}}/ws`;
975
+
976
+ this.ws = new WebSocket(wsUrl);
977
+
978
+ this.ws.onopen = () => {{
979
+ this.isConnected = true;
980
+ this.updateConnectionStatus(true);
981
+ console.log('WebSocket connected');
982
+ }};
983
+
984
+ this.ws.onmessage = (event) => {{
985
+ const data = JSON.parse(event.data);
986
+ if (data.type === 'state_update') {{
987
+ this.updateUI(data.episode_state);
988
+ }}
989
+ }};
990
+
991
+ this.ws.onclose = () => {{
992
+ this.isConnected = false;
993
+ this.updateConnectionStatus(false);
994
+ console.log('WebSocket disconnected');
995
+ // Attempt to reconnect after 3 seconds
996
+ setTimeout(() => this.connectWebSocket(), 3000);
997
+ }};
998
+
999
+ this.ws.onerror = (error) => {{
1000
+ console.error('WebSocket error:', error);
1001
+ }};
1002
+ }}
1003
+
1004
+ setupEventListeners() {{
1005
+ // Instructions toggle
1006
+ const instructionsToggle = document.getElementById('instructions-toggle');
1007
+ const instructionsContent = document.getElementById('instructions-content');
1008
+ if (instructionsToggle && instructionsContent) {{
1009
+ instructionsToggle.addEventListener('click', () => {{
1010
+ instructionsContent.classList.toggle('expanded');
1011
+ instructionsToggle.textContent = instructionsContent.classList.contains('expanded')
1012
+ ? 'Hide Instructions' : 'Show Instructions';
1013
+ }});
1014
+ }}
1015
+
1016
+ // Check if this is a chat environment
1017
+ const isChatEnv = document.getElementById('chat-messages') !== null;
1018
+
1019
+ if (isChatEnv) {{
1020
+ // Chat environment event listeners
1021
+ document.getElementById('send-message-btn').addEventListener('click', () => {{
1022
+ this.sendMessage();
1023
+ }});
1024
+
1025
+ // Send message on Enter (but allow Shift+Enter for new lines)
1026
+ document.getElementById('message-input').addEventListener('keydown', (e) => {{
1027
+ if (e.key === 'Enter' && !e.shiftKey) {{
1028
+ e.preventDefault();
1029
+ this.sendMessage();
1030
+ }}
1031
+ }});
1032
+ }} else {{
1033
+ // Traditional action form submission
1034
+ const actionForm = document.getElementById('action-form');
1035
+ if (actionForm) {{
1036
+ actionForm.addEventListener('submit', (e) => {{
1037
+ e.preventDefault();
1038
+ this.submitAction();
1039
+ }});
1040
+ }}
1041
+ }}
1042
+
1043
+ // Reset button
1044
+ document.getElementById('reset-btn').addEventListener('click', () => {{
1045
+ this.resetEnvironment();
1046
+ }});
1047
+
1048
+ // State button
1049
+ document.getElementById('state-btn').addEventListener('click', () => {{
1050
+ this.getState();
1051
+ }});
1052
+ }}
1053
+
1054
+ async sendMessage() {{
1055
+ const messageInput = document.getElementById('message-input');
1056
+ const roleSelect = document.getElementById('message-role');
1057
+ const message = messageInput.value.trim();
1058
+ const role = roleSelect.value;
1059
+
1060
+ if (!message) {{
1061
+ return;
1062
+ }}
1063
+
1064
+ // Add message to chat display immediately
1065
+ this.addMessageToChat(role, message);
1066
+
1067
+ // Clear input
1068
+ messageInput.value = '';
1069
+
1070
+ try {{
1071
+ // Send message to server to convert to action and step
1072
+ const response = await fetch('/web/step', {{
1073
+ method: 'POST',
1074
+ headers: {{ 'Content-Type': 'application/json' }},
1075
+ body: JSON.stringify({{
1076
+ message: {{
1077
+ role: role,
1078
+ content: message
1079
+ }}
1080
+ }})
1081
+ }});
1082
+
1083
+ if (!response.ok) {{
1084
+ throw new Error(`HTTP error! status: ${{response.status}}`);
1085
+ }}
1086
+
1087
+ const result = await response.json();
1088
+ console.log('Message sent:', result);
1089
+ }} catch (error) {{
1090
+ console.error('Error sending message:', error);
1091
+ alert('Error sending message: ' + error.message);
1092
+ }}
1093
+ }}
1094
+
1095
+ addMessageToChat(role, content) {{
1096
+ const chatMessages = document.getElementById('chat-messages');
1097
+ const messageDiv = document.createElement('div');
1098
+ messageDiv.className = `chat-message ${{role}}`;
1099
+
1100
+ messageDiv.innerHTML = `
1101
+ <div class="message-role">${{role.charAt(0).toUpperCase() + role.slice(1)}}</div>
1102
+ <div class="message-content">${{content}}</div>
1103
+ `;
1104
+
1105
+ chatMessages.appendChild(messageDiv);
1106
+ chatMessages.scrollTop = chatMessages.scrollHeight;
1107
+ }}
1108
+
1109
+ async submitAction() {{
1110
+ const formData = new FormData(document.getElementById('action-form'));
1111
+ const action = {{}};
1112
+
1113
+ // Collect form data
1114
+ for (const [key, value] of formData.entries()) {{
1115
+ if (value !== '') {{
1116
+ // Handle tensor fields (tokens) - convert comma-separated string to array
1117
+ if (key === 'tokens') {{
1118
+ try {{
1119
+ action[key] = value.split(',').map(x => parseInt(x.trim())).filter(x => !isNaN(x));
1120
+ }} catch (e) {{
1121
+ console.error('Error parsing tokens:', e);
1122
+ action[key] = [];
1123
+ }}
1124
+ }} else {{
1125
+ action[key] = value;
1126
+ }}
1127
+ }}
1128
+ }}
1129
+
1130
+ try {{
1131
+ const response = await fetch('/web/step', {{
1132
+ method: 'POST',
1133
+ headers: {{ 'Content-Type': 'application/json' }},
1134
+ body: JSON.stringify({{ action }})
1135
+ }});
1136
+
1137
+ if (!response.ok) {{
1138
+ throw new Error(`HTTP error! status: ${{response.status}}`);
1139
+ }}
1140
+
1141
+ const result = await response.json();
1142
+ console.log('Step result:', result);
1143
+ }} catch (error) {{
1144
+ console.error('Error submitting action:', error);
1145
+ alert('Error submitting action: ' + error.message);
1146
+ }}
1147
+ }}
1148
+
1149
+ async resetEnvironment() {{
1150
+ try {{
1151
+ const response = await fetch('/web/reset', {{
1152
+ method: 'POST',
1153
+ headers: {{ 'Content-Type': 'application/json' }}
1154
+ }});
1155
+
1156
+ if (!response.ok) {{
1157
+ throw new Error(`HTTP error! status: ${{response.status}}`);
1158
+ }}
1159
+
1160
+ const result = await response.json();
1161
+ console.log('Reset result:', result);
1162
+ }} catch (error) {{
1163
+ console.error('Error resetting environment:', error);
1164
+ alert('Error resetting environment: ' + error.message);
1165
+ }}
1166
+ }}
1167
+
1168
+ async getState() {{
1169
+ try {{
1170
+ const response = await fetch('/web/state');
1171
+ const state = await response.json();
1172
+ console.log('Current state:', state);
1173
+ alert('Current state: ' + JSON.stringify(state, null, 2));
1174
+ }} catch (error) {{
1175
+ console.error('Error getting state:', error);
1176
+ alert('Error getting state: ' + error.message);
1177
+ }}
1178
+ }}
1179
+
1180
+ updateConnectionStatus(connected) {{
1181
+ const indicator = document.getElementById('connection-status');
1182
+ if (connected) {{
1183
+ indicator.className = 'status-indicator status-connected';
1184
+ }} else {{
1185
+ indicator.className = 'status-indicator status-disconnected';
1186
+ }}
1187
+ }}
1188
+
1189
+ updateUI(episodeState) {{
1190
+ // Check if this is a chat environment
1191
+ const isChatEnv = document.getElementById('chat-messages') !== null;
1192
+
1193
+ // Update current state
1194
+ document.getElementById('env-status').textContent =
1195
+ episodeState.is_reset ? 'Reset' : 'Running';
1196
+ document.getElementById('episode-id').textContent =
1197
+ episodeState.episode_id || '-';
1198
+ document.getElementById('step-count').textContent =
1199
+ episodeState.step_count.toString();
1200
+
1201
+ if (isChatEnv) {{
1202
+ // Update chat interface
1203
+ this.updateChatInterface(episodeState);
1204
+ }} else {{
1205
+ // Update traditional observation display
1206
+ const observationDiv = document.getElementById('current-observation');
1207
+ if (episodeState.current_observation) {{
1208
+ observationDiv.textContent = JSON.stringify(
1209
+ episodeState.current_observation, null, 2
1210
+ );
1211
+ }} else {{
1212
+ observationDiv.textContent = 'No observation yet';
1213
+ }}
1214
+ }}
1215
+
1216
+ // Update action logs
1217
+ const logsDiv = document.getElementById('action-logs');
1218
+ if (episodeState.action_logs.length === 0) {{
1219
+ logsDiv.innerHTML = 'No actions taken yet';
1220
+ }} else {{
1221
+ logsDiv.innerHTML = episodeState.action_logs.map(log => `
1222
+ <div class="log-entry">
1223
+ <div class="log-timestamp">${{log.timestamp}} (Step ${{log.step_count}})</div>
1224
+ <div class="log-action">Action: ${{JSON.stringify(log.action, null, 2)}}</div>
1225
+ <div class="log-observation">Observation: ${{JSON.stringify(log.observation, null, 2)}}</div>
1226
+ <div>
1227
+ <span class="log-reward">Reward: ${{log.reward !== null ? log.reward : 'None'}}</span>
1228
+ ${{log.done ? '<span class="log-done">DONE</span>' : ''}}
1229
+ </div>
1230
+ </div>
1231
+ `).join('');
1232
+ }}
1233
+ }}
1234
+
1235
+ updateChatInterface(episodeState) {{
1236
+ const chatMessages = document.getElementById('chat-messages');
1237
+ if (!chatMessages) return;
1238
+
1239
+ // Clear existing messages (except system message)
1240
+ const systemMessage = chatMessages.querySelector('.chat-message.system');
1241
+ chatMessages.innerHTML = '';
1242
+ if (systemMessage) {{
1243
+ chatMessages.appendChild(systemMessage);
1244
+ }}
1245
+
1246
+ // Add messages from current observation
1247
+ if (episodeState.current_observation && episodeState.current_observation.messages) {{
1248
+ episodeState.current_observation.messages.forEach(msg => {{
1249
+ this.addMessageToChat(msg.role, msg.content);
1250
+ }});
1251
+ }}
1252
+ }}
1253
+ }}
1254
+
1255
+ // Initialize the web interface when the page loads
1256
+ document.addEventListener('DOMContentLoaded', () => {{
1257
+ new OpenEnvWebInterface();
1258
+ }});
1259
+ </script>
1260
+ </body>
1261
+ </html>
1262
+ """.replace('{_generate_action_form_fields(action_fields)}', _generate_action_form_fields(action_fields))
1263
+
1264
+
1265
+ def _generate_instructions_section(metadata: Optional[EnvironmentMetadata]) -> str:
1266
+ """Generate the instructions section with environment documentation."""
1267
+ if not metadata or not metadata.readme_content:
1268
+ return ''
1269
+
1270
+ # Convert markdown to HTML (basic conversion)
1271
+ import re
1272
+ html_content = _markdown_to_html(metadata.readme_content)
1273
+
1274
+ return f'''
1275
+ <!-- Instructions Section -->
1276
+ <div class="instructions-section">
1277
+ <div class="instructions-header">
1278
+ <h3 class="instructions-title">{metadata.name}</h3>
1279
+ <button class="instructions-toggle" id="instructions-toggle">Show Instructions</button>
1280
+ </div>
1281
+ <div class="instructions-content" id="instructions-content">
1282
+ <div class="instructions-readme">
1283
+ {html_content}
1284
+ </div>
1285
+ </div>
1286
+ </div>
1287
+ '''
1288
+
1289
+
1290
+ def _extract_action_fields(action_cls: Type[Action]) -> List[Dict[str, Any]]:
1291
+ """Extract enhanced field metadata from Action class for form generation."""
1292
+ import typing
1293
+ from typing import get_origin, get_args
1294
+
1295
+ action_fields = []
1296
+ if not hasattr(action_cls, '__dataclass_fields__'):
1297
+ return action_fields
1298
+
1299
+ for field_name, field_info in action_cls.__dataclass_fields__.items():
1300
+ if field_name == 'metadata':
1301
+ continue
1302
+
1303
+ field_type = field_info.type
1304
+ field_metadata = _extract_field_metadata(field_name, field_info)
1305
+
1306
+ # Determine input type based on field type
1307
+ input_type = _determine_input_type(field_type)
1308
+
1309
+ # Check if field is required
1310
+ is_required = field_info.default is field_info.default_factory
1311
+
1312
+ action_fields.append({
1313
+ 'name': field_name,
1314
+ 'type': input_type,
1315
+ 'required': is_required,
1316
+ 'description': field_metadata.get('description', ''),
1317
+ 'default_value': field_metadata.get('default_value'),
1318
+ 'choices': field_metadata.get('choices', []),
1319
+ 'min_value': field_metadata.get('min_value'),
1320
+ 'max_value': field_metadata.get('max_value'),
1321
+ 'placeholder': field_metadata.get('placeholder', ''),
1322
+ 'help_text': field_metadata.get('help_text', ''),
1323
+ })
1324
+
1325
+ return action_fields
1326
+
1327
+
1328
+ def _extract_field_metadata(field_name: str, field_info) -> Dict[str, Any]:
1329
+ """Extract metadata from dataclass field including docstring and type hints."""
1330
+ import typing
1331
+ from typing import get_origin, get_args, Literal, Union, Optional
1332
+
1333
+ metadata = {}
1334
+
1335
+ # Extract description from field docstring or annotation
1336
+ if hasattr(field_info, 'metadata') and field_info.metadata:
1337
+ # Check for custom metadata
1338
+ for meta in field_info.metadata:
1339
+ if isinstance(meta, dict):
1340
+ metadata.update(meta)
1341
+
1342
+ # Extract type information
1343
+ field_type = field_info.type
1344
+ origin = get_origin(field_type)
1345
+
1346
+ # Handle Literal types for dropdown choices
1347
+ if origin is Literal:
1348
+ args = get_args(field_type)
1349
+ metadata['choices'] = list(args)
1350
+
1351
+ # Handle Optional types
1352
+ if origin is Union:
1353
+ args = get_args(field_type)
1354
+ if len(args) == 2 and type(None) in args:
1355
+ # This is Optional[SomeType]
1356
+ non_none_type = args[0] if args[1] is type(None) else args[1]
1357
+ metadata['optional'] = True
1358
+ # Recursively check the non-None type for choices
1359
+ if get_origin(non_none_type) is Literal:
1360
+ metadata['choices'] = list(get_args(non_none_type))
1361
+ else:
1362
+ # Regular Union type
1363
+ metadata['choices'] = [str(arg) for arg in args if arg is not type(None)]
1364
+
1365
+ # Handle numeric constraints
1366
+ if field_type in (int, float):
1367
+ # Check for common constraint patterns in field name
1368
+ if 'count' in field_name.lower() or 'num' in field_name.lower():
1369
+ metadata['min_value'] = 0
1370
+ if 'id' in field_name.lower():
1371
+ metadata['min_value'] = 0
1372
+
1373
+ # Generate placeholder text
1374
+ if 'message' in field_name.lower():
1375
+ metadata['placeholder'] = f'Enter {field_name.replace("_", " ")}...'
1376
+ elif 'code' in field_name.lower():
1377
+ metadata['placeholder'] = 'Enter Python code here...'
1378
+ elif 'tokens' in field_name.lower():
1379
+ metadata['placeholder'] = 'Enter comma-separated token IDs (e.g., 1,2,3,4,5)'
1380
+ else:
1381
+ metadata['placeholder'] = f'Enter {field_name.replace("_", " ")}...'
1382
+
1383
+ # Generate help text based on field name and type
1384
+ if 'action_id' in field_name.lower():
1385
+ metadata['help_text'] = 'The action ID to execute in the environment'
1386
+ elif 'game_name' in field_name.lower():
1387
+ metadata['help_text'] = 'Name of the game or environment'
1388
+ elif 'tokens' in field_name.lower():
1389
+ metadata['help_text'] = 'Token IDs as a comma-separated list of integers'
1390
+ elif 'code' in field_name.lower():
1391
+ metadata['help_text'] = 'Python code to execute in the environment'
1392
+ elif 'message' in field_name.lower():
1393
+ metadata['help_text'] = 'Text message to send'
1394
+
1395
+ return metadata
1396
+
1397
+
1398
+ def _determine_input_type(field_type) -> str:
1399
+ """Determine the appropriate HTML input type for a field type."""
1400
+ import typing
1401
+ from typing import get_origin, get_args, Literal, Union
1402
+
1403
+ # Handle direct types
1404
+ if field_type == str:
1405
+ return "text"
1406
+ elif field_type == int:
1407
+ return "number"
1408
+ elif field_type == float:
1409
+ return "number"
1410
+ elif field_type == bool:
1411
+ return "checkbox"
1412
+
1413
+ # Handle complex types
1414
+ origin = get_origin(field_type)
1415
+
1416
+ if origin is Literal:
1417
+ return "select"
1418
+ elif origin is Union:
1419
+ args = get_args(field_type)
1420
+ if len(args) == 2 and type(None) in args:
1421
+ # Optional type - use the non-None type
1422
+ non_none_type = args[0] if args[1] is type(None) else args[1]
1423
+ return _determine_input_type(non_none_type)
1424
+ elif all(isinstance(arg, str) for arg in args if arg is not type(None)):
1425
+ return "select"
1426
+ else:
1427
+ return "text"
1428
+ elif hasattr(field_type, '__name__') and 'Tensor' in field_type.__name__:
1429
+ return "tensor"
1430
+ else:
1431
+ return "text"
1432
+
1433
+
1434
+ def _markdown_to_html(markdown: str) -> str:
1435
+ """Convert basic markdown to HTML for README display."""
1436
+ import html
1437
+ import re
1438
+
1439
+ # Escape HTML first
1440
+ html_content = html.escape(markdown)
1441
+
1442
+ # Convert headers
1443
+ html_content = re.sub(r'^# (.*?)$', r'<h1>\1</h1>', html_content, flags=re.MULTILINE)
1444
+ html_content = re.sub(r'^## (.*?)$', r'<h2>\1</h2>', html_content, flags=re.MULTILINE)
1445
+ html_content = re.sub(r'^### (.*?)$', r'<h3>\1</h3>', html_content, flags=re.MULTILINE)
1446
+
1447
+ # Convert code blocks
1448
+ html_content = re.sub(r'```(.*?)\n(.*?)\n```', r'<pre><code>\2</code></pre>', html_content, flags=re.DOTALL)
1449
+ html_content = re.sub(r'`([^`]+)`', r'<code>\1</code>', html_content)
1450
+
1451
+ # Convert bold and italic
1452
+ html_content = re.sub(r'\*\*(.*?)\*\*', r'<strong>\1</strong>', html_content)
1453
+ html_content = re.sub(r'\*(.*?)\*', r'<em>\1</em>', html_content)
1454
+
1455
+ # Convert lists
1456
+ html_content = re.sub(r'^- (.*?)$', r'<li>\1</li>', html_content, flags=re.MULTILINE)
1457
+ html_content = re.sub(r'(<li>.*</li>)', r'<ul>\1</ul>', html_content, flags=re.DOTALL)
1458
+
1459
+ # Convert line breaks
1460
+ html_content = html_content.replace('\n', '<br>')
1461
+
1462
+ return html_content
1463
+
1464
+
1465
+ def _generate_action_interface(action_fields: List[Dict[str, Any]], is_chat_env: bool) -> str:
1466
+ """Generate either a chat interface or action form based on environment type."""
1467
+ if is_chat_env:
1468
+ return _generate_chat_interface()
1469
+ else:
1470
+ return _generate_action_form(action_fields)
1471
+
1472
+ def _generate_chat_interface() -> str:
1473
+ """Generate a chat-style interface for chat environments."""
1474
+ return '''
1475
+ <!-- Chat Interface -->
1476
+ <div class="chat-interface">
1477
+ <h3>Chat Interface</h3>
1478
+ <div class="chat-messages" id="chat-messages">
1479
+ <div class="chat-message system">
1480
+ <div class="message-role">System</div>
1481
+ <div class="message-content">Chat environment ready. Send a message to start the conversation.</div>
1482
+ </div>
1483
+ </div>
1484
+ <div class="chat-input-container">
1485
+ <div class="role-selector">
1486
+ <label for="message-role">Role:</label>
1487
+ <select id="message-role">
1488
+ <option value="user">User</option>
1489
+ <option value="assistant">Assistant</option>
1490
+ </select>
1491
+ </div>
1492
+ <div class="message-input">
1493
+ <textarea id="message-input" placeholder="Type your message here..." rows="3"></textarea>
1494
+ <button class="btn" id="send-message-btn">Send Message</button>
1495
+ </div>
1496
+ </div>
1497
+ </div>
1498
+ '''
1499
+
1500
+ def _generate_action_form(action_fields: List[Dict[str, Any]]) -> str:
1501
+ """Generate a traditional action form for non-chat environments."""
1502
+ return f'''
1503
+ <!-- Action Form -->
1504
+ <div class="action-form">
1505
+ <h3>Take Action</h3>
1506
+ <form id="action-form">
1507
+ {_generate_action_form_fields(action_fields)}
1508
+ <button type="submit" class="btn" id="step-btn">Step</button>
1509
+ </form>
1510
+ </div>
1511
+ '''
1512
+
1513
+ def _generate_action_form_fields(action_fields: List[Dict[str, Any]]) -> str:
1514
+ """Generate HTML form fields for action input with enhanced metadata."""
1515
+ if not action_fields:
1516
+ return '<p>No action fields available</p>'
1517
+
1518
+ fields_html = []
1519
+ for field in action_fields:
1520
+ field_html = _generate_single_field(field)
1521
+ fields_html.append(field_html)
1522
+
1523
+ return '\n'.join(fields_html)
1524
+
1525
+
1526
+ def _generate_single_field(field: Dict[str, Any]) -> str:
1527
+ """Generate HTML for a single form field with enhanced metadata."""
1528
+ field_name = field['name']
1529
+ field_type = field['type']
1530
+ required = field['required']
1531
+ placeholder = field.get('placeholder', '')
1532
+ help_text = field.get('help_text', '')
1533
+ choices = field.get('choices', [])
1534
+ min_value = field.get('min_value')
1535
+ max_value = field.get('max_value')
1536
+ default_value = field.get('default_value')
1537
+
1538
+ # Build label with required indicator
1539
+ label_text = field_name.replace('_', ' ').title()
1540
+ if required:
1541
+ label_text += ' <span style="color: red;">*</span>'
1542
+
1543
+ # Build input attributes
1544
+ input_attrs = []
1545
+ if required:
1546
+ input_attrs.append('required')
1547
+ if placeholder:
1548
+ input_attrs.append(f'placeholder="{placeholder}"')
1549
+ if min_value is not None:
1550
+ input_attrs.append(f'min="{min_value}"')
1551
+ if max_value is not None:
1552
+ input_attrs.append(f'max="{max_value}"')
1553
+ if default_value is not None:
1554
+ input_attrs.append(f'value="{default_value}"')
1555
+
1556
+ attrs_str = ' '.join(input_attrs)
1557
+
1558
+ if field_type == 'checkbox':
1559
+ return f'''
1560
+ <div class="form-group">
1561
+ <label>
1562
+ <input type="checkbox" name="{field_name}" value="true" {attrs_str}>
1563
+ {label_text}
1564
+ </label>
1565
+ {f'<small class="help-text">{help_text}</small>' if help_text else ''}
1566
+ </div>
1567
+ '''
1568
+
1569
+ elif field_type == 'select':
1570
+ options_html = []
1571
+ if not required:
1572
+ options_html.append(f'<option value="">-- Select {label_text} --</option>')
1573
+
1574
+ for choice in choices:
1575
+ selected = 'selected' if str(choice) == str(default_value) else ''
1576
+ options_html.append(f'<option value="{choice}" {selected}>{choice}</option>')
1577
+
1578
+ return f'''
1579
+ <div class="form-group">
1580
+ <label for="{field_name}">{label_text}:</label>
1581
+ <select name="{field_name}" id="{field_name}" {attrs_str}>
1582
+ {''.join(options_html)}
1583
+ </select>
1584
+ {f'<small class="help-text">{help_text}</small>' if help_text else ''}
1585
+ </div>
1586
+ '''
1587
+
1588
+ elif field_type == 'tensor':
1589
+ return f'''
1590
+ <div class="form-group">
1591
+ <label for="{field_name}">{label_text} (comma-separated integers):</label>
1592
+ <input type="text" name="{field_name}" id="{field_name}" {attrs_str}>
1593
+ <small class="help-text">{help_text or 'Enter token IDs as comma-separated integers (e.g., 1,2,3,4,5)'}</small>
1594
+ </div>
1595
+ '''
1596
+
1597
+ elif field_type == 'text' and ('message' in field_name.lower() or 'code' in field_name.lower()):
1598
+ return f'''
1599
+ <div class="form-group">
1600
+ <label for="{field_name}">{label_text}:</label>
1601
+ <textarea name="{field_name}" id="{field_name}" rows="3" {attrs_str}></textarea>
1602
+ {f'<small class="help-text">{help_text}</small>' if help_text else ''}
1603
+ </div>
1604
+ '''
1605
+
1606
+ else:
1607
+ return f'''
1608
+ <div class="form-group">
1609
+ <label for="{field_name}">{label_text}:</label>
1610
+ <input type="{field_type}" name="{field_name}" id="{field_name}" {attrs_str}>
1611
+ {f'<small class="help-text">{help_text}</small>' if help_text else ''}
1612
+ </div>
1613
+ '''
src/core/http_env_client.py ADDED
@@ -0,0 +1,207 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 Any, Dict, Generic, Optional, Type, TYPE_CHECKING, TypeVar
16
+
17
+ import requests
18
+
19
+ from .client_types import StepResult
20
+ from .containers.runtime import LocalDockerProvider
21
+
22
+ if TYPE_CHECKING:
23
+ from .containers.runtime import ContainerProvider
24
+
25
+ ActT = TypeVar("ActT")
26
+ ObsT = TypeVar("ObsT")
27
+ EnvClientT = TypeVar("EnvClientT", bound="HTTPEnvClient")
28
+
29
+
30
+ class HTTPEnvClient(ABC, Generic[ActT, ObsT]):
31
+ def __init__(
32
+ self,
33
+ base_url: str,
34
+ request_timeout_s: float = 15.0,
35
+ default_headers: Optional[Dict[str, str]] = None,
36
+ provider: Optional["ContainerProvider"] = None,
37
+ ):
38
+ self._base = base_url.rstrip("/")
39
+ self._timeout = float(request_timeout_s)
40
+ self._http = requests.Session()
41
+ self._headers = default_headers or {}
42
+ self._provider = provider
43
+
44
+ @classmethod
45
+ def from_docker_image(
46
+ cls: Type[EnvClientT],
47
+ image: str,
48
+ provider: Optional["ContainerProvider"] = None,
49
+ **kwargs: Any,
50
+ ) -> EnvClientT:
51
+ """
52
+ Create an environment client by spinning up a Docker container locally.
53
+
54
+ This is a development utility that:
55
+ 1. Starts a Docker container from the specified image
56
+ 2. Waits for the server to be ready
57
+ 3. Creates and returns a client instance connected to the container
58
+
59
+ Note: The container lifecycle management is left to the user or higher-level
60
+ orchestration. The container will keep running until manually stopped.
61
+
62
+ Args:
63
+ image: Docker image name to run (e.g., "echo-env:latest")
64
+ provider: Container provider to use (defaults to LocalDockerProvider)
65
+ **kwargs: Additional arguments to pass to provider.start_container()
66
+ (e.g., env_vars, port)
67
+
68
+ Returns:
69
+ An instance of the client class connected to the running container
70
+
71
+ Example:
72
+ >>> from envs.coding_env.client import CodingEnv
73
+ >>> from envs.coding_env.models import CodeAction
74
+ >>>
75
+ >>> # Create environment from image
76
+ >>> env = CodingEnv.from_docker_image("coding-env:latest")
77
+ >>>
78
+ >>> # Create environment with custom env vars
79
+ >>> env = CodingEnv.from_docker_image(
80
+ ... "coding-env:latest",
81
+ ... env_vars={"MY_VAR": "value"}
82
+ ... )
83
+ >>>
84
+ >>> # Use the environment
85
+ >>> result = env.reset()
86
+ >>> print(result.observation)
87
+ >>>
88
+ >>> step_result = env.step(CodeAction(code="print('hello')"))
89
+ >>> print(step_result.observation.stdout)
90
+ >>>
91
+ >>> # Cleanup (optional)
92
+ >>> env.close()
93
+ """
94
+
95
+ # Use default provider if none provided
96
+ if provider is None:
97
+ provider = LocalDockerProvider()
98
+
99
+ # Extract timeout_s from kwargs for wait_for_ready, with a default
100
+ timeout_s = kwargs.pop('timeout_s', 30.0)
101
+ request_timeout_s = kwargs.pop('request_timeout_s', 15.0)
102
+
103
+ # 1. Start container with optional kwargs (e.g., env_vars, port)
104
+ base_url = provider.start_container(image, **kwargs)
105
+
106
+ # 2. Wait for server to be ready with the specified timeout
107
+ provider.wait_for_ready(base_url, timeout_s=timeout_s)
108
+
109
+ # 3. Create and return client instance with provider reference and request timeout
110
+ return cls(base_url=base_url, request_timeout_s=request_timeout_s, provider=provider)
111
+
112
+ @classmethod
113
+ def from_hub(cls: Type[EnvClientT], repo_id: str, provider: Optional["ContainerProvider"] = None, **kwargs: Any) -> EnvClientT:
114
+ """
115
+ Create an environment client by pulling from a Hugging Face model hub.
116
+ """
117
+
118
+ if provider is None:
119
+ provider = LocalDockerProvider()
120
+
121
+ if "tag" in kwargs:
122
+ tag = kwargs["tag"]
123
+ else:
124
+ tag = "latest"
125
+
126
+ base_url = f"registry.hf.space/{repo_id.replace('/', '-')}:{tag}"
127
+
128
+ return cls.from_docker_image(image=base_url, provider=provider)
129
+
130
+ @abstractmethod
131
+ def _step_payload(self, action: ActT) -> dict:
132
+ """Convert an Action object to the JSON body expected by the env server."""
133
+ raise NotImplementedError
134
+
135
+ @abstractmethod
136
+ def _parse_result(self, payload: dict) -> StepResult[ObsT]:
137
+ """Convert a JSON response from the env server to StepResult[ObsT]."""
138
+ raise NotImplementedError
139
+
140
+ @abstractmethod
141
+ def _parse_state(self, payload: dict) -> Any:
142
+ """Convert a JSON response from the state endpoint to a State object."""
143
+ raise NotImplementedError
144
+
145
+ # ---------- Environment Server Interface Methods ----------
146
+ def reset(self) -> StepResult[ObsT]:
147
+ body: Dict[str, Any] = {}
148
+ # TODO: later:
149
+ # body["seed"] = seed
150
+ # body["episode_id"] = episode_id
151
+ r = self._http.post(
152
+ f"{self._base}/reset",
153
+ json=body,
154
+ headers=self._headers,
155
+ timeout=self._timeout,
156
+ )
157
+ r.raise_for_status()
158
+ return self._parse_result(r.json())
159
+
160
+ def step(self, action: ActT) -> StepResult[ObsT]:
161
+ body: Dict[str, Any] = {
162
+ "action": self._step_payload(action),
163
+ "timeout_s": int(self._timeout),
164
+ }
165
+ # TODO: later:
166
+ # body["request_id"] = str(uuid.uuid4())
167
+ # body["episode_id"] = current_episode_id
168
+ r = self._http.post(
169
+ f"{self._base}/step",
170
+ json=body,
171
+ headers=self._headers,
172
+ timeout=self._timeout,
173
+ )
174
+ r.raise_for_status()
175
+ return self._parse_result(r.json())
176
+
177
+ def state(self) -> Any:
178
+ """
179
+ Get the current environment state from the server.
180
+
181
+ Returns:
182
+ State object with environment state information (e.g., episode_id, step_count)
183
+
184
+ Example:
185
+ >>> client = EchoEnv.from_docker_image("echo-env:latest")
186
+ >>> result = client.reset()
187
+ >>> state = client.state()
188
+ >>> print(state.episode_id)
189
+ >>> print(state.step_count)
190
+ """
191
+ r = self._http.get(
192
+ f"{self._base}/state",
193
+ headers=self._headers,
194
+ timeout=self._timeout,
195
+ )
196
+ r.raise_for_status()
197
+ return self._parse_state(r.json())
198
+
199
+ def close(self) -> None:
200
+ """
201
+ Close the environment and clean up resources.
202
+
203
+ If this client was created via from_docker_image(), this will stop
204
+ and remove the associated container.
205
+ """
206
+ if self._provider is not None:
207
+ self._provider.stop_container()
src/core/pyproject.toml ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [build-system]
2
+ requires = ["setuptools>=45", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "openenv-core"
7
+ version = "0.1.0"
8
+ description = "Core components for OpenEnv - HTTP-based agentic environments"
9
+ readme = "README.md"
10
+ requires-python = ">=3.8"
11
+ license = {text = "BSD-3-Clause"}
12
+ authors = [
13
+ {name = "Meta Platforms, Inc.", email = "opensource@meta.com"}
14
+ ]
15
+ keywords = ["environment", "agent", "http", "docker", "fastapi"]
16
+
17
+ dependencies = [
18
+ "requests>=2.25.0",
19
+ "fastapi>=0.104.0",
20
+ "uvicorn>=0.24.0",
21
+ ]
22
+
23
+ [project.optional-dependencies]
24
+ dev = [
25
+ "pytest>=7.0.0",
26
+ "black>=23.0.0",
27
+ "ruff>=0.1.0",
28
+ "mypy>=1.0.0",
29
+ ]
30
+
31
+ [project.urls]
32
+ Homepage = "https://github.com/facebookresearch/OpenEnv"
33
+ Repository = "https://github.com/facebookresearch/OpenEnv"
34
+ Documentation = "https://github.com/facebookresearch/OpenEnv/blob/main/README.md"
35
+ "Bug Tracker" = "https://github.com/facebookresearch/OpenEnv/issues"
36
+
37
+ [tool.setuptools]
38
+ py-modules = ["openenv_core.__init__", "openenv_core.http_env_client", "openenv_core.client_types"]
39
+ packages = [
40
+ "openenv_core",
41
+ "openenv_core.containers",
42
+ "openenv_core.containers.runtime",
43
+ "openenv_core.env_server",
44
+ "openenv_core.tools"
45
+ ]
46
+ package-dir = {"openenv_core" = "."}
src/core/tools/__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 tools for code execution and other utilities."""
8
+
9
+ from .git_server_client import GitServerClient, RepoInfo
10
+ from .local_python_executor import PyExecutor
11
+ from .local_julia_executor import JuliaExecutor
12
+
13
+
14
+ __all__ = [
15
+ "PyExecutor",
16
+ "JuliaExecutor",
17
+ "GitServerClient",
18
+ "RepoInfo",
19
+ ]
src/core/tools/git_server_client.py ADDED
@@ -0,0 +1,362 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Git Server Client for connecting to external Gitea instance.
4
+
5
+ This module provides a lightweight client for interacting with a shared
6
+ Gitea service, optimized for task-based isolation where multiple environment
7
+ instances share the same Gitea server but have isolated workspaces.
8
+ """
9
+
10
+ import json
11
+ import os
12
+ import shutil
13
+ import subprocess
14
+ import time
15
+ from dataclasses import dataclass
16
+ from pathlib import Path
17
+ from urllib.parse import urlparse
18
+
19
+
20
+ @dataclass
21
+ class RepoInfo:
22
+ """Information about a repository."""
23
+
24
+ name: str
25
+ url: str
26
+ commit: str
27
+ clone_url: str
28
+
29
+
30
+ class GitServerClient:
31
+ """
32
+ Client for connecting to an external Gitea server.
33
+
34
+ This client is optimized for task-based isolation where:
35
+ - Multiple tasks share the same Gitea instance
36
+ - Each task has its own isolated workspace
37
+ - Fast reset() via git operations (no server restart)
38
+ - Repos are pre-migrated to Gitea once
39
+
40
+ Args:
41
+ gitea_url: URL of the Gitea server (e.g., "http://gitea:3000")
42
+ username: Gitea username for authentication
43
+ password: Gitea password for authentication
44
+ workspace_dir: Local workspace directory for cloning repos
45
+
46
+ Example:
47
+ >>> # Connect to shared Gitea (credentials from environment)
48
+ >>> import os
49
+ >>> client = GitServerClient(
50
+ ... gitea_url=os.getenv("GITEA_URL"),
51
+ ... username=os.getenv("GITEA_USERNAME"),
52
+ ... password=os.getenv("GITEA_PASSWORD")
53
+ ... )
54
+ >>> client.wait_for_ready()
55
+ >>> # Clone repo to workspace
56
+ >>> path = client.clone_to_workspace("my-repo", commit="abc123")
57
+ >>> # Fast reset to base state
58
+ >>> client.reset_workspace("my-repo", commit="abc123")
59
+ """
60
+
61
+ def __init__(
62
+ self,
63
+ gitea_url: str,
64
+ username: str,
65
+ password: str,
66
+ workspace_dir: str = "/workspace",
67
+ ):
68
+ """Initialize Git Server Client."""
69
+ self.gitea_url = gitea_url.rstrip("/")
70
+ self.username = username
71
+ self.password = password
72
+ self.workspace_dir = Path(workspace_dir)
73
+ self.is_ready = False
74
+
75
+ # Parse Gitea URL
76
+ parsed = urlparse(self.gitea_url)
77
+ self.domain = parsed.hostname or "localhost"
78
+ self.port = parsed.port or 3000
79
+
80
+ # Ensure workspace exists
81
+ os.makedirs(self.workspace_dir, exist_ok=True)
82
+
83
+ # Configure git credentials
84
+ self._configure_git()
85
+
86
+ def _configure_git(self):
87
+ """Configure git credentials for automatic authentication."""
88
+ home_dir = Path.home()
89
+
90
+ # Git config
91
+ git_config = f"""[user]
92
+ name = {self.username}
93
+ email = {self.username}@local.env
94
+ [init]
95
+ defaultBranch = main
96
+ [credential]
97
+ helper = store
98
+ """
99
+ gitconfig_path = home_dir / ".gitconfig"
100
+ gitconfig_path.write_text(git_config)
101
+
102
+ # Git credentials
103
+ git_credentials = f"http://{self.username}:{self.password}@{self.domain}:{self.port}\n"
104
+ gitcreds_path = home_dir / ".git-credentials"
105
+ gitcreds_path.write_text(git_credentials)
106
+ gitcreds_path.chmod(0o600)
107
+
108
+ def wait_for_ready(self, timeout: int = 30) -> bool:
109
+ """
110
+ Wait for Gitea server to be ready.
111
+
112
+ Args:
113
+ timeout: Maximum seconds to wait
114
+
115
+ Returns:
116
+ True if server is ready, False otherwise
117
+ """
118
+ start_time = time.time()
119
+ while time.time() - start_time < timeout:
120
+ try:
121
+ result = subprocess.run(
122
+ ["curl", "-sf", f"{self.gitea_url}/"],
123
+ capture_output=True,
124
+ timeout=5,
125
+ )
126
+ if result.returncode == 0:
127
+ self.is_ready = True
128
+ return True
129
+ except subprocess.TimeoutExpired:
130
+ pass
131
+ except Exception:
132
+ pass
133
+
134
+ time.sleep(1)
135
+
136
+ return False
137
+
138
+ def list_repositories(self) -> list[dict[str, str]]:
139
+ """
140
+ List all repositories in Gitea.
141
+
142
+ Returns:
143
+ List of repository information dictionaries
144
+ """
145
+ if not self.is_ready:
146
+ raise RuntimeError("Gitea server is not ready")
147
+
148
+ result = subprocess.run(
149
+ [
150
+ "curl",
151
+ "-s",
152
+ f"{self.gitea_url}/api/v1/user/repos",
153
+ "-u",
154
+ f"{self.username}:{self.password}",
155
+ ],
156
+ capture_output=True,
157
+ text=True,
158
+ )
159
+
160
+ if result.returncode != 0:
161
+ return []
162
+
163
+ try:
164
+ repos = json.loads(result.stdout)
165
+ return [
166
+ {
167
+ "name": repo["name"],
168
+ "full_name": repo["full_name"],
169
+ "clone_url": repo["clone_url"],
170
+ "description": repo.get("description", ""),
171
+ }
172
+ for repo in repos
173
+ ]
174
+ except (json.JSONDecodeError, KeyError):
175
+ return []
176
+
177
+ def clone_to_workspace(
178
+ self, repo_name: str, target_dir: str | None = None, commit: str = "main"
179
+ ) -> str:
180
+ """
181
+ Clone a repository to the workspace at a specific commit.
182
+
183
+ This creates a fresh clone optimized for task isolation.
184
+
185
+ Args:
186
+ repo_name: Name of repository to clone
187
+ target_dir: Target directory name (defaults to repo_name)
188
+ commit: Commit hash or branch to check out
189
+
190
+ Returns:
191
+ Path to cloned repository
192
+
193
+ Raises:
194
+ RuntimeError: If clone fails
195
+ """
196
+ if not self.is_ready:
197
+ raise RuntimeError("Gitea server is not ready")
198
+
199
+ target_dir = target_dir or repo_name
200
+ target_path = self.workspace_dir / target_dir
201
+
202
+ # Remove existing directory if present
203
+ if target_path.exists():
204
+ shutil.rmtree(target_path)
205
+
206
+ clone_url = f"{self.gitea_url}/{self.username}/{repo_name}.git"
207
+
208
+ # Clone repository
209
+ result = subprocess.run(
210
+ ["git", "clone", clone_url, str(target_path)],
211
+ capture_output=True,
212
+ text=True,
213
+ )
214
+
215
+ if result.returncode != 0:
216
+ raise RuntimeError(f"Clone failed: {result.stderr}")
217
+
218
+ # Checkout specific commit
219
+ if commit != "main":
220
+ result = subprocess.run(
221
+ ["git", "checkout", commit],
222
+ cwd=str(target_path),
223
+ capture_output=True,
224
+ text=True,
225
+ )
226
+
227
+ if result.returncode != 0:
228
+ raise RuntimeError(f"Checkout failed: {result.stderr}")
229
+
230
+ return str(target_path)
231
+
232
+ def reset_workspace(self, repo_name: str, commit: str = "main") -> bool:
233
+ """
234
+ Fast reset of workspace to base state (optimized for task resets).
235
+
236
+ This is much faster than re-cloning. It:
237
+ 1. Checks out the target commit
238
+ 2. Resets to that commit (hard)
239
+ 3. Cleans untracked files
240
+
241
+ Args:
242
+ repo_name: Name of repository (directory in workspace)
243
+ commit: Commit hash or branch to reset to
244
+
245
+ Returns:
246
+ True if reset successful
247
+
248
+ Raises:
249
+ RuntimeError: If reset fails
250
+ """
251
+ repo_path = self.workspace_dir / repo_name
252
+
253
+ if not repo_path.exists():
254
+ raise RuntimeError(f"Repository not found in workspace: {repo_name}")
255
+
256
+ # Fetch latest (in case commit is new)
257
+ subprocess.run(
258
+ ["git", "fetch", "--all"],
259
+ cwd=str(repo_path),
260
+ capture_output=True,
261
+ )
262
+
263
+ # Checkout and hard reset to commit
264
+ result = subprocess.run(
265
+ ["git", "checkout", commit],
266
+ cwd=str(repo_path),
267
+ capture_output=True,
268
+ text=True,
269
+ )
270
+
271
+ if result.returncode != 0:
272
+ raise RuntimeError(f"Checkout failed: {result.stderr}")
273
+
274
+ result = subprocess.run(
275
+ ["git", "reset", "--hard", f"origin/{commit}" if commit != "main" else commit],
276
+ cwd=str(repo_path),
277
+ capture_output=True,
278
+ text=True,
279
+ )
280
+
281
+ if result.returncode != 0:
282
+ # Try without origin/ prefix
283
+ result = subprocess.run(
284
+ ["git", "reset", "--hard", commit],
285
+ cwd=str(repo_path),
286
+ capture_output=True,
287
+ text=True,
288
+ )
289
+ if result.returncode != 0:
290
+ raise RuntimeError(f"Reset failed: {result.stderr}")
291
+
292
+ # Clean untracked files and directories
293
+ subprocess.run(
294
+ ["git", "clean", "-fdx"],
295
+ cwd=str(repo_path),
296
+ capture_output=True,
297
+ )
298
+
299
+ return True
300
+
301
+ def execute_git_command(
302
+ self, command: str, working_dir: str = ""
303
+ ) -> tuple[int, str, str]:
304
+ """
305
+ Execute a git command in the workspace.
306
+
307
+ Args:
308
+ command: Git command to execute (without 'git' prefix)
309
+ working_dir: Working directory relative to workspace
310
+
311
+ Returns:
312
+ Tuple of (exit_code, stdout, stderr)
313
+ """
314
+ work_path = (
315
+ self.workspace_dir / working_dir if working_dir else self.workspace_dir
316
+ )
317
+
318
+ if not work_path.exists():
319
+ return (1, "", f"Working directory does not exist: {work_path}")
320
+
321
+ # Split command safely
322
+ cmd_parts = ["git"] + command.split()
323
+
324
+ result = subprocess.run(
325
+ cmd_parts,
326
+ cwd=str(work_path),
327
+ capture_output=True,
328
+ text=True,
329
+ )
330
+
331
+ return (result.returncode, result.stdout, result.stderr)
332
+
333
+ def get_current_commit(self, repo_name: str) -> str:
334
+ """
335
+ Get current commit hash of a workspace repository.
336
+
337
+ Args:
338
+ repo_name: Name of repository in workspace
339
+
340
+ Returns:
341
+ Commit hash
342
+ """
343
+ repo_path = self.workspace_dir / repo_name
344
+
345
+ if not repo_path.exists():
346
+ raise RuntimeError(f"Repository not found: {repo_name}")
347
+
348
+ result = subprocess.run(
349
+ ["git", "rev-parse", "HEAD"],
350
+ cwd=str(repo_path),
351
+ capture_output=True,
352
+ text=True,
353
+ )
354
+
355
+ if result.returncode != 0:
356
+ raise RuntimeError(f"Failed to get commit: {result.stderr}")
357
+
358
+ return result.stdout.strip()
359
+
360
+ def workspace_exists(self, repo_name: str) -> bool:
361
+ """Check if a repository exists in workspace."""
362
+ return (self.workspace_dir / repo_name).exists()
src/core/tools/julia_process_pool.py ADDED
@@ -0,0 +1,509 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Yogesh Singla 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
+ Julia Process Pool for high-performance code execution.
9
+
10
+ This module provides a pool of persistent Julia processes that can be reused
11
+ for multiple code executions, eliminating the overhead of spawning new processes.
12
+
13
+ Expected speedup: 50-100x for repeated executions compared to spawning new processes.
14
+
15
+ Features:
16
+ - Persistent Julia processes (no startup overhead)
17
+ - Thread-safe process allocation
18
+ - Automatic recovery from process failures
19
+ - Proper cleanup on shutdown
20
+ - Timeout handling per execution
21
+
22
+ Example:
23
+ >>> pool = JuliaProcessPool(size=4, timeout=30)
24
+ >>> result = pool.execute("println('Hello, Julia!')")
25
+ >>> print(result.stdout) # "Hello, Julia!\n"
26
+ >>> pool.shutdown() # Clean up all processes
27
+ """
28
+
29
+ import atexit
30
+ import logging
31
+ import os
32
+ import subprocess
33
+ import threading
34
+ import time
35
+ from collections import deque
36
+ from pathlib import Path
37
+ from typing import Optional
38
+
39
+ from core.env_server.types import CodeExecResult
40
+
41
+ # Setup logging
42
+ logger = logging.getLogger(__name__)
43
+
44
+
45
+ class JuliaWorkerProcess:
46
+ """
47
+ Single Julia worker process that can execute code repeatedly.
48
+
49
+ This class manages communication with a persistent Julia REPL process
50
+ using a delimiter-based protocol.
51
+ """
52
+
53
+ # Communication protocol delimiters
54
+ START_OUTPUT = "<<<START_OUTPUT>>>"
55
+ START_ERROR = "<<<START_ERROR>>>"
56
+ EXIT_CODE_PREFIX = "<<<EXIT_CODE:"
57
+ END_EXECUTION = "<<<END_EXECUTION>>>"
58
+ END_CODE = "<<<END_CODE>>>"
59
+
60
+ def __init__(
61
+ self,
62
+ worker_id: int,
63
+ julia_path: str,
64
+ worker_script: str,
65
+ optimization_flags: bool = True,
66
+ ):
67
+ """
68
+ Initialize a Julia worker process.
69
+
70
+ Args:
71
+ worker_id: Unique identifier for this worker
72
+ julia_path: Path to Julia executable
73
+ worker_script: Path to julia_repl_worker.jl script
74
+ optimization_flags: Enable Julia optimization flags
75
+ """
76
+ self.worker_id = worker_id
77
+ self.julia_path = julia_path
78
+ self.worker_script = worker_script
79
+ self.optimization_flags = optimization_flags
80
+ self.process: Optional[subprocess.Popen] = None
81
+ self.is_busy = False
82
+ self.is_healthy = True
83
+ self.lock = threading.Lock()
84
+
85
+ # Start the worker process
86
+ self._start_process()
87
+
88
+ def _start_process(self) -> None:
89
+ """Start the Julia worker process."""
90
+ cmd = [self.julia_path]
91
+
92
+ if self.optimization_flags:
93
+ cmd.extend(
94
+ [
95
+ "--compile=min",
96
+ "--optimize=2",
97
+ "--startup-file=no",
98
+ "--history-file=no",
99
+ ]
100
+ )
101
+
102
+ cmd.append(self.worker_script)
103
+
104
+ try:
105
+ self.process = subprocess.Popen(
106
+ cmd,
107
+ stdin=subprocess.PIPE,
108
+ stdout=subprocess.PIPE,
109
+ stderr=subprocess.PIPE,
110
+ text=True,
111
+ bufsize=1, # Line buffered
112
+ )
113
+
114
+ # Wait for "Julia worker ready" message on stderr
115
+ ready_msg = self.process.stderr.readline()
116
+ if "ready" not in ready_msg.lower():
117
+ raise RuntimeError(
118
+ f"Worker {self.worker_id} did not start properly: {ready_msg}"
119
+ )
120
+
121
+ self.is_healthy = True
122
+ logger.info(f"Worker {self.worker_id} started (PID: {self.process.pid})")
123
+
124
+ except Exception as e:
125
+ self.is_healthy = False
126
+ logger.error(f"Failed to start worker {self.worker_id}: {e}")
127
+ raise
128
+
129
+ def execute(self, code: str, timeout: int = 60) -> CodeExecResult:
130
+ """
131
+ Execute Julia code in this worker process.
132
+
133
+ Args:
134
+ code: Julia code to execute
135
+ timeout: Maximum execution time in seconds
136
+
137
+ Returns:
138
+ CodeExecResult with stdout, stderr, and exit_code
139
+ """
140
+ with self.lock:
141
+ if not self.is_healthy or self.process is None:
142
+ raise RuntimeError(f"Worker {self.worker_id} is not healthy")
143
+
144
+ self.is_busy = True
145
+
146
+ try:
147
+ # Send code to worker
148
+ self.process.stdin.write(code + "\n")
149
+ self.process.stdin.write(self.END_CODE + "\n")
150
+ self.process.stdin.flush()
151
+
152
+ # Read response with timeout
153
+ start_time = time.time()
154
+ stdout_lines = []
155
+ stderr_lines = []
156
+ exit_code = -1
157
+
158
+ current_section = None # Track which section we're reading
159
+
160
+ while True:
161
+ # Check timeout
162
+ if time.time() - start_time > timeout:
163
+ logger.error(f"Worker {self.worker_id} execution timed out")
164
+ self.is_healthy = False
165
+ self._kill_process()
166
+ return CodeExecResult(
167
+ stdout="",
168
+ stderr=f"Execution timed out after {timeout} seconds",
169
+ exit_code=-1,
170
+ )
171
+
172
+ # Read line with timeout (use select for non-blocking read on Unix)
173
+ try:
174
+ line = self.process.stdout.readline()
175
+
176
+ if not line:
177
+ # EOF - process died
178
+ logger.error(f"Worker {self.worker_id} died unexpectedly")
179
+ self.is_healthy = False
180
+ return CodeExecResult(
181
+ stdout="".join(stdout_lines),
182
+ stderr="Worker process died unexpectedly",
183
+ exit_code=-1,
184
+ )
185
+
186
+ line = line.rstrip("\n")
187
+
188
+ # Check for delimiters
189
+ if line == self.START_OUTPUT:
190
+ current_section = "stdout"
191
+ continue
192
+ elif line == self.START_ERROR:
193
+ current_section = "stderr"
194
+ continue
195
+ elif line.startswith(self.EXIT_CODE_PREFIX):
196
+ # Parse exit code
197
+ exit_code_str = line[
198
+ len(self.EXIT_CODE_PREFIX) : -3
199
+ ] # Remove prefix and ">>>"
200
+ exit_code = int(exit_code_str)
201
+ continue
202
+ elif line == self.END_EXECUTION:
203
+ # Execution complete
204
+ break
205
+
206
+ # Accumulate output
207
+ if current_section == "stdout":
208
+ stdout_lines.append(line)
209
+ elif current_section == "stderr":
210
+ stderr_lines.append(line)
211
+
212
+ except Exception as e:
213
+ logger.error(f"Error reading from worker {self.worker_id}: {e}")
214
+ self.is_healthy = False
215
+ return CodeExecResult(
216
+ stdout="".join(stdout_lines),
217
+ stderr=f"Error reading from worker: {str(e)}",
218
+ exit_code=-1,
219
+ )
220
+
221
+ # Reconstruct output (add newlines back)
222
+ stdout_str = "\n".join(stdout_lines) + ("\n" if stdout_lines else "")
223
+ stderr_str = "\n".join(stderr_lines) + ("\n" if stderr_lines else "")
224
+
225
+ return CodeExecResult(
226
+ stdout=stdout_str,
227
+ stderr=stderr_str,
228
+ exit_code=exit_code,
229
+ )
230
+
231
+ finally:
232
+ self.is_busy = False
233
+
234
+ def _kill_process(self) -> None:
235
+ """Kill the worker process."""
236
+ if self.process is not None:
237
+ try:
238
+ self.process.terminate()
239
+ self.process.wait(timeout=2.0)
240
+ except:
241
+ try:
242
+ self.process.kill()
243
+ self.process.wait(timeout=1.0)
244
+ except:
245
+ pass
246
+
247
+ def shutdown(self) -> None:
248
+ """Shutdown the worker process gracefully."""
249
+ with self.lock:
250
+ if self.process is not None:
251
+ logger.info(f"Shutting down worker {self.worker_id}")
252
+ self._kill_process()
253
+ self.process = None
254
+ self.is_healthy = False
255
+
256
+
257
+ class JuliaProcessPool:
258
+ """
259
+ Pool of persistent Julia processes for high-performance code execution.
260
+
261
+ This class manages multiple Julia worker processes and distributes
262
+ code execution among them, providing significant speedup by eliminating
263
+ process startup overhead.
264
+
265
+ Thread-safe for concurrent access from multiple threads.
266
+
267
+ Example:
268
+ >>> pool = JuliaProcessPool(size=4)
269
+ >>>
270
+ >>> # Execute code
271
+ >>> result = pool.execute("println('Hello')")
272
+ >>>
273
+ >>> # Pool automatically manages workers
274
+ >>> results = [pool.execute(f"println({i})") for i in range(100)]
275
+ >>>
276
+ >>> # Cleanup when done
277
+ >>> pool.shutdown()
278
+ """
279
+
280
+ def __init__(
281
+ self,
282
+ size: int = 4,
283
+ timeout: int = 60,
284
+ julia_path: Optional[str] = None,
285
+ optimization_flags: bool = True,
286
+ auto_recover: bool = True,
287
+ ):
288
+ """
289
+ Initialize the Julia process pool.
290
+
291
+ Args:
292
+ size: Number of worker processes to create (default: 4)
293
+ timeout: Default timeout for code execution in seconds (default: 60)
294
+ julia_path: Path to Julia executable (auto-detected if None)
295
+ optimization_flags: Enable Julia optimization flags (default: True)
296
+ auto_recover: Automatically restart failed workers (default: True)
297
+
298
+ Raises:
299
+ RuntimeError: If Julia executable is not found
300
+ """
301
+ self.size = size
302
+ self.timeout = timeout
303
+ self.optimization_flags = optimization_flags
304
+ self.auto_recover = auto_recover
305
+
306
+ # Find Julia executable
307
+ if julia_path is None:
308
+ julia_path = self._find_julia_executable()
309
+
310
+ self.julia_path = julia_path
311
+
312
+ # Find worker script
313
+ self.worker_script = self._find_worker_script()
314
+
315
+ # Initialize workers
316
+ self.workers: list[JuliaWorkerProcess] = []
317
+ self.available_workers: deque[JuliaWorkerProcess] = deque()
318
+ self.pool_lock = threading.Lock()
319
+ self.shutdown_flag = False
320
+
321
+ # Create worker processes
322
+ logger.info(f"Creating Julia process pool with {size} workers")
323
+ for i in range(size):
324
+ try:
325
+ worker = JuliaWorkerProcess(
326
+ worker_id=i,
327
+ julia_path=self.julia_path,
328
+ worker_script=self.worker_script,
329
+ optimization_flags=self.optimization_flags,
330
+ )
331
+ self.workers.append(worker)
332
+ self.available_workers.append(worker)
333
+ except Exception as e:
334
+ logger.error(f"Failed to create worker {i}: {e}")
335
+ # Clean up partially created pool
336
+ self.shutdown()
337
+ raise RuntimeError(f"Failed to create worker pool: {e}")
338
+
339
+ logger.info(f"Julia process pool initialized with {len(self.workers)} workers")
340
+
341
+ # Register cleanup on exit
342
+ atexit.register(self.shutdown)
343
+
344
+ def _find_julia_executable(self) -> str:
345
+ """Find Julia executable in PATH or common locations."""
346
+ # Try PATH first
347
+ julia_path = os.popen("which julia").read().strip()
348
+ if julia_path:
349
+ return julia_path
350
+
351
+ # Try common locations
352
+ common_paths = [
353
+ os.path.expanduser("~/.juliaup/bin/julia"),
354
+ os.path.expanduser("~/.julia/bin/julia"),
355
+ "/usr/local/bin/julia",
356
+ "/usr/bin/julia",
357
+ ]
358
+
359
+ for path in common_paths:
360
+ if os.path.isfile(path) and os.access(path, os.X_OK):
361
+ return path
362
+
363
+ raise RuntimeError(
364
+ "Julia executable not found. Please install Julia: "
365
+ "https://julialang.org/downloads/"
366
+ )
367
+
368
+ def _find_worker_script(self) -> str:
369
+ """Find the julia_repl_worker.jl script."""
370
+ # Try relative to this file
371
+ this_dir = Path(__file__).parent
372
+ worker_script = this_dir / "julia_repl_worker.jl"
373
+
374
+ if worker_script.exists():
375
+ return str(worker_script)
376
+
377
+ raise RuntimeError(
378
+ f"Worker script not found at {worker_script}. "
379
+ "Please ensure julia_repl_worker.jl is in the same directory."
380
+ )
381
+
382
+ def _get_available_worker(
383
+ self, timeout: float = 30.0
384
+ ) -> Optional[JuliaWorkerProcess]:
385
+ """
386
+ Get an available worker from the pool.
387
+
388
+ Args:
389
+ timeout: Maximum time to wait for a worker (seconds)
390
+
391
+ Returns:
392
+ Available worker or None if timeout
393
+ """
394
+ start_time = time.time()
395
+
396
+ while time.time() - start_time < timeout:
397
+ with self.pool_lock:
398
+ # Try to get healthy worker
399
+ while self.available_workers:
400
+ worker = self.available_workers.popleft()
401
+
402
+ if worker.is_healthy:
403
+ return worker
404
+
405
+ # Worker is unhealthy, try to recover
406
+ if self.auto_recover and not self.shutdown_flag:
407
+ logger.warning(
408
+ f"Worker {worker.worker_id} is unhealthy, attempting recovery"
409
+ )
410
+ try:
411
+ worker.shutdown()
412
+ worker = JuliaWorkerProcess(
413
+ worker_id=worker.worker_id,
414
+ julia_path=self.julia_path,
415
+ worker_script=self.worker_script,
416
+ optimization_flags=self.optimization_flags,
417
+ )
418
+ # Update in workers list
419
+ self.workers[worker.worker_id] = worker
420
+ return worker
421
+ except Exception as e:
422
+ logger.error(
423
+ f"Failed to recover worker {worker.worker_id}: {e}"
424
+ )
425
+
426
+ # No workers available, wait a bit
427
+ time.sleep(0.1)
428
+
429
+ logger.error("Timeout waiting for available worker")
430
+ return None
431
+
432
+ def _return_worker(self, worker: JuliaWorkerProcess) -> None:
433
+ """Return a worker to the available pool."""
434
+ with self.pool_lock:
435
+ if worker.is_healthy and not self.shutdown_flag:
436
+ self.available_workers.append(worker)
437
+
438
+ def execute(self, code: str, timeout: Optional[int] = None) -> CodeExecResult:
439
+ """
440
+ Execute Julia code using an available worker from the pool.
441
+
442
+ Args:
443
+ code: Julia code to execute
444
+ timeout: Execution timeout in seconds (uses pool default if None)
445
+
446
+ Returns:
447
+ CodeExecResult with stdout, stderr, and exit_code
448
+ """
449
+ if self.shutdown_flag:
450
+ return CodeExecResult(
451
+ stdout="",
452
+ stderr="Process pool has been shut down",
453
+ exit_code=-1,
454
+ )
455
+
456
+ if timeout is None:
457
+ timeout = self.timeout
458
+
459
+ # Get available worker
460
+ worker = self._get_available_worker()
461
+
462
+ if worker is None:
463
+ return CodeExecResult(
464
+ stdout="",
465
+ stderr="No available worker (timeout waiting for worker)",
466
+ exit_code=-1,
467
+ )
468
+
469
+ try:
470
+ # Execute code in worker
471
+ result = worker.execute(code, timeout=timeout)
472
+ return result
473
+
474
+ finally:
475
+ # Return worker to pool
476
+ self._return_worker(worker)
477
+
478
+ def shutdown(self) -> None:
479
+ """
480
+ Shutdown all worker processes gracefully.
481
+
482
+ This method is automatically called on exit via atexit.
483
+ """
484
+ if self.shutdown_flag:
485
+ return
486
+
487
+ logger.info("Shutting down Julia process pool")
488
+ self.shutdown_flag = True
489
+
490
+ with self.pool_lock:
491
+ for worker in self.workers:
492
+ worker.shutdown()
493
+
494
+ self.workers.clear()
495
+ self.available_workers.clear()
496
+
497
+ logger.info("Julia process pool shutdown complete")
498
+
499
+ def __enter__(self):
500
+ """Context manager entry."""
501
+ return self
502
+
503
+ def __exit__(self, exc_type, exc_val, exc_tb):
504
+ """Context manager exit."""
505
+ self.shutdown()
506
+
507
+ def __del__(self):
508
+ """Ensure cleanup on garbage collection."""
509
+ self.shutdown()
src/core/tools/julia_repl_worker.jl ADDED
@@ -0,0 +1,159 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env julia
2
+
3
+ """
4
+ Julia REPL Worker for Process Pool
5
+
6
+ This script runs as a persistent Julia process that accepts code via stdin,
7
+ executes it, and returns results via stdout with delimiters.
8
+
9
+ Protocol:
10
+ - Input: Code block followed by "<<<END_CODE>>>"
11
+ - Output: Results with status markers:
12
+ - "<<<START_OUTPUT>>>" - stdout begins
13
+ - "<<<START_ERROR>>>" - stderr begins
14
+ - "<<<EXIT_CODE:N>>>" - exit code (0 = success, 1 = error)
15
+ - "<<<END_EXECUTION>>>" - execution complete
16
+ """
17
+
18
+ # Delimiters for communication protocol
19
+ const START_OUTPUT = "<<<START_OUTPUT>>>"
20
+ const START_ERROR = "<<<START_ERROR>>>"
21
+ const EXIT_CODE_PREFIX = "<<<EXIT_CODE:"
22
+ const END_EXECUTION = "<<<END_EXECUTION>>>"
23
+ const END_CODE = "<<<END_CODE>>>"
24
+
25
+ """
26
+ Execute code and capture output using pipes
27
+ """
28
+ function execute_code(code::String)
29
+ # Initialize return values
30
+ stdout_str = ""
31
+ stderr_str = ""
32
+ exit_code = 0
33
+
34
+ # Create pipes for output capture
35
+ out_pipe = Pipe()
36
+ err_pipe = Pipe()
37
+
38
+ try
39
+ # Execute with output redirected to pipes
40
+ redirect_stdout(out_pipe) do
41
+ redirect_stderr(err_pipe) do
42
+ try
43
+ # Execute the code using include_string which properly handles
44
+ # multiple statements including 'using' statements
45
+ include_string(Main, code)
46
+ catch e
47
+ # Execution error - write to stderr
48
+ exit_code = 1
49
+ showerror(stderr, e, catch_backtrace())
50
+ println(stderr)
51
+ end
52
+ end
53
+ end
54
+
55
+ # Close write ends to signal EOF to readers
56
+ Base.close(out_pipe.in)
57
+ Base.close(err_pipe.in)
58
+
59
+ # Read captured output
60
+ stdout_str = read(out_pipe.out, String)
61
+ stderr_str = read(err_pipe.out, String)
62
+
63
+ # Close read ends
64
+ Base.close(out_pipe.out)
65
+ Base.close(err_pipe.out)
66
+
67
+ catch e
68
+ # Worker error
69
+ exit_code = 1
70
+
71
+ # Try to close pipes
72
+ try
73
+ Base.close(out_pipe)
74
+ Base.close(err_pipe)
75
+ catch
76
+ end
77
+
78
+ stderr_str = "Worker error: " * sprint(showerror, e)
79
+ end
80
+
81
+ return (stdout_str, stderr_str, exit_code)
82
+ end
83
+
84
+ """
85
+ Main loop: read code, execute, return results
86
+ """
87
+ function main()
88
+ # Signal that worker is ready
89
+ println(stderr, "Julia worker ready")
90
+ flush(stderr)
91
+
92
+ while true
93
+ try
94
+ # Read code until END_CODE delimiter
95
+ code_lines = String[]
96
+
97
+ while true
98
+ if eof(stdin)
99
+ println(stderr, "Worker received EOF, shutting down")
100
+ return
101
+ end
102
+
103
+ line = readline(stdin)
104
+
105
+ # Check for end of code
106
+ if line == END_CODE
107
+ break
108
+ end
109
+
110
+ push!(code_lines, line)
111
+ end
112
+
113
+ # If no code received, continue
114
+ if isempty(code_lines)
115
+ # Send empty response
116
+ println(START_OUTPUT)
117
+ println(START_ERROR)
118
+ println(EXIT_CODE_PREFIX, 0, ">>>")
119
+ println(END_EXECUTION)
120
+ flush(stdout)
121
+ continue
122
+ end
123
+
124
+ code = join(code_lines, "\n")
125
+
126
+ # Execute code and capture output
127
+ (stdout_str, stderr_str, exit_code) = execute_code(code)
128
+
129
+ # Send results with delimiters
130
+ println(START_OUTPUT)
131
+ print(stdout_str)
132
+ flush(stdout)
133
+
134
+ println(START_ERROR)
135
+ print(stderr_str)
136
+ flush(stdout)
137
+
138
+ println(EXIT_CODE_PREFIX, exit_code, ">>>")
139
+ println(END_EXECUTION)
140
+ flush(stdout)
141
+
142
+ catch e
143
+ # Worker error - report and continue
144
+ println(stderr, "Worker error: ", e)
145
+ flush(stderr)
146
+
147
+ # Send error response
148
+ println(START_OUTPUT)
149
+ println(START_ERROR)
150
+ println("Worker internal error: ", e)
151
+ println(EXIT_CODE_PREFIX, 1, ">>>")
152
+ println(END_EXECUTION)
153
+ flush(stdout)
154
+ end
155
+ end
156
+ end
157
+
158
+ # Run main loop
159
+ main()
src/core/tools/local_julia_executor.py ADDED
@@ -0,0 +1,474 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Yogesh Singla 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 Julia Executor.
9
+
10
+ This module provides functionality for executing Julia code locally using
11
+ subprocess, similar to PyExecutor.
12
+
13
+ Features:
14
+ - Proper process cleanup on timeout (no zombie processes)
15
+ - Robust error handling and logging
16
+ - Process group management for complete cleanup
17
+ - Automatic retry on transient failures
18
+ - Optional process pool for 50-100x speedup on repeated executions
19
+
20
+ Performance Modes:
21
+ - Standard mode: Spawn new process for each execution (default for single executions)
22
+ - Pool mode: Reuse persistent Julia processes (recommended for repeated executions)
23
+ """
24
+
25
+ import logging
26
+ import os
27
+ import shutil
28
+ import signal
29
+ import subprocess
30
+ import tempfile
31
+ import threading
32
+ import time
33
+ from pathlib import Path
34
+ from typing import Optional
35
+
36
+ from core.env_server.types import CodeExecResult
37
+
38
+ # Try to import process pool (optional dependency)
39
+ try:
40
+ from core.tools.julia_process_pool import JuliaProcessPool
41
+
42
+ POOL_AVAILABLE = True
43
+ except ImportError:
44
+ POOL_AVAILABLE = False
45
+ JuliaProcessPool = None
46
+
47
+ # Setup logging
48
+ logger = logging.getLogger(__name__)
49
+
50
+
51
+ class JuliaExecutor:
52
+ """
53
+ Executor for running Julia code in a subprocess with robust process management.
54
+
55
+ This class provides a safe interface to execute Julia code in isolation
56
+ and capture the results including stdout, stderr, and exit code.
57
+
58
+ Features:
59
+ - Proper timeout handling without zombie processes
60
+ - Process group cleanup for nested processes
61
+ - Automatic retry on transient failures
62
+ - Comprehensive logging for debugging
63
+ - Optional process pool for 50-100x speedup on repeated executions
64
+
65
+ Example:
66
+ >>> executor = JuliaExecutor()
67
+ >>> result = executor.run('println("Hello, Julia!")')
68
+ >>> print(result.stdout) # "Hello, Julia!\n"
69
+ >>> print(result.exit_code) # 0
70
+ >>>
71
+ >>> # With tests
72
+ >>> code = '''
73
+ ... function add(a, b)
74
+ ... return a + b
75
+ ... end
76
+ ...
77
+ ... using Test
78
+ ... @test add(2, 3) == 5
79
+ ... '''
80
+ >>> result = executor.run(code)
81
+ >>> print(result.exit_code) # 0
82
+ >>>
83
+ >>> # With process pool (recommended for repeated executions)
84
+ >>> executor.enable_process_pool(size=4)
85
+ >>> for i in range(100):
86
+ ... result = executor.run(f'println({i})') # 50-100x faster!
87
+ >>> executor.shutdown_pool() # Clean up when done
88
+ """
89
+
90
+ # Class-level process pool (shared across all instances if enabled)
91
+ _shared_pool: Optional["JuliaProcessPool"] = None
92
+ _pool_lock = threading.Lock()
93
+
94
+ def __init__(
95
+ self,
96
+ timeout: int = 60,
97
+ max_retries: int = 1,
98
+ use_optimization_flags: bool = True,
99
+ use_process_pool: bool = False,
100
+ pool_size: int = 4,
101
+ ):
102
+ """
103
+ Initialize the JuliaExecutor.
104
+
105
+ Args:
106
+ timeout: Maximum execution time in seconds (default: 60)
107
+ max_retries: Number of retry attempts on transient failures (default: 1)
108
+ use_optimization_flags: Enable Julia performance flags (default: True)
109
+ use_process_pool: Enable process pool for better performance (default: False)
110
+ pool_size: Number of workers in pool if enabled (default: 4)
111
+
112
+ Raises:
113
+ RuntimeError: If Julia executable is not found in PATH
114
+ """
115
+ self.timeout = timeout
116
+ self.max_retries = max_retries
117
+ self.use_optimization_flags = use_optimization_flags
118
+ self.use_process_pool = use_process_pool
119
+ self.pool_size = pool_size
120
+
121
+ # Find Julia executable in PATH
122
+ self.julia_path = shutil.which("julia")
123
+
124
+ if not self.julia_path:
125
+ # Try common installation paths
126
+ common_paths = [
127
+ os.path.expanduser("~/.juliaup/bin/julia"),
128
+ os.path.expanduser("~/.julia/bin/julia"),
129
+ "/usr/local/bin/julia",
130
+ "/usr/bin/julia",
131
+ ]
132
+
133
+ for path in common_paths:
134
+ if os.path.isfile(path) and os.access(path, os.X_OK):
135
+ self.julia_path = path
136
+ break
137
+
138
+ if not self.julia_path:
139
+ raise RuntimeError(
140
+ "Julia executable not found in PATH or common locations. "
141
+ "Please install Julia: https://julialang.org/downloads/ "
142
+ "or ensure it's in your PATH environment variable."
143
+ )
144
+
145
+ # Build optimized Julia command with performance flags
146
+ self.base_cmd = [self.julia_path]
147
+
148
+ if self.use_optimization_flags:
149
+ # Performance optimization flags:
150
+ # --compile=min: Reduce compilation overhead (faster startup)
151
+ # --optimize=2: Medium optimization level (good balance)
152
+ # --startup-file=no: Don't load ~/.julia/config/startup.jl
153
+ # --history-file=no: Don't save REPL history
154
+ self.base_cmd.extend(
155
+ [
156
+ "--compile=min", # Minimize compilation for faster startup
157
+ "--optimize=2", # Good optimization level
158
+ "--startup-file=no", # Skip startup file
159
+ "--history-file=no", # Skip history
160
+ ]
161
+ )
162
+
163
+ logger.info("Julia optimization flags enabled for faster execution")
164
+
165
+ logger.info(f"JuliaExecutor initialized with Julia at: {self.julia_path}")
166
+ logger.info(f"Command: {' '.join(self.base_cmd)}")
167
+ logger.info(f"Timeout: {self.timeout}s, Max retries: {self.max_retries}")
168
+
169
+ # Initialize process pool if requested
170
+ if self.use_process_pool:
171
+ self.enable_process_pool(size=self.pool_size)
172
+
173
+ def _kill_process_tree(
174
+ self, proc: subprocess.Popen, script_file: Optional[str] = None
175
+ ) -> None:
176
+ """
177
+ Terminate a process and all its children.
178
+
179
+ Args:
180
+ proc: The subprocess.Popen instance to terminate
181
+ script_file: Optional script file path to kill if process is stuck
182
+ """
183
+ if proc.poll() is None: # Process is still running
184
+ try:
185
+ # Try graceful termination first
186
+ logger.warning(f"Terminating process {proc.pid} gracefully...")
187
+ proc.terminate()
188
+
189
+ # Wait up to 2 seconds for graceful termination
190
+ try:
191
+ proc.wait(timeout=2.0)
192
+ logger.info(f"Process {proc.pid} terminated gracefully")
193
+ return
194
+ except subprocess.TimeoutExpired:
195
+ logger.warning(
196
+ f"Process {proc.pid} did not terminate, forcing kill..."
197
+ )
198
+
199
+ # Force kill if still running
200
+ proc.kill()
201
+ proc.wait(timeout=2.0)
202
+ logger.info(f"Process {proc.pid} killed forcefully")
203
+
204
+ except Exception as e:
205
+ logger.error(f"Error killing process {proc.pid}: {e}")
206
+
207
+ # Last resort: try killing via process group
208
+ try:
209
+ if hasattr(os, "killpg"):
210
+ os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
211
+ logger.info(f"Killed process group for {proc.pid}")
212
+ except Exception as pg_error:
213
+ logger.error(f"Failed to kill process group: {pg_error}")
214
+
215
+ def run(self, code: str) -> CodeExecResult:
216
+ """
217
+ Execute Julia code and return the result with robust error handling.
218
+
219
+ This method provides:
220
+ - Automatic retry on transient failures
221
+ - Proper timeout handling without zombie processes
222
+ - Process group cleanup for nested processes
223
+ - Comprehensive error logging
224
+ - Optional process pool for 50-100x speedup
225
+
226
+ Args:
227
+ code: Julia code string to execute
228
+
229
+ Returns:
230
+ CodeExecResult containing stdout, stderr, and exit_code
231
+
232
+ Example:
233
+ >>> executor = JuliaExecutor()
234
+ >>> result = executor.run("x = 5 + 3\\nprintln(x)")
235
+ >>> print(result.stdout) # "8\n"
236
+ >>> print(result.exit_code) # 0
237
+ >>>
238
+ >>> # Error handling
239
+ >>> result = executor.run("1 / 0")
240
+ >>> print(result.exit_code) # 1
241
+ >>> print(result.stderr) # Contains error message
242
+ """
243
+ # Use process pool if enabled and available
244
+ if self.use_process_pool and JuliaExecutor._shared_pool is not None:
245
+ try:
246
+ return JuliaExecutor._shared_pool.execute(code, timeout=self.timeout)
247
+ except Exception as e:
248
+ logger.warning(
249
+ f"Process pool execution failed: {e}, falling back to subprocess"
250
+ )
251
+ # Fall through to standard execution
252
+
253
+ code_file = None
254
+
255
+ for attempt in range(self.max_retries + 1):
256
+ proc = None
257
+
258
+ try:
259
+ # Create temporary file for Julia code
260
+ with tempfile.NamedTemporaryFile(
261
+ mode="w", suffix=".jl", delete=False, encoding="utf-8"
262
+ ) as f:
263
+ f.write(code)
264
+ code_file = f.name
265
+
266
+ script_name = Path(code_file).name
267
+ logger.debug(
268
+ f"[Attempt {attempt + 1}/{self.max_retries + 1}] Executing Julia script: {script_name}"
269
+ )
270
+
271
+ # Start process with Popen for better control
272
+ # Use process group to ensure we can kill all child processes
273
+ start_time = time.time()
274
+
275
+ # On Unix systems, use process groups for better cleanup
276
+ kwargs = {
277
+ "stdout": subprocess.PIPE,
278
+ "stderr": subprocess.PIPE,
279
+ "text": True,
280
+ }
281
+
282
+ # Create new process group on Unix systems
283
+ if hasattr(os, "setpgrp"):
284
+ kwargs["preexec_fn"] = os.setpgrp
285
+
286
+ proc = subprocess.Popen(self.base_cmd + [code_file], **kwargs)
287
+
288
+ logger.debug(
289
+ f"Started Julia process {proc.pid} for script {script_name}"
290
+ )
291
+
292
+ # Wait for process with timeout
293
+ try:
294
+ stdout, stderr = proc.communicate(timeout=self.timeout)
295
+ exit_code = proc.returncode
296
+ elapsed = time.time() - start_time
297
+
298
+ logger.debug(
299
+ f"Julia execution completed in {elapsed:.2f}s (exit code: {exit_code})"
300
+ )
301
+
302
+ # Clean up temp file
303
+ try:
304
+ Path(code_file).unlink()
305
+ except Exception as cleanup_error:
306
+ logger.debug(
307
+ f"Could not delete temp file {code_file}: {cleanup_error}"
308
+ )
309
+
310
+ return CodeExecResult(
311
+ stdout=stdout,
312
+ stderr=stderr,
313
+ exit_code=exit_code,
314
+ )
315
+
316
+ except subprocess.TimeoutExpired:
317
+ logger.error(
318
+ f"Julia execution timed out after {self.timeout}s (attempt {attempt + 1}/{self.max_retries + 1})"
319
+ )
320
+
321
+ # CRITICAL: Kill the process AND all its children to prevent zombies
322
+ self._kill_process_tree(proc, code_file)
323
+
324
+ # If this was our last retry, return timeout error
325
+ if attempt >= self.max_retries:
326
+ logger.error(
327
+ f"Julia execution failed permanently after {self.max_retries + 1} timeout attempts"
328
+ )
329
+ return CodeExecResult(
330
+ stdout="",
331
+ stderr=f"Execution timed out after {self.timeout} seconds (tried {self.max_retries + 1} times)",
332
+ exit_code=-1,
333
+ )
334
+
335
+ # Wait before retry
336
+ logger.info(f"Waiting 1s before retry...")
337
+ time.sleep(1.0)
338
+ continue
339
+
340
+ except FileNotFoundError:
341
+ logger.error(f"Julia executable not found at {self.julia_path}")
342
+ return CodeExecResult(
343
+ stdout="",
344
+ stderr=f"Julia executable not found: {self.julia_path}",
345
+ exit_code=-1,
346
+ )
347
+
348
+ except Exception as e:
349
+ logger.error(
350
+ f"Error executing Julia code (attempt {attempt + 1}/{self.max_retries + 1}): {e}"
351
+ )
352
+
353
+ # Try to kill process if it exists
354
+ if proc is not None and proc.poll() is None:
355
+ self._kill_process_tree(proc, code_file)
356
+
357
+ # If this was our last retry, return error
358
+ if attempt >= self.max_retries:
359
+ logger.error(
360
+ f"Julia execution failed permanently after {self.max_retries + 1} attempts"
361
+ )
362
+ return CodeExecResult(
363
+ stdout="",
364
+ stderr=f"Error executing Julia code: {str(e)}",
365
+ exit_code=-1,
366
+ )
367
+
368
+ # Wait before retry
369
+ logger.info(f"Waiting 1s before retry...")
370
+ time.sleep(1.0)
371
+ continue
372
+
373
+ finally:
374
+ # Always ensure temp file is cleaned up
375
+ if code_file and Path(code_file).exists():
376
+ try:
377
+ Path(code_file).unlink()
378
+ logger.debug(f"Cleaned up temp file: {code_file}")
379
+ except Exception as cleanup_error:
380
+ logger.debug(
381
+ f"Could not delete temp file {code_file}: {cleanup_error}"
382
+ )
383
+
384
+ # Should never reach here, but just in case
385
+ return CodeExecResult(
386
+ stdout="",
387
+ stderr="Unexpected error: all retries exhausted",
388
+ exit_code=-1,
389
+ )
390
+
391
+ @classmethod
392
+ def enable_process_pool(cls, size: int = 4, timeout: int = 60) -> bool:
393
+ """
394
+ Enable the shared Julia process pool for all JuliaExecutor instances.
395
+
396
+ This provides 50-100x speedup for repeated code executions by reusing
397
+ persistent Julia processes instead of spawning new ones.
398
+
399
+ Args:
400
+ size: Number of worker processes to create (default: 4)
401
+ timeout: Default timeout for code execution in seconds (default: 60)
402
+
403
+ Returns:
404
+ True if pool was created successfully, False otherwise
405
+
406
+ Example:
407
+ >>> JuliaExecutor.enable_process_pool(size=8)
408
+ >>> executor = JuliaExecutor(use_process_pool=True)
409
+ >>> # All executors with use_process_pool=True will use the shared pool
410
+ """
411
+ if not POOL_AVAILABLE:
412
+ logger.warning(
413
+ "Process pool not available (julia_process_pool module not found)"
414
+ )
415
+ return False
416
+
417
+ with cls._pool_lock:
418
+ if cls._shared_pool is not None:
419
+ logger.warning("Process pool already enabled")
420
+ return True
421
+
422
+ try:
423
+ logger.info(f"Enabling Julia process pool with {size} workers")
424
+ cls._shared_pool = JuliaProcessPool(size=size, timeout=timeout)
425
+ logger.info("Julia process pool enabled successfully")
426
+ return True
427
+ except Exception as e:
428
+ logger.error(f"Failed to enable process pool: {e}")
429
+ return False
430
+
431
+ @classmethod
432
+ def shutdown_pool(cls) -> None:
433
+ """
434
+ Shutdown the shared Julia process pool.
435
+
436
+ This should be called when you're done with all Julia executions
437
+ to properly clean up worker processes.
438
+
439
+ Example:
440
+ >>> JuliaExecutor.enable_process_pool()
441
+ >>> # ... do work ...
442
+ >>> JuliaExecutor.shutdown_pool() # Clean up
443
+ """
444
+ with cls._pool_lock:
445
+ if cls._shared_pool is not None:
446
+ logger.info("Shutting down Julia process pool")
447
+ cls._shared_pool.shutdown()
448
+ cls._shared_pool = None
449
+ logger.info("Julia process pool shutdown complete")
450
+
451
+ @classmethod
452
+ def is_pool_enabled(cls) -> bool:
453
+ """
454
+ Check if the process pool is currently enabled.
455
+
456
+ Returns:
457
+ True if pool is enabled, False otherwise
458
+ """
459
+ with cls._pool_lock:
460
+ return cls._shared_pool is not None
461
+
462
+ def __enter__(self):
463
+ """Context manager entry."""
464
+ return self
465
+
466
+ def __exit__(self, exc_type, exc_val, exc_tb):
467
+ """Context manager exit."""
468
+ # Don't shutdown the shared pool when exiting a single executor
469
+ pass
470
+
471
+ def __del__(self):
472
+ """Cleanup on garbage collection."""
473
+ # Don't shutdown the shared pool when a single executor is deleted
474
+ pass
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/envs/julia_env/__init__.py ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Yogesh Singla 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
+ """Julia Environment - Code execution environment for RL training."""
8
+
9
+ from .julia_env_client import JuliaEnv
10
+ from .models import JuliaAction, JuliaObservation, JuliaState
11
+
12
+ __all__ = ["JuliaAction", "JuliaObservation", "JuliaState", "JuliaEnv"]
13
+
src/envs/julia_env/julia_env_client.py ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Yogesh Singla 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
+ Julia Environment HTTP Client.
9
+
10
+ This module provides the client for connecting to a Julia Environment server
11
+ over HTTP.
12
+ """
13
+
14
+ from typing import Dict
15
+
16
+ from core.client_types import StepResult
17
+ from core.http_env_client import HTTPEnvClient
18
+
19
+ from .models import JuliaAction, JuliaObservation, JuliaState
20
+
21
+
22
+ class JuliaEnv(HTTPEnvClient[JuliaAction, JuliaObservation]):
23
+ """
24
+ HTTP client for the Julia Environment.
25
+
26
+ This client connects to a JuliaEnvironment HTTP server and provides
27
+ methods to interact with it: reset(), step(), and state access.
28
+
29
+ Example:
30
+ >>> # Connect to a running server
31
+ >>> client = JuliaEnv(base_url="http://localhost:8000")
32
+ >>> result = client.reset()
33
+ >>> print(result.observation.stdout)
34
+ >>>
35
+ >>> # Execute Julia code
36
+ >>> action = JuliaAction(code='''
37
+ ... function multiply(a, b)
38
+ ... return a * b
39
+ ... end
40
+ ...
41
+ ... using Test
42
+ ... @test multiply(3, 4) == 12
43
+ ... ''')
44
+ >>> result = client.step(action)
45
+ >>> print(result.observation.tests_passed) # 1
46
+ >>> print(result.reward)
47
+
48
+ Example with Docker:
49
+ >>> # Automatically start container and connect
50
+ >>> client = JuliaEnv.from_docker_image("julia-env:latest")
51
+ >>> result = client.reset()
52
+ >>> result = client.step(JuliaAction(code="println(2 + 2)"))
53
+ >>> print(result.observation.stdout) # "4\n"
54
+ >>> client.close()
55
+ """
56
+
57
+ def _step_payload(self, action: JuliaAction) -> Dict:
58
+ """
59
+ Convert JuliaAction to JSON payload for step request.
60
+
61
+ Args:
62
+ action: JuliaAction instance
63
+
64
+ Returns:
65
+ Dictionary representation suitable for JSON encoding
66
+ """
67
+ return {
68
+ "core_code": action.core_code,
69
+ "test_code": action.test_code
70
+ }
71
+
72
+ def _parse_result(self, payload: Dict) -> StepResult[JuliaObservation]:
73
+ """
74
+ Parse server response into StepResult[JuliaObservation].
75
+
76
+ Args:
77
+ payload: JSON response from server
78
+
79
+ Returns:
80
+ StepResult with JuliaObservation
81
+ """
82
+ obs_data = payload.get("observation", {})
83
+ observation = JuliaObservation(
84
+ stdout=obs_data.get("stdout", ""),
85
+ stderr=obs_data.get("stderr", ""),
86
+ exit_code=obs_data.get("exit_code", 0),
87
+ tests_passed=obs_data.get("tests_passed", 0),
88
+ tests_failed=obs_data.get("tests_failed", 0),
89
+ code_compiles=obs_data.get("code_compiles", True),
90
+ metadata=obs_data.get("metadata", {}),
91
+ )
92
+
93
+ return StepResult[JuliaObservation](
94
+ observation=observation,
95
+ reward=payload.get("reward"),
96
+ done=payload.get("done", False),
97
+ )
98
+
99
+ def _parse_state(self, payload: Dict) -> JuliaState:
100
+ """
101
+ Parse server response into JuliaState object.
102
+
103
+ Args:
104
+ payload: JSON response from /state endpoint
105
+
106
+ Returns:
107
+ JuliaState object with episode metadata
108
+ """
109
+ return JuliaState(
110
+ episode_id=payload.get("episode_id"),
111
+ step_count=payload.get("step_count", 0),
112
+ last_exit_code=payload.get("last_exit_code", 0),
113
+ last_code_compiles=payload.get("last_code_compiles", True),
114
+ total_tests_passed=payload.get("total_tests_passed", 0),
115
+ total_tests_failed=payload.get("total_tests_failed", 0),
116
+ )
117
+
src/envs/julia_env/models.py ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Yogesh Singla 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 Julia Environment.
9
+
10
+ The Julia environment executes Julia code and provides feedback through
11
+ compilation and unit test results.
12
+ """
13
+
14
+ from dataclasses import dataclass, field
15
+ from typing import Optional
16
+
17
+ from core.env_server.types import Action, Observation, State
18
+
19
+
20
+ @dataclass(kw_only=True)
21
+ class JuliaAction(Action):
22
+ """
23
+ Action for the Julia environment - code to execute.
24
+
25
+ Attributes:
26
+ core_code: Core Julia code to execute
27
+ test_code: Test code to execute
28
+ """
29
+ core_code: str
30
+ test_code: str
31
+
32
+ @dataclass(kw_only=True)
33
+ class JuliaObservation(Observation):
34
+ """
35
+ Observation from the Julia environment - execution results.
36
+
37
+ Attributes:
38
+ stdout: Standard output from Julia execution
39
+ stderr: Standard error from Julia execution
40
+ exit_code: Exit code (0 = success, non-zero = error)
41
+ execution_time: Time taken to execute in seconds
42
+ tests_passed: Number of tests passed (if tests were run)
43
+ tests_failed: Number of tests failed (if tests were run)
44
+ code_compiles: Whether the core code compiled/executed successfully
45
+ """
46
+ stdout: str = ""
47
+ stderr: str = ""
48
+ exit_code: int = 0
49
+ tests_passed: int = 0
50
+ tests_failed: int = 0
51
+ code_compiles: bool = True
52
+
53
+
54
+ @dataclass
55
+ class JuliaState(State):
56
+ """
57
+ State for Julia environment.
58
+
59
+ Attributes:
60
+ episode_id: Unique episode identifier
61
+ step_count: Number of steps taken in episode
62
+ last_exit_code: Exit code from last execution
63
+ total_tests_passed: Cumulative tests passed in episode
64
+ total_tests_failed: Cumulative tests failed in episode
65
+ """
66
+ last_exit_code: int = 0
67
+ last_code_compiles: bool = True
68
+ total_tests_passed: int = 0
69
+ total_tests_failed: int = 0
70
+
src/envs/julia_env/server/Dockerfile ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Yogesh Singla, 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
+
11
+ # Use the standard openenv base image
12
+ ARG BASE_IMAGE=openenv-base:latest
13
+ FROM ${BASE_IMAGE}
14
+
15
+ # Install Julia using juliaup (official installer - more reliable in Docker)
16
+ RUN apt-get update && apt-get install -y \
17
+ curl \
18
+ ca-certificates \
19
+ && rm -rf /var/lib/apt/lists/*
20
+
21
+ # Install juliaup and Julia
22
+ RUN curl -fsSL https://install.julialang.org | sh -s -- --yes --default-channel 1.10
23
+
24
+ # Add Julia to PATH
25
+ ENV PATH="/root/.juliaup/bin:${PATH}"
26
+
27
+ # Verify Julia installation
28
+ RUN julia --version
29
+
30
+ # Precompile commonly used Julia packages (Test is built-in, but precompile it)
31
+ RUN julia -e 'using Test; println("Julia Test module ready")'
32
+
33
+ # Install smolagents for Python code execution utilities
34
+ RUN pip install --no-cache-dir smolagents
35
+
36
+ # Environment variable to enable Julia process pool (optional - can be set at runtime)
37
+ # Set to "1" to enable process pool, "0" to use standard execution
38
+ ENV JULIA_USE_PROCESS_POOL=1
39
+ ENV JULIA_POOL_SIZE=32
40
+
41
+ # Copy only what's needed for the Julia environment
42
+ COPY src/core/ /app/src/core/
43
+ COPY src/envs/julia_env/ /app/src/envs/julia_env/
44
+
45
+ # Environment variables for port and workers with defaults
46
+ ENV PORT=8000
47
+ ENV NUM_WORKER=4
48
+
49
+ # Health check
50
+ HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
51
+ CMD curl -f http://localhost:${PORT}/health || exit 1
52
+
53
+ # Run the FastAPI server
54
+ CMD uvicorn envs.julia_env.server.app:app --host 0.0.0.0 --port ${PORT} --workers ${NUM_WORKER}
src/envs/julia_env/server/README.md ADDED
@@ -0,0 +1,436 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Julia Environment Server
2
+
3
+ HTTP server for executing Julia code with test result tracking and reward calculation.
4
+
5
+ ## Overview
6
+
7
+ This server provides a Julia code execution environment through OpenEnv's HTTP interface. It executes Julia code, parses test results from the `Test` module, and calculates rewards based on execution success and test outcomes.
8
+
9
+ ## Features
10
+
11
+ - ✅ Execute Julia code in isolated subprocess
12
+ - ✅ Parse `Test` module output (tests passed/failed)
13
+ - ✅ Calculate rewards based on execution results
14
+ - ✅ Safety transforms for output truncation
15
+ - ✅ Docker support for reproducible execution
16
+ - ✅ Compatible with GRPO training
17
+
18
+ ## Docker Setup
19
+
20
+ ### Prerequisites
21
+
22
+ First, build the OpenEnv base image (one-time setup):
23
+
24
+ ```bash
25
+ # From OpenEnv root directory
26
+ docker build -t openenv-base:latest -f src/core/containers/images/Dockerfile .
27
+ ```
28
+
29
+ ### Build Julia Environment Image
30
+
31
+ ```bash
32
+ # From OpenEnv root directory
33
+ docker build -t julia-env:latest -f src/envs/julia_env/server/Dockerfile .
34
+ ```
35
+
36
+ ### Run the Server
37
+
38
+ ```bash
39
+ # Run in background with default settings (port 8000, 4 workers)
40
+ docker run -d -p 8000:8000 --name julia-env-server julia-env:latest
41
+
42
+ # OR run in foreground (to see logs)
43
+ docker run -p 8000:8000 --name julia-env-server julia-env:latest
44
+
45
+ # Run with custom port
46
+ docker run -d -p 9000:9000 -e PORT=9000 --name julia-env-server julia-env:latest
47
+
48
+ # Run with custom number of workers (uvicorn workers)
49
+ docker run -d -p 8000:8000 -e NUM_WORKER=8 --name julia-env-server julia-env:latest
50
+
51
+ # Run with custom Julia max workers (for process pool)
52
+ docker run -d -p 8000:8000 -e JULIA_MAX_WORKERS=32 --name julia-env-server julia-env:latest
53
+
54
+ # Run with all custom configurations
55
+ docker run -d -p 9000:9000 \
56
+ -e PORT=9000 \
57
+ -e NUM_WORKER=8 \
58
+ -e JULIA_MAX_WORKERS=32 \
59
+ --name julia-env-server julia-env:latest
60
+ ```
61
+
62
+ ### Test the Server
63
+
64
+ ```bash
65
+ # Health check
66
+ curl http://localhost:8000/health
67
+ # Expected: {"status":"healthy"}
68
+
69
+ # Check Julia version inside container
70
+ docker exec julia-env-server julia --version
71
+ # Expected: julia version 1.10.0
72
+ ```
73
+
74
+ ### Docker Management Commands
75
+
76
+ ```bash
77
+ # View logs
78
+ docker logs julia-env-server
79
+ docker logs -f julia-env-server # Follow logs
80
+
81
+ # Stop/start container
82
+ docker stop julia-env-server
83
+ docker start julia-env-server
84
+
85
+ # Remove container
86
+ docker rm -f julia-env-server
87
+
88
+ # Rebuild after code changes
89
+ docker build -t julia-env:latest -f src/envs/julia_env/server/Dockerfile .
90
+ docker rm -f julia-env-server
91
+ docker run -d -p 8000:8000 --name julia-env-server julia-env:latest
92
+
93
+ # Interactive debugging
94
+ docker exec -it julia-env-server /bin/bash
95
+ ```
96
+
97
+ ## Local Development (Without Docker)
98
+
99
+ ### Prerequisites
100
+
101
+ - Python 3.10+
102
+ - Julia 1.10.0+ installed and in PATH
103
+ - FastAPI and dependencies
104
+
105
+ ### Install Julia
106
+
107
+ **Using juliaup (recommended):**
108
+ ```bash
109
+ curl -fsSL https://install.julialang.org | sh
110
+ ```
111
+
112
+ **Or download from:** https://julialang.org/downloads/
113
+
114
+ ### Install Python Dependencies
115
+
116
+ ```bash
117
+ pip install fastapi uvicorn
118
+ ```
119
+
120
+ ### Run Server Locally
121
+
122
+ ```bash
123
+ # From OpenEnv root directory
124
+ export PYTHONPATH="${PWD}/src:${PYTHONPATH}"
125
+ python -m envs.julia_env.server.app
126
+ ```
127
+
128
+ Server will start at: http://localhost:8000
129
+
130
+ ## API Endpoints
131
+
132
+ ### Health Check
133
+ ```
134
+ GET /health
135
+ Response: {"status": "healthy"}
136
+ ```
137
+
138
+ ### Reset Environment
139
+ ```
140
+ POST /reset
141
+ Response: {
142
+ "observation": {
143
+ "stdout": "",
144
+ "stderr": "",
145
+ "exit_code": 0,
146
+ "tests_passed": 0,
147
+ "tests_failed": 0,
148
+ "reward": 0.0,
149
+ "execution_time": 0.0
150
+ }
151
+ }
152
+ ```
153
+
154
+ ### Execute Code (Step)
155
+ ```
156
+ POST /step
157
+ Body: {"code": "function add(a,b)\n a+b\nend\nusing Test\n@test add(2,3)==5"}
158
+ Response: {
159
+ "observation": {
160
+ "stdout": "Test Passed",
161
+ "stderr": "",
162
+ "exit_code": 0,
163
+ "tests_passed": 1,
164
+ "tests_failed": 0,
165
+ "reward": 1.0,
166
+ "execution_time": 0.15
167
+ },
168
+ "reward": 1.0,
169
+ "done": false
170
+ }
171
+ ```
172
+
173
+ ### Get State
174
+ ```
175
+ GET /state
176
+ Response: {
177
+ "episode_id": "uuid",
178
+ "step_count": 5,
179
+ "last_exit_code": 0,
180
+ "total_tests_passed": 10,
181
+ "total_tests_failed": 2
182
+ }
183
+ ```
184
+
185
+ ## Reward Structure
186
+
187
+ The environment calculates rewards based on:
188
+
189
+ - **Failed execution** (exit_code != 0): `-0.5`
190
+ - **Clean execution** (exit_code == 0): `+0.2`
191
+ - **Tests passed**: `+0.3 × (passed/total)`
192
+ - **Tests failed**: `-0.2 × (failed/total)`
193
+ - **All tests passed bonus**: `+0.5`
194
+
195
+ Example:
196
+ ```julia
197
+ # 3 tests pass, 1 fails → exit_code 1
198
+ reward = -0.5 # Failed execution
199
+ # Total: -0.5
200
+
201
+ # 3 tests pass, 0 fail → exit_code 0
202
+ reward = 0.2 + 0.3 × 1.0 + 0.5 = 1.0
203
+ # Total: 1.0 (perfect score!)
204
+ ```
205
+
206
+ ## Test Parsing
207
+
208
+ The environment parses Julia's `Test` module output:
209
+
210
+ ### Method 1: Error Message Pattern
211
+ ```
212
+ Some tests did not pass: 3 passed, 1 failed, 0 errored, 0 broken.
213
+ → tests_passed=3, tests_failed=1
214
+ ```
215
+
216
+ ### Method 2: Test Summary Table
217
+ ```
218
+ Test Summary: | Pass Fail Total Time
219
+ Add function Tests | 3 1 4 0.5s
220
+ → tests_passed=3, tests_failed=1
221
+ ```
222
+
223
+ ## Example Usage
224
+
225
+ ### From Python Client
226
+
227
+ ```python
228
+ from envs.julia_env import JuliaEnv, JuliaAction
229
+
230
+ # Connect to server
231
+ env = JuliaEnv(base_url="http://localhost:8000")
232
+
233
+ # Reset
234
+ result = env.reset()
235
+
236
+ # Execute Julia code with tests
237
+ code = """
238
+ function fibonacci(n)
239
+ if n <= 1
240
+ return n
241
+ end
242
+ return fibonacci(n-1) + fibonacci(n-2)
243
+ end
244
+
245
+ using Test
246
+ @test fibonacci(0) == 0
247
+ @test fibonacci(1) == 1
248
+ @test fibonacci(5) == 5
249
+ @test fibonacci(10) == 55
250
+ """
251
+
252
+ result = env.step(JuliaAction(code=code))
253
+
254
+ print(f"Exit code: {result.observation.exit_code}")
255
+ print(f"Tests passed: {result.observation.tests_passed}")
256
+ print(f"Tests failed: {result.observation.tests_failed}")
257
+ print(f"Reward: {result.reward}")
258
+
259
+ # Close connection
260
+ env.close()
261
+ ```
262
+
263
+ ### Example Script
264
+
265
+ ```bash
266
+ # From OpenEnv root
267
+ python examples/julia_simple.py
268
+ ```
269
+
270
+ ## GRPO Training Integration
271
+
272
+ This environment is designed for GRPO (Group Relative Policy Optimization) training:
273
+
274
+ ```python
275
+ # In your GRPO training loop
276
+ async def play_julia_game(game_idx, game_id, server_url, policy, tokenizer):
277
+ env = JuliaEnv(base_url=server_url)
278
+
279
+ # Generate code with LLM
280
+ prompt = format_julia_prompt(task)
281
+ responses = await policy.generate.route(prompt)
282
+ code = extract_julia_code(responses[0].text)
283
+
284
+ # Execute in environment
285
+ result = env.step(JuliaAction(code=code))
286
+
287
+ # Get reward
288
+ reward = result.observation.reward
289
+
290
+ return {
291
+ "prompt": prompt,
292
+ "response": responses[0],
293
+ "reward": reward,
294
+ "tests_passed": result.observation.tests_passed,
295
+ "tests_failed": result.observation.tests_failed
296
+ }
297
+ ```
298
+
299
+ See `examples/grpo_blackjack/` for a complete GRPO training example that can be adapted for Julia.
300
+
301
+ ## Configuration
302
+
303
+ ### Docker Environment Variables
304
+
305
+ The Docker container accepts the following environment variables:
306
+
307
+ - **`PORT`**: HTTP server port (default: `8000`)
308
+ - Controls which port the FastAPI server listens on
309
+ - Must match the port mapping in `-p` flag (e.g., `-p 9000:9000 -e PORT=9000`)
310
+
311
+ - **`NUM_WORKER`**: Number of uvicorn worker processes (default: `4`)
312
+ - Controls parallel request handling capacity
313
+ - More workers = more concurrent requests but higher memory usage
314
+ - Recommended: 2-8 workers for typical workloads
315
+
316
+ - **`JULIA_MAX_WORKERS`**: Maximum Julia process pool size (default: `16`)
317
+ - Controls maximum concurrent Julia code executions
318
+ - Higher values allow more parallel Julia executions
319
+ - Each worker consumes memory; tune based on available resources
320
+ - Recommended: 8-32 workers depending on your workload
321
+
322
+ ### Runtime Environment Variables
323
+
324
+ These can be set when running locally (non-Docker):
325
+
326
+ - `HOST`: Server host (default: 0.0.0.0)
327
+ - `JULIA_TIMEOUT`: Julia execution timeout in seconds (default: 60)
328
+
329
+ ### Dockerfile Customization
330
+
331
+ To use a different Julia version:
332
+
333
+ ```dockerfile
334
+ # In Dockerfile, change the version
335
+ RUN curl -fsSL https://install.julialang.org | sh -s -- --yes --default-channel 1.11
336
+ ```
337
+
338
+ ## Troubleshooting
339
+
340
+ ### Julia not found
341
+ ```bash
342
+ # Verify Julia is in PATH
343
+ julia --version
344
+
345
+ # In Docker, check installation
346
+ docker exec julia-env-server julia --version
347
+ ```
348
+
349
+ ### Port already in use
350
+ ```bash
351
+ # Use different port
352
+ docker run -p 8001:8000 --name julia-env-server julia-env:latest
353
+
354
+ # Update client base_url
355
+ env = JuliaEnv(base_url="http://localhost:8001")
356
+ ```
357
+
358
+ ### Container exits immediately
359
+ ```bash
360
+ # Check logs
361
+ docker logs julia-env-server
362
+
363
+ # Run in foreground to see errors
364
+ docker run -p 8000:8000 julia-env:latest
365
+ ```
366
+
367
+ ### Build failures
368
+ ```bash
369
+ # Clean build with no cache
370
+ docker build --no-cache -t julia-env:latest -f src/envs/julia_env/server/Dockerfile .
371
+
372
+ # Verbose output
373
+ docker build --progress=plain -t julia-env:latest -f src/envs/julia_env/server/Dockerfile .
374
+ ```
375
+
376
+ ## Architecture
377
+
378
+ ```
379
+ ┌─────────────────────────────────────┐
380
+ │ Python Client (HTTP) │
381
+ │ JuliaEnv │
382
+ └────────────┬────────────────────────┘
383
+ │ HTTP POST /step
384
+ │ {"code": "..."}
385
+
386
+ ┌─────────────────────────────────────┐
387
+ │ FastAPI Server │
388
+ │ app.py │
389
+ └────────────┬────────────────────────┘
390
+
391
+
392
+ ┌─────────────────────────────────────┐
393
+ │ JuliaCodeActEnv │
394
+ │ - Execute code via JuliaExecutor │
395
+ │ - Parse test results │
396
+ │ - Calculate rewards │
397
+ │ - Apply transforms │
398
+ └────────────┬────────────────────────┘
399
+
400
+
401
+ ┌─────────────────────────────────────┐
402
+ │ JuliaExecutor (subprocess) │
403
+ │ - Write code to temp file │
404
+ │ - Run: julia temp_file.jl │
405
+ │ - Capture stdout/stderr │
406
+ │ - Return results │
407
+ └─────────────────────────────────────┘
408
+ ```
409
+
410
+ ## Development
411
+
412
+ ### Running Tests
413
+
414
+ ```bash
415
+ # Unit tests
416
+ pytest tests/envs/julia_env/
417
+
418
+ # Integration test
419
+ python examples/julia_simple.py
420
+ ```
421
+
422
+ ### Code Structure
423
+
424
+ ```
425
+ server/
426
+ ├── Dockerfile # Docker build instructions
427
+ ├── README.md # This file
428
+ ├── __init__.py # Package initialization
429
+ ├── app.py # FastAPI server entry point
430
+ ├── julia_codeact_env.py # Environment implementation
431
+ └── julia_transforms.py # Output transforms
432
+ ```
433
+
434
+ ## License
435
+
436
+ BSD-style license. See LICENSE file in repository root.
src/envs/julia_env/server/__init__.py ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
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
+ """Julia Environment Server."""
8
+
src/envs/julia_env/server/app.py ADDED
@@ -0,0 +1,455 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Yogesh Singla 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 Julia Environment with concurrent execution support.
9
+
10
+ This module creates an HTTP server that exposes the JuliaCodeActEnv
11
+ over HTTP endpoints with optimized async execution for handling multiple
12
+ concurrent requests efficiently.
13
+
14
+ Features:
15
+ - Async Julia code execution to avoid blocking
16
+ - Environment pool for concurrent request handling
17
+ - Thread pool executor for CPU-bound Julia tasks
18
+ - Automatic error recovery and retry logic
19
+ - Comprehensive logging to file and console
20
+ - Worker health monitoring and auto-restart
21
+ - 10x+ performance improvement over single-threaded version
22
+
23
+ Usage:
24
+ # Development (with auto-reload):
25
+ uvicorn envs.julia_env.server.app:app --reload --host 0.0.0.0 --port 8000
26
+
27
+ # Production (with multiple workers for even better concurrency):
28
+ uvicorn envs.julia_env.server.app:app --host 0.0.0.0 --port 8000 --workers 4
29
+
30
+ # Or run directly:
31
+ python -m envs.julia_env.server.app
32
+ """
33
+
34
+ import asyncio
35
+ import logging
36
+ import os
37
+ import sys
38
+ import traceback
39
+ from concurrent.futures import ThreadPoolExecutor
40
+ from contextlib import asynccontextmanager
41
+ from dataclasses import asdict
42
+ from datetime import datetime
43
+ from logging.handlers import RotatingFileHandler
44
+ from typing import Any, Dict
45
+
46
+ from fastapi import Body, FastAPI, HTTPException, Request
47
+ from fastapi.responses import JSONResponse
48
+
49
+ from ..models import JuliaAction, JuliaObservation
50
+ from .julia_codeact_env import JuliaCodeActEnv
51
+
52
+ # Configuration
53
+ MAX_WORKERS = int(
54
+ os.getenv("JULIA_MAX_WORKERS", "8")
55
+ ) # Number of concurrent Julia executions
56
+ ENABLE_WEB = os.getenv("ENABLE_WEB_INTERFACE", "false").lower() in ("true", "1", "yes")
57
+ EXECUTION_TIMEOUT = int(os.getenv("JULIA_EXECUTION_TIMEOUT", "120")) # seconds
58
+ LOG_FILE = os.getenv("JULIA_LOG_FILE", "/tmp/run.log")
59
+ LOG_LEVEL = os.getenv("JULIA_LOG_LEVEL", "INFO")
60
+
61
+ # Global thread pool executor for CPU-bound Julia tasks
62
+ executor = None
63
+
64
+
65
+ # Setup comprehensive logging
66
+ def setup_logging():
67
+ """Configure logging to both file and console with rotation."""
68
+ logger = logging.getLogger("julia_env")
69
+ logger.setLevel(getattr(logging, LOG_LEVEL))
70
+
71
+ # Prevent duplicate handlers
72
+ if logger.handlers:
73
+ return logger
74
+
75
+ # Create formatters
76
+ detailed_formatter = logging.Formatter(
77
+ "%(asctime)s - %(name)s - [%(process)d:%(thread)d] - %(levelname)s - %(message)s",
78
+ datefmt="%Y-%m-%d %H:%M:%S",
79
+ )
80
+
81
+ # File handler with rotation (10MB max, keep 5 backup files)
82
+ try:
83
+ os.makedirs(os.path.dirname(LOG_FILE), exist_ok=True)
84
+ file_handler = RotatingFileHandler(
85
+ LOG_FILE, maxBytes=10 * 1024 * 1024, backupCount=5, encoding="utf-8" # 10MB
86
+ )
87
+ file_handler.setLevel(logging.DEBUG)
88
+ file_handler.setFormatter(detailed_formatter)
89
+ logger.addHandler(file_handler)
90
+ except Exception as e:
91
+ print(f"Warning: Could not create log file {LOG_FILE}: {e}")
92
+
93
+ # Console handler
94
+ console_handler = logging.StreamHandler(sys.stdout)
95
+ console_handler.setLevel(logging.INFO)
96
+ console_handler.setFormatter(detailed_formatter)
97
+ logger.addHandler(console_handler)
98
+
99
+ return logger
100
+
101
+
102
+ logger = setup_logging()
103
+
104
+
105
+ @asynccontextmanager
106
+ async def lifespan(app: FastAPI):
107
+ """Lifespan context manager for startup/shutdown with health monitoring"""
108
+ global executor
109
+
110
+ logger.info("=" * 80)
111
+ logger.info("Starting Julia Environment Server")
112
+ logger.info(f"Max Workers: {MAX_WORKERS}")
113
+ logger.info(f"Execution Timeout: {EXECUTION_TIMEOUT}s")
114
+ logger.info(f"Log File: {LOG_FILE}")
115
+ logger.info(f"Log Level: {LOG_LEVEL}")
116
+ logger.info("=" * 80)
117
+
118
+ # Startup: Create thread pool with error handling
119
+ try:
120
+ executor = ThreadPoolExecutor(
121
+ max_workers=MAX_WORKERS, thread_name_prefix="julia_worker"
122
+ )
123
+ logger.info(f"✅ Thread pool created with {MAX_WORKERS} workers")
124
+ logger.info(f"✅ Julia Environment Server started successfully")
125
+ print(
126
+ f"✅ Julia Environment Server started with {MAX_WORKERS} concurrent workers"
127
+ )
128
+ except Exception as e:
129
+ logger.error(f"❌ Failed to start server: {e}")
130
+ logger.error(traceback.format_exc())
131
+ raise
132
+
133
+ yield
134
+
135
+ # Shutdown: Cleanup with grace period
136
+ logger.info("Shutting down Julia Environment Server...")
137
+ try:
138
+ executor.shutdown(wait=True, cancel_futures=False)
139
+ logger.info("✅ All workers completed gracefully")
140
+ except Exception as e:
141
+ logger.error(f"Error during shutdown: {e}")
142
+
143
+ logger.info("✅ Julia Environment Server shutdown complete")
144
+ print("✅ Julia Environment Server shutdown complete")
145
+
146
+
147
+ # Create FastAPI app with lifespan management
148
+ app = FastAPI(
149
+ title="Julia Environment Server",
150
+ description="Async Julia code execution environment with concurrent request support and auto-recovery",
151
+ version="2.1.0",
152
+ lifespan=lifespan,
153
+ )
154
+
155
+
156
+ # Global exception handler for uncaught errors
157
+ @app.exception_handler(Exception)
158
+ async def global_exception_handler(request: Request, exc: Exception):
159
+ """Handle all uncaught exceptions to prevent worker crashes"""
160
+ error_id = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
161
+ logger.error(f"[ERROR-{error_id}] Uncaught exception in {request.url.path}")
162
+ logger.error(f"[ERROR-{error_id}] Request: {request.method} {request.url}")
163
+ logger.error(f"[ERROR-{error_id}] Exception: {type(exc).__name__}: {exc}")
164
+ logger.error(f"[ERROR-{error_id}] Traceback:\n{traceback.format_exc()}")
165
+
166
+ return JSONResponse(
167
+ status_code=500,
168
+ content={
169
+ "error": "Internal server error",
170
+ "type": type(exc).__name__,
171
+ "message": str(exc),
172
+ "error_id": error_id,
173
+ "timestamp": datetime.now().isoformat(),
174
+ },
175
+ )
176
+
177
+
178
+ async def execute_julia_async(
179
+ action: JuliaAction, request_id: str = None
180
+ ) -> JuliaObservation:
181
+ """
182
+ Execute Julia code asynchronously in thread pool with timeout and error recovery.
183
+
184
+ This runs the CPU-bound Julia execution in a separate thread to avoid
185
+ blocking the event loop, allowing the server to handle multiple requests
186
+ concurrently.
187
+
188
+ Features:
189
+ - Timeout protection
190
+ - Automatic retry on transient failures
191
+ - Comprehensive error logging
192
+ - Resource cleanup
193
+ """
194
+ if request_id is None:
195
+ request_id = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
196
+
197
+ loop = asyncio.get_event_loop()
198
+ max_retries = 2
199
+ retry_count = 0
200
+
201
+ logger.debug(
202
+ f"[{request_id}] Starting Julia execution (timeout: {EXECUTION_TIMEOUT}s)"
203
+ )
204
+
205
+ while retry_count <= max_retries:
206
+ env = None
207
+ try:
208
+ # Create a fresh environment instance for this request
209
+ # This ensures thread safety and allows concurrent execution
210
+ env = JuliaCodeActEnv()
211
+
212
+ # Run the blocking step() call in thread pool with timeout
213
+ observation = await asyncio.wait_for(
214
+ loop.run_in_executor(executor, env.step, action),
215
+ timeout=EXECUTION_TIMEOUT,
216
+ )
217
+
218
+ logger.debug(f"[{request_id}] Julia execution completed successfully")
219
+ logger.debug(
220
+ f"[{request_id}] Result: tests_passed={observation.tests_passed}, "
221
+ f"tests_failed={observation.tests_failed}, reward={observation.reward}"
222
+ )
223
+
224
+ return observation
225
+
226
+ except asyncio.TimeoutError:
227
+ retry_count += 1
228
+ logger.warning(
229
+ f"[{request_id}] Julia execution timeout (attempt {retry_count}/{max_retries + 1})"
230
+ )
231
+
232
+ if retry_count > max_retries:
233
+ logger.error(
234
+ f"[{request_id}] Julia execution failed after {max_retries + 1} attempts"
235
+ )
236
+ # Return a failure observation
237
+ return JuliaObservation(
238
+ stdout="",
239
+ stderr=f"Execution timeout after {EXECUTION_TIMEOUT}s",
240
+ exit_code=-1,
241
+ tests_passed=0,
242
+ tests_failed=1,
243
+ code_compiles=False,
244
+ reward=0.0,
245
+ done=True,
246
+ )
247
+
248
+ # Wait a bit before retry
249
+ await asyncio.sleep(0.5)
250
+
251
+ except Exception as e:
252
+ retry_count += 1
253
+ logger.error(
254
+ f"[{request_id}] Julia execution error (attempt {retry_count}/{max_retries + 1}): {e}"
255
+ )
256
+ logger.error(f"[{request_id}] Traceback:\n{traceback.format_exc()}")
257
+
258
+ if retry_count > max_retries:
259
+ logger.error(
260
+ f"[{request_id}] Julia execution failed permanently after {max_retries + 1} attempts"
261
+ )
262
+ # Return a failure observation
263
+ return JuliaObservation(
264
+ stdout="",
265
+ stderr=f"Execution error: {str(e)}",
266
+ exit_code=-1,
267
+ tests_passed=0,
268
+ tests_failed=1,
269
+ code_compiles=False,
270
+ reward=0.0,
271
+ done=True,
272
+ )
273
+
274
+ # Wait a bit before retry
275
+ await asyncio.sleep(0.5)
276
+
277
+ finally:
278
+ # Clean up environment resources if possible
279
+ if env is not None:
280
+ try:
281
+ # Add any cleanup needed here
282
+ del env
283
+ except Exception as cleanup_error:
284
+ logger.debug(f"[{request_id}] Cleanup warning: {cleanup_error}")
285
+
286
+
287
+ @app.post("/reset")
288
+ async def reset(request: Dict[str, Any] = Body(default={})) -> Dict[str, Any]:
289
+ """
290
+ Reset endpoint - returns initial observation.
291
+
292
+ Creates a fresh environment instance for the new episode.
293
+ """
294
+ request_id = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
295
+ logger.info(f"[{request_id}] Reset request received")
296
+
297
+ try:
298
+ # Run reset in thread pool to avoid blocking
299
+ loop = asyncio.get_event_loop()
300
+ env = JuliaCodeActEnv()
301
+ observation = await asyncio.wait_for(
302
+ loop.run_in_executor(executor, env.reset),
303
+ timeout=30.0, # Reset should be quick
304
+ )
305
+
306
+ # Serialize observation
307
+ obs_dict = asdict(observation)
308
+ reward = obs_dict.pop("reward", None)
309
+ done = obs_dict.pop("done", False)
310
+ obs_dict.pop("metadata", None)
311
+
312
+ logger.info(f"[{request_id}] Reset completed successfully")
313
+
314
+ return {
315
+ "observation": obs_dict,
316
+ "reward": reward,
317
+ "done": done,
318
+ }
319
+ except asyncio.TimeoutError:
320
+ logger.error(f"[{request_id}] Reset timeout")
321
+ raise HTTPException(status_code=504, detail="Reset operation timed out")
322
+ except Exception as e:
323
+ logger.error(f"[{request_id}] Reset error: {e}")
324
+ logger.error(traceback.format_exc())
325
+ raise HTTPException(status_code=500, detail=f"Reset failed: {str(e)}")
326
+
327
+
328
+ @app.post("/step")
329
+ async def step(request: Dict[str, Any]) -> Dict[str, Any]:
330
+ """
331
+ Step endpoint - executes Julia code and returns observation.
332
+
333
+ Runs Julia code execution asynchronously to handle multiple concurrent requests.
334
+ Each request gets its own environment instance for thread safety.
335
+ """
336
+ request_id = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
337
+
338
+ try:
339
+ action_data = request.get("action", {})
340
+ if not action_data:
341
+ logger.warning(f"[{request_id}] Step request with empty action")
342
+ raise HTTPException(status_code=400, detail="Action data is required")
343
+
344
+ # Deserialize action
345
+ metadata = action_data.pop("metadata", {})
346
+ action = JuliaAction(**action_data)
347
+ action.metadata = metadata
348
+
349
+ logger.info(f"[{request_id}] Step request received")
350
+ logger.debug(
351
+ f"[{request_id}] Action: core_code_length={len(action.core_code) if action.core_code else 0}, "
352
+ f"test_code_length={len(action.test_code) if action.test_code else 0}"
353
+ )
354
+
355
+ # Execute Julia code asynchronously with timeout and retry
356
+ observation = await execute_julia_async(action, request_id)
357
+
358
+ # Serialize observation
359
+ obs_dict = asdict(observation)
360
+ reward = obs_dict.pop("reward", None)
361
+ done = obs_dict.pop("done", False)
362
+ obs_dict.pop("metadata", None)
363
+
364
+ logger.info(
365
+ f"[{request_id}] Step completed - reward={reward}, "
366
+ f"tests_passed={observation.tests_passed}, tests_failed={observation.tests_failed}"
367
+ )
368
+
369
+ return {
370
+ "observation": obs_dict,
371
+ "reward": reward,
372
+ "done": done,
373
+ }
374
+
375
+ except HTTPException:
376
+ raise
377
+ except Exception as e:
378
+ logger.error(f"[{request_id}] Step endpoint error: {e}")
379
+ logger.error(f"[{request_id}] Traceback:\n{traceback.format_exc()}")
380
+ raise HTTPException(status_code=500, detail=f"Step execution failed: {str(e)}")
381
+
382
+
383
+ @app.get("/state")
384
+ async def get_state() -> Dict[str, Any]:
385
+ """
386
+ State endpoint - returns environment metadata and server health.
387
+
388
+ Note: Since each request creates a fresh environment, this returns
389
+ general server state rather than specific episode state.
390
+ """
391
+ try:
392
+ import psutil
393
+
394
+ process = psutil.Process()
395
+ memory_info = process.memory_info()
396
+
397
+ return {
398
+ "max_workers": MAX_WORKERS,
399
+ "executor_type": "ThreadPoolExecutor",
400
+ "status": "ready",
401
+ "timeout": EXECUTION_TIMEOUT,
402
+ "log_file": LOG_FILE,
403
+ "memory_mb": memory_info.rss / 1024 / 1024,
404
+ "threads": len(process.threads()),
405
+ }
406
+ except ImportError:
407
+ # psutil not available, return basic info
408
+ return {
409
+ "max_workers": MAX_WORKERS,
410
+ "executor_type": "ThreadPoolExecutor",
411
+ "status": "ready",
412
+ "timeout": EXECUTION_TIMEOUT,
413
+ "log_file": LOG_FILE,
414
+ }
415
+ except Exception as e:
416
+ logger.warning(f"Could not get full state info: {e}")
417
+ return {
418
+ "max_workers": MAX_WORKERS,
419
+ "executor_type": "ThreadPoolExecutor",
420
+ "status": "ready",
421
+ }
422
+
423
+
424
+ @app.get("/health")
425
+ async def health() -> Dict[str, str]:
426
+ """
427
+ Health check endpoint.
428
+
429
+ Returns healthy status if the server is operational and can accept requests.
430
+ """
431
+ try:
432
+ # Quick health check - verify executor is available
433
+ if executor is None:
434
+ logger.error("Health check failed: executor not initialized")
435
+ raise HTTPException(status_code=503, detail="Service not ready")
436
+
437
+ return {
438
+ "status": "healthy",
439
+ "workers": str(MAX_WORKERS),
440
+ "timeout": str(EXECUTION_TIMEOUT),
441
+ "timestamp": datetime.now().isoformat(),
442
+ }
443
+ except HTTPException:
444
+ raise
445
+ except Exception as e:
446
+ logger.error(f"Health check error: {e}")
447
+ raise HTTPException(status_code=503, detail="Health check failed")
448
+
449
+
450
+ if __name__ == "__main__":
451
+ import uvicorn
452
+
453
+ # Run with uvicorn
454
+ # Use multiple workers for even better concurrency
455
+ uvicorn.run(app, host="0.0.0.0", port=8000, log_level="info")
src/envs/julia_env/server/julia_codeact_env.py ADDED
@@ -0,0 +1,276 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Julia Code Action Environment.
3
+
4
+ This environment mirrors the PythonCodeActEnv but runs Julia code instead.
5
+ It executes Julia code using JuliaExecutor, captures output,
6
+ tracks the last exit code, and returns a JuliaObservation.
7
+ """
8
+
9
+ import re
10
+ import uuid
11
+
12
+ from core.env_server import Environment
13
+ from core.tools import JuliaExecutor
14
+ from ..models import JuliaAction, JuliaObservation, JuliaState
15
+ from .julia_transforms import create_safe_julia_transform
16
+
17
+
18
+ class JuliaCodeActEnv(Environment):
19
+ """
20
+ Julia Code Action Environment for executing code and tracking state.
21
+
22
+ This environment executes Julia code submitted as CodeAction during step,
23
+ maintains the last exit code in its state, and returns results wrapped
24
+ in CodeObservation.
25
+
26
+ Example:
27
+ >>> env = JuliaCodeActEnv()
28
+ >>> obs = env.reset()
29
+ >>> action = CodeAction(code='println("Hello, Julia!")')
30
+ >>> obs = env.step(action)
31
+ >>> print(obs.stdout) # "Hello, Julia!\n"
32
+ >>> print(obs.exit_code) # 0
33
+ >>> print(env.state.last_exit_code) # 0
34
+ """
35
+
36
+ def __init__(self):
37
+ """Initialize the Julia Code Act Environment."""
38
+ self._executor = JuliaExecutor()
39
+ self._state = JuliaState()
40
+ self.transform = create_safe_julia_transform()
41
+
42
+ def reset(self) -> JuliaObservation:
43
+ """
44
+ Reset environment for a fresh Julia execution session.
45
+ Returns an empty JuliaObservation with exit_code=0.
46
+ """
47
+ self._state = JuliaState(episode_id=str(uuid.uuid4()), step_count=0)
48
+ self._state.last_exit_code = 0
49
+ self._state.last_code_compiles = True
50
+ self._executor = JuliaExecutor()
51
+
52
+ observation = JuliaObservation(
53
+ stdout="",
54
+ stderr="",
55
+ exit_code=0,
56
+ reward=0.0,
57
+ metadata={"core_code": "", "test_code": ""},
58
+ tests_passed=0,
59
+ tests_failed=0,
60
+ code_compiles=True,
61
+ )
62
+
63
+ observation = self._apply_transform(observation)
64
+ return observation
65
+
66
+ def step(self, action: JuliaAction) -> JuliaObservation:
67
+ """
68
+ Execute Julia code and return the result as JuliaObservation.
69
+
70
+ Optimized single-pass execution:
71
+ - Runs core_code + test_code together
72
+ - Infers compilation status from combined execution
73
+ - 2x faster than double execution
74
+ """
75
+ if not isinstance(action, JuliaAction):
76
+ raise ValueError(f"Expected JuliaAction, got {type(action)}")
77
+
78
+ # Single execution: Run core_code + test_code together
79
+ combined_code = action.core_code + "\n\n" + action.test_code
80
+ full_result = self._executor.run(combined_code)
81
+
82
+ # Parse test results from execution output
83
+ tests_passed, tests_failed = self._parse_test_results(
84
+ full_result.stdout, full_result.stderr
85
+ )
86
+
87
+ # Infer compilation status from execution
88
+ # If tests ran, code compiled successfully
89
+ # If exit_code != 0 and no tests ran, code didn't compile
90
+ code_compiles = (
91
+ full_result.exit_code == 0 # Clean execution
92
+ or tests_passed > 0 # Some tests passed (code must have compiled)
93
+ or tests_failed > 0 # Some tests failed (code compiled but tests failed)
94
+ )
95
+
96
+ # If no tests detected and non-zero exit, check for compilation errors
97
+ if not code_compiles and tests_passed == 0 and tests_failed == 0:
98
+ # Check stderr for compilation errors
99
+ stderr_lower = full_result.stderr.lower()
100
+ if any(
101
+ err in stderr_lower
102
+ for err in ["error", "syntax", "undefined", "loadError"]
103
+ ):
104
+ code_compiles = False
105
+ else:
106
+ # If no clear compilation error, assume it compiled
107
+ code_compiles = True
108
+
109
+ # Calculate reward based on compilation and test results
110
+ reward = self._calculate_reward(code_compiles, tests_passed, tests_failed)
111
+
112
+ # Update environment state
113
+ self._state.step_count += 1
114
+ self._state.last_exit_code = full_result.exit_code
115
+ self._state.last_code_compiles = code_compiles
116
+ self._state.total_tests_passed = tests_passed
117
+ self._state.total_tests_failed = tests_failed
118
+
119
+ # Build observation
120
+ observation = JuliaObservation(
121
+ stdout=full_result.stdout,
122
+ stderr=full_result.stderr,
123
+ exit_code=full_result.exit_code,
124
+ reward=reward,
125
+ metadata={"core_code": action.core_code, "test_code": action.test_code},
126
+ tests_passed=tests_passed,
127
+ tests_failed=tests_failed,
128
+ code_compiles=code_compiles,
129
+ )
130
+
131
+ # Apply safety and quality transforms
132
+ observation = self._apply_transform(observation)
133
+
134
+ return observation
135
+
136
+ def _parse_test_results(self, stdout: str, stderr: str) -> tuple[int, int]:
137
+ """
138
+ Parse Julia test output to count passed/failed tests.
139
+
140
+ Julia's Test module outputs results like:
141
+ "Test Summary: | Pass Fail Total Time"
142
+ "Add function Tests | 1 1 2 1.5s"
143
+
144
+ Also checks error messages:
145
+ "Some tests did not pass: 1 passed, 1 failed, 0 errored, 0 broken."
146
+
147
+ Args:
148
+ stdout: Standard output from Julia execution
149
+ stderr: Standard error from Julia execution
150
+
151
+ Returns:
152
+ Tuple of (tests_passed, tests_failed)
153
+ """
154
+ # Combine stdout and stderr for analysis
155
+ passed = 0
156
+ failed = 0
157
+ output = stdout + "\n" + stderr
158
+
159
+ # Method 1: Look for "Some tests did not pass" error message
160
+ # Pattern: "Some tests did not pass: X passed, Y failed, Z errored, W broken."
161
+ error_pattern = r"Some tests did not pass:\s*(\d+)\s+passed,\s*(\d+)\s+failed,\s*(\d+)\s+errored"
162
+ match = re.search(error_pattern, output)
163
+
164
+ if match:
165
+ passed = int(match.group(1))
166
+ failed = int(match.group(2))
167
+ errored = int(match.group(3))
168
+ return passed, failed + errored # Treat errors as failures
169
+
170
+ # Method 2: Look for Test Summary table
171
+ # Multiple possible formats:
172
+ # All pass: "Test Summary: | Pass Total Time"
173
+ # "My Tests | 3 3 0.5s"
174
+ # Some fail: "Test Summary: | Pass Fail Total Time"
175
+ # "My Tests | 2 1 3 0.5s"
176
+ # All error: "Test Summary: | Error Total Time"
177
+ # "My Tests | 3 3 0.9s"
178
+ # Mixed: "Test Summary: | Pass Fail Error Total Time"
179
+ # "My Tests | 1 1 1 3 0.5s"
180
+ summary_lines = output.split("\n")
181
+ for i, line in enumerate(summary_lines):
182
+ if "Test Summary:" in line and i + 1 < len(summary_lines):
183
+ header_line = line
184
+ next_line = summary_lines[i + 1]
185
+
186
+ # Determine which columns are present
187
+ has_pass = "Pass" in header_line
188
+ has_fail = "Fail" in header_line
189
+ has_error = "Error" in header_line
190
+
191
+ # Extract all numbers from the line
192
+ all_numbers = re.findall(r"\d+", next_line)
193
+ if not all_numbers:
194
+ continue
195
+
196
+ # Last number is always Total, second to last is Time (skip it)
197
+ # Extract based on which columns exist
198
+ if has_pass and has_fail and has_error:
199
+ # Pass Fail Error Total Time
200
+ if len(all_numbers) >= 5:
201
+ passed = int(all_numbers[0])
202
+ failed = int(all_numbers[1]) + int(
203
+ all_numbers[2]
204
+ ) # Fail + Error
205
+ return passed, failed
206
+ elif has_pass and has_fail:
207
+ # Pass Fail Total Time
208
+ if len(all_numbers) >= 4:
209
+ passed = int(all_numbers[0])
210
+ failed = int(all_numbers[1])
211
+ return passed, failed
212
+ elif has_pass and has_error:
213
+ # Pass Error Total Time
214
+ if len(all_numbers) >= 4:
215
+ passed = int(all_numbers[0])
216
+ failed = int(all_numbers[1]) # Treat errors as failures
217
+ return passed, failed
218
+ elif has_fail and has_error:
219
+ # Fail Error Total Time (no passes)
220
+ if len(all_numbers) >= 4:
221
+ passed = 0
222
+ failed = int(all_numbers[0]) + int(all_numbers[1])
223
+ return passed, failed
224
+ elif has_pass:
225
+ # Pass Total Time (no failures/errors)
226
+ if len(all_numbers) >= 3:
227
+ passed = int(all_numbers[0])
228
+ failed = 0
229
+ return passed, failed
230
+ elif has_error:
231
+ # Error Total Time (all errors, no passes)
232
+ if len(all_numbers) >= 3:
233
+ passed = 0
234
+ failed = int(all_numbers[0]) # Treat all errors as failures
235
+ return passed, failed
236
+ elif has_fail:
237
+ # Fail Total Time (all failures, no passes)
238
+ if len(all_numbers) >= 3:
239
+ passed = 0
240
+ failed = int(all_numbers[0])
241
+ return passed, failed
242
+
243
+ return passed, failed
244
+
245
+ def _calculate_reward(
246
+ self, code_compiles: bool, tests_passed: int, tests_failed: int
247
+ ) -> int:
248
+ """
249
+ Optimized integer reward for Julia GRPO.
250
+ Strong signal shaping: rewards correctness, penalizes instability,
251
+ and gives higher incentive for near-perfect results.
252
+ """
253
+
254
+ # Code doesn't compile — immediate strong penalty
255
+ if not code_compiles:
256
+ return -3
257
+
258
+ reward = 1
259
+
260
+ reward += 3 * tests_passed - 1 * tests_failed
261
+
262
+ if tests_failed == 0 and tests_passed > 0:
263
+ reward += 2
264
+
265
+ return reward
266
+
267
+ def _apply_transform(self, observation: JuliaObservation) -> JuliaObservation:
268
+ """Apply safety and quality transforms to observation."""
269
+ if self.transform:
270
+ observation = self.transform(observation)
271
+ return observation
272
+
273
+ @property
274
+ def state(self) -> JuliaState:
275
+ """Return current environment state."""
276
+ return self._state
src/envs/julia_env/server/julia_transforms.py ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ envs/julia_env/julia_transforms.py
3
+ --------------------------------
4
+ Safety and quality transforms for Julia code.
5
+ """
6
+
7
+ import re
8
+ from core.env_server.base_transforms import CompositeTransform
9
+ from core.env_server.interfaces import Transform
10
+ from ..models import JuliaObservation
11
+
12
+
13
+ # -------------------------
14
+ # Safety Transform
15
+ # -------------------------
16
+ class JuliaSafetyTransform(Transform):
17
+ """Detects dangerous Julia operations and penalizes them with a negative reward."""
18
+
19
+ def __init__(self, penalty: float = -3.0):
20
+ self.penalty = penalty
21
+ self.dangerous_patterns = [
22
+ r"run\(",
23
+ r"read\(",
24
+ r"write\(",
25
+ r"unsafe_",
26
+ r"ccall\(",
27
+ r"Base\.exit",
28
+ r"Base\.kill",
29
+ r"rm\(", # file deletion
30
+ r"download\(" # downloading
31
+ ]
32
+
33
+ def __call__(self, observation):
34
+ # Only act on JuliaObservation objects
35
+ if not isinstance(observation, JuliaObservation):
36
+ return observation
37
+
38
+ # Extract last executed code from metadata
39
+ code = observation.metadata.get("last_code", "") if observation.metadata else ""
40
+
41
+ for pattern in self.dangerous_patterns:
42
+ if re.search(pattern, code):
43
+ # Apply penalty and record violation
44
+ observation.reward = (observation.reward or 0.0) + self.penalty
45
+ observation.metadata = observation.metadata or {}
46
+ observation.metadata["safety_violation"] = pattern
47
+ return observation
48
+
49
+ # Safe code gets neutral reward
50
+ observation.reward = observation.reward or 0.0
51
+ return observation
52
+
53
+
54
+ # -------------------------
55
+ # Quality Transform
56
+ # -------------------------
57
+ class JuliaQualityTransform(Transform):
58
+ """Evaluates and rewards Julia code quality."""
59
+
60
+ def __init__(self, concise_bonus=1, max_length_threshold=120):
61
+ self.concise_bonus = concise_bonus
62
+ self.max_length_threshold = max_length_threshold
63
+
64
+ def __call__(self, observation):
65
+ # Only act on JuliaObservation objects
66
+ if not isinstance(observation, JuliaObservation):
67
+ return observation
68
+
69
+ code = observation.metadata.get("last_code", "") if observation.metadata else ""
70
+ reward = observation.reward or 0.0
71
+
72
+ # Reward concise code
73
+ if len(code.strip()) <= self.max_length_threshold:
74
+ reward += self.concise_bonus
75
+ else:
76
+ reward -= 0.1 # slight penalty for verbosity
77
+
78
+ observation.reward = reward
79
+ return observation
80
+
81
+
82
+ # -------------------------
83
+ # Composite Transform
84
+ # -------------------------
85
+ def create_safe_julia_transform():
86
+ """Combines safety and quality transforms into one pipeline."""
87
+ return CompositeTransform([JuliaSafetyTransform(), JuliaQualityTransform()])