Spaces:
Sleeping
Sleeping
Upload folder using huggingface_hub
Browse files- Dockerfile +20 -0
- README.md +34 -5
- src/core/README.md +180 -0
- src/core/__init__.py +19 -0
- src/core/client_types.py +22 -0
- src/core/containers/__init__.py +7 -0
- src/core/containers/images/Dockerfile +47 -0
- src/core/containers/images/README.md +92 -0
- src/core/containers/runtime/__init__.py +15 -0
- src/core/containers/runtime/providers.py +359 -0
- src/core/containers/test_local_docker_provider.py +258 -0
- src/core/env_server/__init__.py +35 -0
- src/core/env_server/base_transforms.py +29 -0
- src/core/env_server/http_server.py +233 -0
- src/core/env_server/interfaces.py +118 -0
- src/core/env_server/types.py +57 -0
- src/core/env_server/web_interface.py +1613 -0
- src/core/http_env_client.py +207 -0
- src/core/pyproject.toml +46 -0
- src/core/tools/__init__.py +19 -0
- src/core/tools/git_server_client.py +362 -0
- src/core/tools/julia_process_pool.py +509 -0
- src/core/tools/julia_repl_worker.jl +159 -0
- src/core/tools/local_julia_executor.py +474 -0
- src/core/tools/local_python_executor.py +105 -0
- src/envs/julia_env/__init__.py +13 -0
- src/envs/julia_env/julia_env_client.py +117 -0
- src/envs/julia_env/models.py +70 -0
- src/envs/julia_env/server/Dockerfile +54 -0
- src/envs/julia_env/server/README.md +436 -0
- src/envs/julia_env/server/__init__.py +8 -0
- src/envs/julia_env/server/app.py +455 -0
- src/envs/julia_env/server/julia_codeact_env.py +276 -0
- src/envs/julia_env/server/julia_transforms.py +87 -0
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:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
| 7 |
pinned: false
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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()])
|