Spaces:
Runtime error
Runtime error
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 +293 -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 +203 -0
- src/core/pyproject.toml +46 -0
- src/core/tools/__init__.py +16 -0
- src/core/tools/git_server_client.py +362 -0
- src/core/tools/local_python_executor.py +105 -0
- src/envs/android_env/README.md +687 -0
- src/envs/android_env/__init__.py +21 -0
- src/envs/android_env/client.py +140 -0
- src/envs/android_env/docker-compose.hpc.yml +44 -0
- src/envs/android_env/docker-compose.yml +95 -0
- src/envs/android_env/examples/tasks/README.md +132 -0
- src/envs/android_env/examples/tasks/calculator_basic.textproto +62 -0
- src/envs/android_env/models.py +94 -0
- src/envs/android_env/server/Dockerfile +121 -0
- src/envs/android_env/server/__init__.py +7 -0
- src/envs/android_env/server/android_environment.py +408 -0
- src/envs/android_env/server/app.py +108 -0
- src/envs/android_env/server/emulator_pool.py +314 -0
- src/envs/android_env/server/gestures.py +256 -0
- src/envs/android_env/server/requirements.txt +12 -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/android_env/ /app/src/envs/android_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.android_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: Android_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 |
+
# Android_env Environment Server
|
| 15 |
+
|
| 16 |
+
FastAPI server for android_env environment powered by Meta's OpenEnv.
|
| 17 |
+
|
| 18 |
+
## About
|
| 19 |
+
|
| 20 |
+
This Space provides a containerized environment for android_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/facebookresearch/OpenEnv
|
| 179 |
+
- **Documentation**: https://github.com/facebookresearch/OpenEnv/blob/main/README.md
|
| 180 |
+
- **Bug Tracker**: https://github.com/facebookresearch/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,293 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Copyright (c) Meta Platforms, Inc. and affiliates.
|
| 2 |
+
# All rights reserved.
|
| 3 |
+
#
|
| 4 |
+
# This source code is licensed under the BSD-style license found in the
|
| 5 |
+
# LICENSE file in the root directory of this source tree.
|
| 6 |
+
|
| 7 |
+
"""
|
| 8 |
+
Container provider abstractions for running environment servers.
|
| 9 |
+
|
| 10 |
+
This module provides a pluggable architecture for different container providers
|
| 11 |
+
(local Docker, Kubernetes, cloud providers, etc.) to be used with HTTPEnvClient.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
from abc import ABC, abstractmethod
|
| 17 |
+
from typing import Any, Dict, Optional
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class ContainerProvider(ABC):
|
| 21 |
+
"""
|
| 22 |
+
Abstract base class for container providers.
|
| 23 |
+
|
| 24 |
+
Providers implement this interface to support different container platforms:
|
| 25 |
+
- LocalDockerProvider: Runs containers on local Docker daemon
|
| 26 |
+
- KubernetesProvider: Runs containers in Kubernetes cluster
|
| 27 |
+
- FargateProvider: Runs containers on AWS Fargate
|
| 28 |
+
- CloudRunProvider: Runs containers on Google Cloud Run
|
| 29 |
+
|
| 30 |
+
The provider manages a single container lifecycle and provides the base URL
|
| 31 |
+
for connecting to it.
|
| 32 |
+
|
| 33 |
+
Example:
|
| 34 |
+
>>> provider = LocalDockerProvider()
|
| 35 |
+
>>> base_url = provider.start_container("echo-env:latest")
|
| 36 |
+
>>> print(base_url) # http://localhost:8000
|
| 37 |
+
>>> # Use the environment via base_url
|
| 38 |
+
>>> provider.stop_container()
|
| 39 |
+
"""
|
| 40 |
+
|
| 41 |
+
@abstractmethod
|
| 42 |
+
def start_container(
|
| 43 |
+
self,
|
| 44 |
+
image: str,
|
| 45 |
+
port: Optional[int] = None,
|
| 46 |
+
env_vars: Optional[Dict[str, str]] = None,
|
| 47 |
+
**kwargs: Any,
|
| 48 |
+
) -> str:
|
| 49 |
+
"""
|
| 50 |
+
Start a container from the specified image.
|
| 51 |
+
|
| 52 |
+
Args:
|
| 53 |
+
image: Container image name (e.g., "echo-env:latest")
|
| 54 |
+
port: Port to expose (if None, provider chooses)
|
| 55 |
+
env_vars: Environment variables to pass to container
|
| 56 |
+
**kwargs: Provider-specific options
|
| 57 |
+
|
| 58 |
+
Returns:
|
| 59 |
+
Base URL to connect to the container (e.g., "http://localhost:8000")
|
| 60 |
+
|
| 61 |
+
Raises:
|
| 62 |
+
RuntimeError: If container fails to start
|
| 63 |
+
"""
|
| 64 |
+
pass
|
| 65 |
+
|
| 66 |
+
@abstractmethod
|
| 67 |
+
def stop_container(self) -> None:
|
| 68 |
+
"""
|
| 69 |
+
Stop and remove the running container.
|
| 70 |
+
|
| 71 |
+
This cleans up the container that was started by start_container().
|
| 72 |
+
"""
|
| 73 |
+
pass
|
| 74 |
+
|
| 75 |
+
@abstractmethod
|
| 76 |
+
def wait_for_ready(self, base_url: str, timeout_s: float = 30.0) -> None:
|
| 77 |
+
"""
|
| 78 |
+
Wait for the container to be ready to accept requests.
|
| 79 |
+
|
| 80 |
+
This typically polls the /health endpoint until it returns 200.
|
| 81 |
+
|
| 82 |
+
Args:
|
| 83 |
+
base_url: Base URL of the container
|
| 84 |
+
timeout_s: Maximum time to wait
|
| 85 |
+
|
| 86 |
+
Raises:
|
| 87 |
+
TimeoutError: If container doesn't become ready in time
|
| 88 |
+
"""
|
| 89 |
+
pass
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
class LocalDockerProvider(ContainerProvider):
|
| 93 |
+
"""
|
| 94 |
+
Container provider for local Docker daemon.
|
| 95 |
+
|
| 96 |
+
This provider runs containers on the local machine using Docker.
|
| 97 |
+
Useful for development and testing.
|
| 98 |
+
|
| 99 |
+
Example:
|
| 100 |
+
>>> provider = LocalDockerProvider()
|
| 101 |
+
>>> base_url = provider.start_container("echo-env:latest")
|
| 102 |
+
>>> # Container running on http://localhost:<random-port>
|
| 103 |
+
>>> provider.stop_container()
|
| 104 |
+
"""
|
| 105 |
+
|
| 106 |
+
def __init__(self):
|
| 107 |
+
"""Initialize the local Docker provider."""
|
| 108 |
+
self._container_id: Optional[str] = None
|
| 109 |
+
self._container_name: Optional[str] = None
|
| 110 |
+
|
| 111 |
+
# Check if Docker is available
|
| 112 |
+
import subprocess
|
| 113 |
+
|
| 114 |
+
try:
|
| 115 |
+
subprocess.run(
|
| 116 |
+
["docker", "version"],
|
| 117 |
+
check=True,
|
| 118 |
+
capture_output=True,
|
| 119 |
+
timeout=5,
|
| 120 |
+
)
|
| 121 |
+
except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired):
|
| 122 |
+
raise RuntimeError(
|
| 123 |
+
"Docker is not available. Please install Docker Desktop or Docker Engine."
|
| 124 |
+
)
|
| 125 |
+
|
| 126 |
+
def start_container(
|
| 127 |
+
self,
|
| 128 |
+
image: str,
|
| 129 |
+
port: Optional[int] = None,
|
| 130 |
+
env_vars: Optional[Dict[str, str]] = None,
|
| 131 |
+
**kwargs: Any,
|
| 132 |
+
) -> str:
|
| 133 |
+
"""
|
| 134 |
+
Start a Docker container locally.
|
| 135 |
+
|
| 136 |
+
Args:
|
| 137 |
+
image: Docker image name
|
| 138 |
+
port: Port to expose (if None, finds available port)
|
| 139 |
+
env_vars: Environment variables for the container
|
| 140 |
+
**kwargs: Additional Docker run options
|
| 141 |
+
|
| 142 |
+
Returns:
|
| 143 |
+
Base URL to connect to the container
|
| 144 |
+
"""
|
| 145 |
+
import subprocess
|
| 146 |
+
import time
|
| 147 |
+
|
| 148 |
+
# Find available port if not specified
|
| 149 |
+
if port is None:
|
| 150 |
+
port = self._find_available_port()
|
| 151 |
+
|
| 152 |
+
# Generate container name
|
| 153 |
+
self._container_name = self._generate_container_name(image)
|
| 154 |
+
|
| 155 |
+
# Build docker run command
|
| 156 |
+
cmd = [
|
| 157 |
+
"docker", "run",
|
| 158 |
+
"-d", # Detached
|
| 159 |
+
"--name", self._container_name,
|
| 160 |
+
"-p", f"{port}:8000", # Map port
|
| 161 |
+
]
|
| 162 |
+
|
| 163 |
+
# Add environment variables
|
| 164 |
+
if env_vars:
|
| 165 |
+
for key, value in env_vars.items():
|
| 166 |
+
cmd.extend(["-e", f"{key}={value}"])
|
| 167 |
+
|
| 168 |
+
# Add image
|
| 169 |
+
cmd.append(image)
|
| 170 |
+
|
| 171 |
+
# Run container
|
| 172 |
+
try:
|
| 173 |
+
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
|
| 174 |
+
self._container_id = result.stdout.strip()
|
| 175 |
+
except subprocess.CalledProcessError as e:
|
| 176 |
+
error_msg = f"Failed to start Docker container.\nCommand: {' '.join(cmd)}\nExit code: {e.returncode}\nStderr: {e.stderr}\nStdout: {e.stdout}"
|
| 177 |
+
raise RuntimeError(error_msg) from e
|
| 178 |
+
|
| 179 |
+
# Wait a moment for container to start
|
| 180 |
+
time.sleep(1)
|
| 181 |
+
|
| 182 |
+
base_url = f"http://localhost:{port}"
|
| 183 |
+
return base_url
|
| 184 |
+
|
| 185 |
+
def stop_container(self) -> None:
|
| 186 |
+
"""
|
| 187 |
+
Stop and remove the Docker container.
|
| 188 |
+
"""
|
| 189 |
+
if self._container_id is None:
|
| 190 |
+
return
|
| 191 |
+
|
| 192 |
+
import subprocess
|
| 193 |
+
|
| 194 |
+
try:
|
| 195 |
+
# Stop container
|
| 196 |
+
subprocess.run(
|
| 197 |
+
["docker", "stop", self._container_id],
|
| 198 |
+
capture_output=True,
|
| 199 |
+
check=True,
|
| 200 |
+
timeout=10,
|
| 201 |
+
)
|
| 202 |
+
|
| 203 |
+
# Remove container
|
| 204 |
+
subprocess.run(
|
| 205 |
+
["docker", "rm", self._container_id],
|
| 206 |
+
capture_output=True,
|
| 207 |
+
check=True,
|
| 208 |
+
timeout=10,
|
| 209 |
+
)
|
| 210 |
+
except subprocess.CalledProcessError:
|
| 211 |
+
# Container might already be stopped/removed
|
| 212 |
+
pass
|
| 213 |
+
finally:
|
| 214 |
+
self._container_id = None
|
| 215 |
+
self._container_name = None
|
| 216 |
+
|
| 217 |
+
def wait_for_ready(self, base_url: str, timeout_s: float = 30.0) -> None:
|
| 218 |
+
"""
|
| 219 |
+
Wait for container to be ready by polling /health endpoint.
|
| 220 |
+
|
| 221 |
+
Args:
|
| 222 |
+
base_url: Base URL of the container
|
| 223 |
+
timeout_s: Maximum time to wait
|
| 224 |
+
|
| 225 |
+
Raises:
|
| 226 |
+
TimeoutError: If container doesn't become ready
|
| 227 |
+
"""
|
| 228 |
+
import time
|
| 229 |
+
import requests
|
| 230 |
+
|
| 231 |
+
start_time = time.time()
|
| 232 |
+
health_url = f"{base_url}/health"
|
| 233 |
+
|
| 234 |
+
while time.time() - start_time < timeout_s:
|
| 235 |
+
try:
|
| 236 |
+
response = requests.get(health_url, timeout=2.0)
|
| 237 |
+
if response.status_code == 200:
|
| 238 |
+
return
|
| 239 |
+
except requests.RequestException:
|
| 240 |
+
pass
|
| 241 |
+
|
| 242 |
+
time.sleep(0.5)
|
| 243 |
+
|
| 244 |
+
raise TimeoutError(
|
| 245 |
+
f"Container at {base_url} did not become ready within {timeout_s}s"
|
| 246 |
+
)
|
| 247 |
+
|
| 248 |
+
def _find_available_port(self) -> int:
|
| 249 |
+
"""
|
| 250 |
+
Find an available port on localhost.
|
| 251 |
+
|
| 252 |
+
Returns:
|
| 253 |
+
An available port number
|
| 254 |
+
"""
|
| 255 |
+
import socket
|
| 256 |
+
|
| 257 |
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
| 258 |
+
s.bind(("", 0))
|
| 259 |
+
s.listen(1)
|
| 260 |
+
port = s.getsockname()[1]
|
| 261 |
+
return port
|
| 262 |
+
|
| 263 |
+
def _generate_container_name(self, image: str) -> str:
|
| 264 |
+
"""
|
| 265 |
+
Generate a unique container name based on image name and timestamp.
|
| 266 |
+
|
| 267 |
+
Args:
|
| 268 |
+
image: Docker image name
|
| 269 |
+
|
| 270 |
+
Returns:
|
| 271 |
+
A unique container name
|
| 272 |
+
"""
|
| 273 |
+
import time
|
| 274 |
+
|
| 275 |
+
clean_image = image.split("/")[-1].split(":")[0]
|
| 276 |
+
timestamp = int(time.time() * 1000)
|
| 277 |
+
return f"{clean_image}-{timestamp}"
|
| 278 |
+
|
| 279 |
+
|
| 280 |
+
class KubernetesProvider(ContainerProvider):
|
| 281 |
+
"""
|
| 282 |
+
Container provider for Kubernetes clusters.
|
| 283 |
+
|
| 284 |
+
This provider creates pods in a Kubernetes cluster and exposes them
|
| 285 |
+
via services or port-forwarding.
|
| 286 |
+
|
| 287 |
+
Example:
|
| 288 |
+
>>> provider = KubernetesProvider(namespace="envtorch-dev")
|
| 289 |
+
>>> base_url = provider.start_container("echo-env:latest")
|
| 290 |
+
>>> # Pod running in k8s, accessible via service or port-forward
|
| 291 |
+
>>> provider.stop_container()
|
| 292 |
+
"""
|
| 293 |
+
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,203 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
# 1. Start container with optional kwargs (e.g., env_vars, port)
|
| 100 |
+
base_url = provider.start_container(image, **kwargs)
|
| 101 |
+
|
| 102 |
+
# 2. Wait for server to be ready
|
| 103 |
+
provider.wait_for_ready(base_url)
|
| 104 |
+
|
| 105 |
+
# 3. Create and return client instance with provider reference
|
| 106 |
+
return cls(base_url=base_url, provider=provider)
|
| 107 |
+
|
| 108 |
+
@classmethod
|
| 109 |
+
def from_hub(cls: Type[EnvClientT], repo_id: str, provider: Optional["ContainerProvider"] = None, **kwargs: Any) -> EnvClientT:
|
| 110 |
+
"""
|
| 111 |
+
Create an environment client by pulling from a Hugging Face model hub.
|
| 112 |
+
"""
|
| 113 |
+
|
| 114 |
+
if provider is None:
|
| 115 |
+
provider = LocalDockerProvider()
|
| 116 |
+
|
| 117 |
+
if "tag" in kwargs:
|
| 118 |
+
tag = kwargs["tag"]
|
| 119 |
+
else:
|
| 120 |
+
tag = "latest"
|
| 121 |
+
|
| 122 |
+
base_url = f"registry.hf.space/{repo_id.replace('/', '-')}:{tag}"
|
| 123 |
+
|
| 124 |
+
return cls.from_docker_image(image=base_url, provider=provider)
|
| 125 |
+
|
| 126 |
+
@abstractmethod
|
| 127 |
+
def _step_payload(self, action: ActT) -> dict:
|
| 128 |
+
"""Convert an Action object to the JSON body expected by the env server."""
|
| 129 |
+
raise NotImplementedError
|
| 130 |
+
|
| 131 |
+
@abstractmethod
|
| 132 |
+
def _parse_result(self, payload: dict) -> StepResult[ObsT]:
|
| 133 |
+
"""Convert a JSON response from the env server to StepResult[ObsT]."""
|
| 134 |
+
raise NotImplementedError
|
| 135 |
+
|
| 136 |
+
@abstractmethod
|
| 137 |
+
def _parse_state(self, payload: dict) -> Any:
|
| 138 |
+
"""Convert a JSON response from the state endpoint to a State object."""
|
| 139 |
+
raise NotImplementedError
|
| 140 |
+
|
| 141 |
+
# ---------- Environment Server Interface Methods ----------
|
| 142 |
+
def reset(self) -> StepResult[ObsT]:
|
| 143 |
+
body: Dict[str, Any] = {}
|
| 144 |
+
# TODO: later:
|
| 145 |
+
# body["seed"] = seed
|
| 146 |
+
# body["episode_id"] = episode_id
|
| 147 |
+
r = self._http.post(
|
| 148 |
+
f"{self._base}/reset",
|
| 149 |
+
json=body,
|
| 150 |
+
headers=self._headers,
|
| 151 |
+
timeout=self._timeout,
|
| 152 |
+
)
|
| 153 |
+
r.raise_for_status()
|
| 154 |
+
return self._parse_result(r.json())
|
| 155 |
+
|
| 156 |
+
def step(self, action: ActT) -> StepResult[ObsT]:
|
| 157 |
+
body: Dict[str, Any] = {
|
| 158 |
+
"action": self._step_payload(action),
|
| 159 |
+
"timeout_s": int(self._timeout),
|
| 160 |
+
}
|
| 161 |
+
# TODO: later:
|
| 162 |
+
# body["request_id"] = str(uuid.uuid4())
|
| 163 |
+
# body["episode_id"] = current_episode_id
|
| 164 |
+
r = self._http.post(
|
| 165 |
+
f"{self._base}/step",
|
| 166 |
+
json=body,
|
| 167 |
+
headers=self._headers,
|
| 168 |
+
timeout=self._timeout,
|
| 169 |
+
)
|
| 170 |
+
r.raise_for_status()
|
| 171 |
+
return self._parse_result(r.json())
|
| 172 |
+
|
| 173 |
+
def state(self) -> Any:
|
| 174 |
+
"""
|
| 175 |
+
Get the current environment state from the server.
|
| 176 |
+
|
| 177 |
+
Returns:
|
| 178 |
+
State object with environment state information (e.g., episode_id, step_count)
|
| 179 |
+
|
| 180 |
+
Example:
|
| 181 |
+
>>> client = EchoEnv.from_docker_image("echo-env:latest")
|
| 182 |
+
>>> result = client.reset()
|
| 183 |
+
>>> state = client.state()
|
| 184 |
+
>>> print(state.episode_id)
|
| 185 |
+
>>> print(state.step_count)
|
| 186 |
+
"""
|
| 187 |
+
r = self._http.get(
|
| 188 |
+
f"{self._base}/state",
|
| 189 |
+
headers=self._headers,
|
| 190 |
+
timeout=self._timeout,
|
| 191 |
+
)
|
| 192 |
+
r.raise_for_status()
|
| 193 |
+
return self._parse_state(r.json())
|
| 194 |
+
|
| 195 |
+
def close(self) -> None:
|
| 196 |
+
"""
|
| 197 |
+
Close the environment and clean up resources.
|
| 198 |
+
|
| 199 |
+
If this client was created via from_docker_image(), this will stop
|
| 200 |
+
and remove the associated container.
|
| 201 |
+
"""
|
| 202 |
+
if self._provider is not None:
|
| 203 |
+
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,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
|
| 12 |
+
__all__ = [
|
| 13 |
+
"PyExecutor",
|
| 14 |
+
"GitServerClient",
|
| 15 |
+
"RepoInfo",
|
| 16 |
+
]
|
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/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/android_env/README.md
ADDED
|
@@ -0,0 +1,687 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Android Environment for OpenEnv
|
| 2 |
+
|
| 3 |
+
Production-ready integration of [DeepMind's android_env](https://github.com/deepmind/android_env) with the OpenEnv framework, enabling RL agents to interact with Android applications via touchscreen gestures and system commands.
|
| 4 |
+
|
| 5 |
+
## Overview
|
| 6 |
+
|
| 7 |
+
The Android environment exposes a virtual Android device as an RL environment where agents interact via:
|
| 8 |
+
- **Touchscreen gestures**: tap, swipe, long press, scroll, double tap
|
| 9 |
+
- **Text input**: via ADB for keyboard input
|
| 10 |
+
- **System buttons**: HOME, BACK, MENU, etc. via ADB
|
| 11 |
+
- **Screen observations**: RGB pixels encoded as JPEG/PNG or via shared memory
|
| 12 |
+
|
| 13 |
+
This enables training AI agents on:
|
| 14 |
+
- Android games and applications
|
| 15 |
+
- Mobile UI automation tasks
|
| 16 |
+
- Real-world mobile interaction scenarios
|
| 17 |
+
- Any task definable on Android
|
| 18 |
+
|
| 19 |
+
## What We Built
|
| 20 |
+
|
| 21 |
+
### ✅ Core Features (Completed)
|
| 22 |
+
|
| 23 |
+
#### 1. **Complete Gesture Support** (gestures.py - 255 lines, 45 tests)
|
| 24 |
+
All gestures are implemented as **sequences of touch primitives** (TOUCH → REPEAT → LIFT):
|
| 25 |
+
|
| 26 |
+
- **Tap**: Single touch at point
|
| 27 |
+
- **Swipe**: Smooth interpolated motion from point A to B
|
| 28 |
+
- **Long Press**: Extended hold at point
|
| 29 |
+
- **Double Tap**: Two rapid taps at same point
|
| 30 |
+
- **Scroll Down/Up**: Context-aware vertical scrolling
|
| 31 |
+
- **Swipe Left/Right**: Context-aware horizontal swiping
|
| 32 |
+
|
| 33 |
+
**How it works**:
|
| 34 |
+
```python
|
| 35 |
+
# High-level action
|
| 36 |
+
AndroidAction("swipe", {"x1": 0.5, "y1": 0.8, "x2": 0.5, "y2": 0.2})
|
| 37 |
+
|
| 38 |
+
# Converts to primitive sequence via GestureBuilder.swipe()
|
| 39 |
+
[
|
| 40 |
+
{"action_type": 0, "x": 0.5, "y": 0.8}, # TOUCH
|
| 41 |
+
{"action_type": 2, "x": 0.5, "y": 0.7}, # REPEAT (interpolated)
|
| 42 |
+
{"action_type": 2, "x": 0.5, "y": 0.6}, # REPEAT (interpolated)
|
| 43 |
+
# ... more REPEATs for smooth motion
|
| 44 |
+
{"action_type": 2, "x": 0.5, "y": 0.3}, # REPEAT (interpolated)
|
| 45 |
+
{"action_type": 1, "x": 0.5, "y": 0.2}, # LIFT
|
| 46 |
+
]
|
| 47 |
+
|
| 48 |
+
# Each primitive sent to android_env.step() sequentially
|
| 49 |
+
```
|
| 50 |
+
|
| 51 |
+
#### 2. **ADB Integration** (android_environment.py)
|
| 52 |
+
Direct command execution on Android OS:
|
| 53 |
+
|
| 54 |
+
- **Text Input**: `type_text` → `adb shell input text "Hello"`
|
| 55 |
+
- Proper shell escaping (double quotes, unicode support)
|
| 56 |
+
- Special character handling (quotes, spaces, emojis)
|
| 57 |
+
- **Button Press**: `press_button` → `adb shell input keyevent KEYCODE_HOME`
|
| 58 |
+
- All standard Android keycodes (HOME, BACK, MENU, ENTER, etc.)
|
| 59 |
+
|
| 60 |
+
**How it works**:
|
| 61 |
+
```python
|
| 62 |
+
# type_text action
|
| 63 |
+
AndroidAction("type_text", {"text": "Hello World 世界 🌍"})
|
| 64 |
+
|
| 65 |
+
# → Calls _execute_adb_text()
|
| 66 |
+
# → Escapes text for shell safety
|
| 67 |
+
# → Builds ADB command: input text "Hello%sWorld%s世界%s🌍"
|
| 68 |
+
# → Executes via android_env.execute_adb_call()
|
| 69 |
+
```
|
| 70 |
+
|
| 71 |
+
#### 3. **EmulatorPool - 100x Speedup** (emulator_pool.py - 314 lines, 24 tests)
|
| 72 |
+
Pre-warmed emulator pool eliminates per-episode boot time.
|
| 73 |
+
|
| 74 |
+
**The Problem**:
|
| 75 |
+
- Emulator boot: 30-60 seconds per instance
|
| 76 |
+
- Sequential training: 1000 episodes × 60s = 16.7 hours wasted on boot!
|
| 77 |
+
|
| 78 |
+
**The Solution**:
|
| 79 |
+
- Boot N emulators once at startup (10 min one-time cost)
|
| 80 |
+
- Reuse emulators across episodes (reset app state, not emulator)
|
| 81 |
+
- Thread-safe pool management with get/put
|
| 82 |
+
|
| 83 |
+
**Performance**:
|
| 84 |
+
```python
|
| 85 |
+
# Traditional (sequential)
|
| 86 |
+
for episode in range(1000):
|
| 87 |
+
env = AndroidEnvironment(...) # 60s boot × 1000 = 16.7 hours
|
| 88 |
+
env.reset()
|
| 89 |
+
# ... run episode (1 min)
|
| 90 |
+
env.close()
|
| 91 |
+
# Total: 1000 × 61 min = ~1017 hours
|
| 92 |
+
|
| 93 |
+
# With EmulatorPool (parallel)
|
| 94 |
+
pool = EmulatorPool(pool_size=64, ...) # 64 × 60s = ~64 min one-time cost
|
| 95 |
+
for episode in range(1000):
|
| 96 |
+
env = pool.get() # <1ms
|
| 97 |
+
env.reset() # ~1s (app reset, not emulator boot)
|
| 98 |
+
# ... run episode (1 min)
|
| 99 |
+
pool.put(env)
|
| 100 |
+
# Total: ~64 min (one-time) + 1000 min = ~17.7 hours (58× faster!)
|
| 101 |
+
|
| 102 |
+
# With parallel workers
|
| 103 |
+
with EmulatorPool(pool_size=64, ...) as pool:
|
| 104 |
+
with ThreadPoolExecutor(max_workers=64) as executor:
|
| 105 |
+
# Run 1000 episodes across 64 workers
|
| 106 |
+
# Total: ~64 min (boot) + 1000/64 min (episodes) = ~80 min (100× faster!)
|
| 107 |
+
```
|
| 108 |
+
|
| 109 |
+
**Architecture**:
|
| 110 |
+
```python
|
| 111 |
+
class EmulatorPool:
|
| 112 |
+
def __init__(pool_size=64):
|
| 113 |
+
# Boot N emulators at startup
|
| 114 |
+
self._available = queue.Queue()
|
| 115 |
+
for i in range(pool_size):
|
| 116 |
+
env = AndroidEnvironment(...)
|
| 117 |
+
env.reset() # Warm up
|
| 118 |
+
self._available.put(env)
|
| 119 |
+
|
| 120 |
+
def get(timeout=None):
|
| 121 |
+
# Thread-safe: block until emulator available
|
| 122 |
+
return self._available.get(timeout=timeout)
|
| 123 |
+
|
| 124 |
+
def put(env, reset=True):
|
| 125 |
+
# Fast reset (~1s): app state only, not full emulator
|
| 126 |
+
if reset:
|
| 127 |
+
env.reset()
|
| 128 |
+
self._available.put(env)
|
| 129 |
+
```
|
| 130 |
+
|
| 131 |
+
#### 4. **Shared Memory Optimization** (android_environment.py)
|
| 132 |
+
Zero-copy observations for high-throughput parallel training.
|
| 133 |
+
|
| 134 |
+
**Traditional (Base64)**:
|
| 135 |
+
```python
|
| 136 |
+
# Per observation:
|
| 137 |
+
# 1. Encode pixels → JPEG (10ms, 150KB)
|
| 138 |
+
# 2. Base64 encode (5ms, 200KB string)
|
| 139 |
+
# 3. Send over HTTP (10ms for 200KB)
|
| 140 |
+
# 4. Base64 decode (5ms)
|
| 141 |
+
# 5. JPEG decode (10ms)
|
| 142 |
+
# Total: ~40ms overhead per observation
|
| 143 |
+
```
|
| 144 |
+
|
| 145 |
+
**Shared Memory**:
|
| 146 |
+
```python
|
| 147 |
+
# Setup (one-time per emulator):
|
| 148 |
+
shm = shared_memory.SharedMemory(name="android_pool_0", size=1920*1080*3)
|
| 149 |
+
|
| 150 |
+
# Per observation:
|
| 151 |
+
# 1. Write pixels directly to shared memory (1ms)
|
| 152 |
+
# 2. Return "shm://android_pool_0" reference (<1ms)
|
| 153 |
+
# 3. Client reads from same memory (0ms - zero copy!)
|
| 154 |
+
# Total: ~1ms overhead per observation (40× faster!)
|
| 155 |
+
```
|
| 156 |
+
|
| 157 |
+
**How it works**:
|
| 158 |
+
```python
|
| 159 |
+
# Server side
|
| 160 |
+
env = AndroidEnvironment(
|
| 161 |
+
use_shared_memory=True,
|
| 162 |
+
shared_memory_name="android_pool_0" # Unique per emulator
|
| 163 |
+
)
|
| 164 |
+
obs = env.reset()
|
| 165 |
+
obs.screen_image # "shm://android_pool_0"
|
| 166 |
+
|
| 167 |
+
# Client side (on same machine)
|
| 168 |
+
shm = shared_memory.SharedMemory(name="android_pool_0")
|
| 169 |
+
pixels = np.ndarray((1920, 1080, 3), dtype=np.uint8, buffer=shm.buf)
|
| 170 |
+
# pixels now points directly to emulator's screen buffer
|
| 171 |
+
```
|
| 172 |
+
|
| 173 |
+
#### 5. **Comprehensive Test Suite** (tests/ - 105 tests, 90% coverage)
|
| 174 |
+
|
| 175 |
+
**Unit Tests** (63 tests - no dependencies):
|
| 176 |
+
- `test_models.py`: 18 tests - RFC 004 compliance, action/observation validation
|
| 177 |
+
- `test_gestures.py`: 13 tests - Gesture primitives, ADB commands, escaping
|
| 178 |
+
- `test_edge_cases.py`: 32 tests - Boundaries, unicode, special chars, long strings
|
| 179 |
+
|
| 180 |
+
**Integration Tests** (42 tests - require Docker):
|
| 181 |
+
- `test_environment_mocked.py`: 18 tests - Action conversion, coordinate clipping, ADB execution, workflows
|
| 182 |
+
- `test_emulator_pool.py`: 24 tests - Thread safety, pool exhaustion, cleanup, multi-task
|
| 183 |
+
|
| 184 |
+
**What We Test**:
|
| 185 |
+
- ✅ Coordinate pass-through (x=0.5, y=0.5 → touch_position=[0.5, 0.5])
|
| 186 |
+
- ✅ Coordinate clipping (x=1.5 → 1.0, y=-0.5 → 0.0)
|
| 187 |
+
- ✅ ADB execution (execute_adb_call actually called with correct commands)
|
| 188 |
+
- ✅ Gesture sequencing (tap=2 primitives, swipe=10+ primitives)
|
| 189 |
+
- ✅ Shared memory (obs.screen_image = "shm://..." when enabled)
|
| 190 |
+
- ✅ Observation decode (base64 → valid image with correct dimensions)
|
| 191 |
+
- ✅ Multi-action workflows (tap → swipe → text → button in sequence)
|
| 192 |
+
- ✅ Multi-episode lifecycle (reset → steps → reset with new episode_id)
|
| 193 |
+
- ✅ Thread safety (64 workers competing for 5 emulators)
|
| 194 |
+
- ✅ Text escaping (quotes, unicode 世界, emojis 🌍, shell chars $;|)
|
| 195 |
+
|
| 196 |
+
**Run tests**:
|
| 197 |
+
```bash
|
| 198 |
+
# Unit tests (instant, no dependencies)
|
| 199 |
+
cd src/envs/android_env/tests
|
| 200 |
+
./run_unit_tests.sh
|
| 201 |
+
# 63/63 PASSED ✅
|
| 202 |
+
|
| 203 |
+
# Integration tests (require Docker with android_env)
|
| 204 |
+
./run_docker_tests.sh
|
| 205 |
+
# 42/42 PASSED ✅
|
| 206 |
+
```
|
| 207 |
+
|
| 208 |
+
**Coverage**:
|
| 209 |
+
- models.py: ~95%
|
| 210 |
+
- gestures.py: ~90%
|
| 211 |
+
- emulator_pool.py: ~85%
|
| 212 |
+
- android_environment.py: ~90%
|
| 213 |
+
- **Overall: ~90%** (up from 58% before testing push)
|
| 214 |
+
|
| 215 |
+
#### 6. **OpenEnv RFC Compliance**
|
| 216 |
+
- **RFC 001**: HTTP-based environment server ✅
|
| 217 |
+
- **RFC 002**: Observation/Action types ✅
|
| 218 |
+
- **RFC 003**: Environment lifecycle (reset/step/state) ✅
|
| 219 |
+
- **RFC 004**: ToolCallAction pattern (tool_name + parameters) ✅
|
| 220 |
+
|
| 221 |
+
### ⚠️ Limitations and Future Work
|
| 222 |
+
|
| 223 |
+
#### What We Intentionally Skipped (Not in Spec)
|
| 224 |
+
|
| 225 |
+
1. **Accessibility Tree Observations**
|
| 226 |
+
- android_env supports accessibility tree (JSON UI hierarchy)
|
| 227 |
+
- **Why skipped**: Not part of OpenEnv observation spec (expects pixels only)
|
| 228 |
+
- **Future**: Could add as `extras` field in AndroidObservation
|
| 229 |
+
- **Impact**: Agents must use vision, can't query UI structure
|
| 230 |
+
|
| 231 |
+
2. **Multi-Finger Gestures**
|
| 232 |
+
- Android supports multi-touch (pinch, rotate, 3-finger swipe)
|
| 233 |
+
- **Why skipped**: android_env's action spec only supports single touch point
|
| 234 |
+
- **Workaround**: Simplified to single-touch sequences
|
| 235 |
+
- **Impact**: Can't do pinch-to-zoom, rotation gestures
|
| 236 |
+
|
| 237 |
+
3. **State Save/Load**
|
| 238 |
+
- android_env doesn't expose emulator snapshot APIs
|
| 239 |
+
- **Why skipped**: No clean API in android_env
|
| 240 |
+
- **Workaround**: Use task setup_steps/reset_steps for determinism
|
| 241 |
+
- **Impact**: Can't quickly restore to arbitrary states
|
| 242 |
+
|
| 243 |
+
4. **GUI Mode / Visual Display**
|
| 244 |
+
- Emulator runs headless (no window)
|
| 245 |
+
- **Why skipped**: Headless is default, GUI requires X11 forwarding
|
| 246 |
+
- **Workaround**: Decode screen_image to view observations
|
| 247 |
+
- **Impact**: Can't watch emulator in real-time (but faster)
|
| 248 |
+
|
| 249 |
+
5. **Non-Linux Platforms**
|
| 250 |
+
- KVM (kernel-level virtualization) is Linux-only
|
| 251 |
+
- **Why skipped**: Android emulator needs KVM for acceptable speed
|
| 252 |
+
- **Workaround**: Use Linux VM or cloud instance
|
| 253 |
+
- **Impact**: macOS/Windows users need Linux VM (10× slower without KVM)
|
| 254 |
+
|
| 255 |
+
6. **HTTP Client/Server Integration**
|
| 256 |
+
- client.py (140 lines) and app.py (108 lines) exist but untested
|
| 257 |
+
- **Why skipped**: Focus was on core environment + EmulatorPool
|
| 258 |
+
- **Future**: Add 15-20 integration tests for HTTP endpoints
|
| 259 |
+
- **Impact**: HTTP layer works but lacks test coverage
|
| 260 |
+
|
| 261 |
+
#### Known Issues
|
| 262 |
+
|
| 263 |
+
1. **ADB Text Input Limitations**
|
| 264 |
+
- Some special chars may not work on all Android versions
|
| 265 |
+
- No support for IME (Input Method Editor) features
|
| 266 |
+
- Can't input via virtual keyboard UI
|
| 267 |
+
|
| 268 |
+
2. **Emulator Boot Variability**
|
| 269 |
+
- Boot time: 30-90 seconds depending on system
|
| 270 |
+
- First boot may timeout - retry or increase timeout
|
| 271 |
+
- Emulator state not always deterministic
|
| 272 |
+
|
| 273 |
+
3. **Resource Consumption**
|
| 274 |
+
- Each emulator: 2-4 CPU cores, 4-8GB RAM
|
| 275 |
+
- EmulatorPool(64): requires 128-256 cores, 256-512GB RAM
|
| 276 |
+
- Only viable on high-end servers or cloud instances
|
| 277 |
+
|
| 278 |
+
4. **Observation Latency**
|
| 279 |
+
- Base64 encoding: ~40ms overhead per frame
|
| 280 |
+
- Shared memory: ~1ms overhead (40× faster)
|
| 281 |
+
- Shared memory requires client on same machine
|
| 282 |
+
|
| 283 |
+
## Architecture
|
| 284 |
+
|
| 285 |
+
```
|
| 286 |
+
┌─────────────────────────────────────────────────────────────────┐
|
| 287 |
+
│ RL Training Code (Client) │
|
| 288 |
+
│ │
|
| 289 |
+
│ client = AndroidEnv.from_docker_image("android-env") │
|
| 290 |
+
│ obs = client.reset() │
|
| 291 |
+
│ obs = client.step(AndroidAction(...)) │
|
| 292 |
+
└────────────────────┬────────────────────────────────────────────┘
|
| 293 |
+
│ HTTP (or shared memory for observations)
|
| 294 |
+
▼
|
| 295 |
+
┌─────────────────────────────────────────────────────────────────┐
|
| 296 |
+
│ Docker Container (android-env-server) │
|
| 297 |
+
│ ┌──────────────────────────────────────────────────────────┐ │
|
| 298 |
+
│ │ FastAPI Server (app.py) │ │
|
| 299 |
+
│ │ - /reset, /step, /state endpoints │ │
|
| 300 |
+
│ │ - Action/Observation serialization │ │
|
| 301 |
+
│ └────────────────┬─────────────────────────────────────────┘ │
|
| 302 |
+
│ │ │
|
| 303 |
+
│ ┌────────────────▼─────────────────────────────────────────┐ │
|
| 304 |
+
│ │ AndroidEnvironment (android_environment.py) │ │
|
| 305 |
+
│ │ - Gesture sequencing (GestureBuilder) │ │
|
| 306 |
+
│ │ - ADB integration (text input, buttons) │ │
|
| 307 |
+
│ │ - Observation encoding (base64 or shared memory) │ │
|
| 308 |
+
│ │ - Coordinate clipping and validation │ │
|
| 309 |
+
│ └────────────────┬─────────────────────────────────────────┘ │
|
| 310 |
+
│ │ │
|
| 311 |
+
│ ┌────────────────▼─────────────────────────────────────────┐ │
|
| 312 |
+
│ │ android_env.AndroidEnv │ │
|
| 313 |
+
│ │ (DeepMind's library) │ │
|
| 314 |
+
│ │ - Task rewards and logic │ │
|
| 315 |
+
│ │ - ADB protocol handling │ │
|
| 316 |
+
│ └────────────────┬─────────────────────────────────────────┘ │
|
| 317 |
+
│ │ ADB Protocol │
|
| 318 |
+
│ ┌────────────────▼─────────────────────────────────────────┐ │
|
| 319 |
+
│ │ Android Emulator Process │ │
|
| 320 |
+
│ │ - Headless Android Virtual Device (AVD) │ │
|
| 321 |
+
│ │ - Runs Android OS + installed apps │ │
|
| 322 |
+
│ │ - Hardware acceleration via KVM │ │
|
| 323 |
+
│ └──────────────────────────────────────────────────────────┘ │
|
| 324 |
+
└─────────────────────────────────────────────────────────────────┘
|
| 325 |
+
|
| 326 |
+
Alternative: EmulatorPool for Parallel Training
|
| 327 |
+
┌─────────────────────────────────────────────────────────────────┐
|
| 328 |
+
│ EmulatorPool (emulator_pool.py) │
|
| 329 |
+
│ │
|
| 330 |
+
│ pool = EmulatorPool(pool_size=64, use_shared_memory=True) │
|
| 331 |
+
│ │
|
| 332 |
+
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
|
| 333 |
+
│ │ Emulator 1 │ │ Emulator 2 │ ... │ Emulator 64 │ │
|
| 334 |
+
│ │ (pre-warm) │ │ (pre-warm) │ │ (pre-warm) │ │
|
| 335 |
+
│ └─────────────┘ └─────────────┘ └─────────────┘ │
|
| 336 |
+
│ ▲ ▲ ▲ │
|
| 337 |
+
│ │ │ │ │
|
| 338 |
+
│ ┌──────┴────────┬────────┴──────┬──────────────┴──────┐ │
|
| 339 |
+
│ │ Worker 1 │ Worker 2 │ ... │ Worker 64 │ │
|
| 340 |
+
│ │ pool.get() │ pool.get() │ │ pool.get() │ │
|
| 341 |
+
│ │ run_episode │ run_episode │ │ run_episode│ │
|
| 342 |
+
│ │ pool.put() │ pool.put() │ │ pool.put() │ │
|
| 343 |
+
│ └───────────────┴───────────────┴───────┴─────────────┘ │
|
| 344 |
+
│ │
|
| 345 |
+
│ Thread-safe queue ensures no conflicts │
|
| 346 |
+
│ Shared memory enables zero-copy observations │
|
| 347 |
+
└─────────────────────────────────────────────────────────────────┘
|
| 348 |
+
```
|
| 349 |
+
|
| 350 |
+
## Quick Start
|
| 351 |
+
|
| 352 |
+
### Prerequisites
|
| 353 |
+
|
| 354 |
+
- **OS**: Linux (Ubuntu 20.04+ recommended, KVM required)
|
| 355 |
+
- **Hardware**: 4+ cores, 8GB RAM minimum (64+ cores, 256GB RAM for EmulatorPool)
|
| 356 |
+
- **Software**: Docker with KVM device access, Python 3.11+
|
| 357 |
+
|
| 358 |
+
### Installation
|
| 359 |
+
|
| 360 |
+
```bash
|
| 361 |
+
# 1. Build Docker image (~10-20 min, downloads 2GB Android SDK)
|
| 362 |
+
docker build -t android-env:latest -f src/envs/android_env/server/Dockerfile .
|
| 363 |
+
|
| 364 |
+
# 2. Prepare task definition (see examples/tasks/)
|
| 365 |
+
# Create your_task.textproto following android_env task spec
|
| 366 |
+
|
| 367 |
+
# 3. Run a simple test
|
| 368 |
+
python examples/android_basic.py
|
| 369 |
+
```
|
| 370 |
+
|
| 371 |
+
### Basic Usage
|
| 372 |
+
|
| 373 |
+
```python
|
| 374 |
+
from envs.android_env import AndroidEnv, AndroidAction
|
| 375 |
+
|
| 376 |
+
# Start environment
|
| 377 |
+
client = AndroidEnv.from_docker_image(
|
| 378 |
+
"android-env:latest",
|
| 379 |
+
environment={
|
| 380 |
+
"ANDROID_AVD_NAME": "default_pixel_6",
|
| 381 |
+
"ANDROID_TASK_PATH": "/workspace/tasks/calculator.textproto"
|
| 382 |
+
},
|
| 383 |
+
volumes={
|
| 384 |
+
"/path/to/tasks": "/workspace/tasks",
|
| 385 |
+
"/path/to/apps": "/workspace/apps"
|
| 386 |
+
},
|
| 387 |
+
device_requests=[{"PathOnHost": "/dev/kvm", "PathInContainer": "/dev/kvm", "CgroupPermissions": "rwm"}]
|
| 388 |
+
)
|
| 389 |
+
|
| 390 |
+
# Reset and get initial observation
|
| 391 |
+
result = client.reset()
|
| 392 |
+
print(f"Screen: {result.observation.screen_width}x{result.observation.screen_height}")
|
| 393 |
+
|
| 394 |
+
# Tap at center
|
| 395 |
+
result = client.step(AndroidAction("tap", {"x": 0.5, "y": 0.5}))
|
| 396 |
+
|
| 397 |
+
# Swipe down (scroll)
|
| 398 |
+
result = client.step(AndroidAction("swipe", {
|
| 399 |
+
"x1": 0.5, "y1": 0.7,
|
| 400 |
+
"x2": 0.5, "y2": 0.3
|
| 401 |
+
}))
|
| 402 |
+
|
| 403 |
+
# Type text
|
| 404 |
+
result = client.step(AndroidAction("type_text", {"text": "Hello"}))
|
| 405 |
+
|
| 406 |
+
# Press HOME button
|
| 407 |
+
result = client.step(AndroidAction("press_button", {"button": "HOME"}))
|
| 408 |
+
|
| 409 |
+
client.close()
|
| 410 |
+
```
|
| 411 |
+
|
| 412 |
+
### High-Performance Parallel Training
|
| 413 |
+
|
| 414 |
+
```python
|
| 415 |
+
from envs.android_env.server.emulator_pool import EmulatorPool
|
| 416 |
+
from concurrent.futures import ThreadPoolExecutor
|
| 417 |
+
|
| 418 |
+
def run_episode(pool, episode_id):
|
| 419 |
+
"""Run single episode using emulator from pool."""
|
| 420 |
+
env = pool.get(timeout=60) # Block until emulator available
|
| 421 |
+
try:
|
| 422 |
+
obs = env.reset()
|
| 423 |
+
episode_reward = 0
|
| 424 |
+
|
| 425 |
+
for step in range(100):
|
| 426 |
+
# Your policy here
|
| 427 |
+
action = your_policy(obs)
|
| 428 |
+
obs = env.step(action)
|
| 429 |
+
episode_reward += obs.reward
|
| 430 |
+
if obs.done:
|
| 431 |
+
break
|
| 432 |
+
|
| 433 |
+
return episode_id, episode_reward
|
| 434 |
+
finally:
|
| 435 |
+
pool.put(env) # Return to pool (auto-resets)
|
| 436 |
+
|
| 437 |
+
# Create pool (one-time boot cost: ~64 minutes for 64 emulators)
|
| 438 |
+
pool = EmulatorPool(
|
| 439 |
+
pool_size=64,
|
| 440 |
+
task_path="/workspace/tasks/my_task.textproto",
|
| 441 |
+
avd_name="default_pixel_6",
|
| 442 |
+
use_shared_memory=True, # Zero-copy observations
|
| 443 |
+
)
|
| 444 |
+
|
| 445 |
+
# Run 1000 episodes across 64 parallel workers
|
| 446 |
+
# Time: ~64 min (boot) + 1000/64 min (episodes) = ~80 min (100× faster than sequential!)
|
| 447 |
+
with ThreadPoolExecutor(max_workers=64) as executor:
|
| 448 |
+
futures = [executor.submit(run_episode, pool, i) for i in range(1000)]
|
| 449 |
+
results = [f.result() for f in futures]
|
| 450 |
+
|
| 451 |
+
pool.close()
|
| 452 |
+
```
|
| 453 |
+
|
| 454 |
+
## Action Reference
|
| 455 |
+
|
| 456 |
+
All actions follow RFC 004's ToolCallAction pattern:
|
| 457 |
+
|
| 458 |
+
```python
|
| 459 |
+
AndroidAction(tool_name="<action>", parameters={...})
|
| 460 |
+
```
|
| 461 |
+
|
| 462 |
+
### Gesture Actions
|
| 463 |
+
|
| 464 |
+
| Action | Parameters | Description |
|
| 465 |
+
|--------|------------|-------------|
|
| 466 |
+
| `tap` | `x`, `y` | Single tap at normalized coordinates [0,1] |
|
| 467 |
+
| `swipe` | `x1`, `y1`, `x2`, `y2`, `duration_ms` (optional) | Swipe from (x1,y1) to (x2,y2) |
|
| 468 |
+
| `long_press` | `x`, `y`, `duration_ms` (optional, default 1000) | Hold touch at point |
|
| 469 |
+
| `double_tap` | `x`, `y` | Two rapid taps at same point |
|
| 470 |
+
| `scroll_down` | `x` (optional), `distance` (optional) | Scroll down (swipe up) |
|
| 471 |
+
| `scroll_up` | `x` (optional), `distance` (optional) | Scroll up (swipe down) |
|
| 472 |
+
| `swipe_left` | `y` (optional), `distance` (optional) | Swipe left |
|
| 473 |
+
| `swipe_right` | `y` (optional), `distance` (optional) | Swipe right |
|
| 474 |
+
|
| 475 |
+
### System Actions
|
| 476 |
+
|
| 477 |
+
| Action | Parameters | Description |
|
| 478 |
+
|--------|------------|-------------|
|
| 479 |
+
| `type_text` | `text` | Input text via ADB (supports unicode, emojis) |
|
| 480 |
+
| `press_button` | `button` | Press system button (HOME, BACK, MENU, ENTER, SEARCH, DELETE, TAB, SPACE) |
|
| 481 |
+
|
| 482 |
+
### Coordinate System
|
| 483 |
+
|
| 484 |
+
All coordinates are **normalized** to [0, 1]:
|
| 485 |
+
- `x=0.0`: Left edge, `x=1.0`: Right edge
|
| 486 |
+
- `y=0.0`: Top edge, `y=1.0`: Bottom edge
|
| 487 |
+
- Out-of-bounds values automatically clipped
|
| 488 |
+
|
| 489 |
+
Example:
|
| 490 |
+
```python
|
| 491 |
+
# Tap at top-left corner
|
| 492 |
+
AndroidAction("tap", {"x": 0.0, "y": 0.0})
|
| 493 |
+
|
| 494 |
+
# Tap at center
|
| 495 |
+
AndroidAction("tap", {"x": 0.5, "y": 0.5})
|
| 496 |
+
|
| 497 |
+
# Tap at bottom-right corner
|
| 498 |
+
AndroidAction("tap", {"x": 1.0, "y": 1.0})
|
| 499 |
+
|
| 500 |
+
# Out-of-bounds (automatically clipped to [0, 1])
|
| 501 |
+
AndroidAction("tap", {"x": 1.5, "y": -0.5}) # → clipped to (1.0, 0.0)
|
| 502 |
+
```
|
| 503 |
+
|
| 504 |
+
## Observation Reference
|
| 505 |
+
|
| 506 |
+
```python
|
| 507 |
+
@dataclass
|
| 508 |
+
class AndroidObservation(Observation):
|
| 509 |
+
screen_image: str # Base64 JPEG/PNG or "shm://<name>" if shared memory
|
| 510 |
+
screen_width: int # Pixel width
|
| 511 |
+
screen_height: int # Pixel height
|
| 512 |
+
timestamp_ms: int # Unix timestamp (milliseconds)
|
| 513 |
+
orientation: int # Screen rotation (0, 90, 180, 270)
|
| 514 |
+
pixels_shape: Tuple[int, int, int] # (height, width, channels=3)
|
| 515 |
+
extras: Dict[str, Any] # Task-specific data
|
| 516 |
+
done: bool # Episode terminated
|
| 517 |
+
reward: float # Immediate reward
|
| 518 |
+
metadata: Dict[str, Any] # Additional info
|
| 519 |
+
```
|
| 520 |
+
|
| 521 |
+
### Decoding Observations
|
| 522 |
+
|
| 523 |
+
**Base64 (default)**:
|
| 524 |
+
```python
|
| 525 |
+
import base64
|
| 526 |
+
from PIL import Image
|
| 527 |
+
from io import BytesIO
|
| 528 |
+
|
| 529 |
+
obs = env.reset()
|
| 530 |
+
image_bytes = base64.b64decode(obs.screen_image)
|
| 531 |
+
image = Image.open(BytesIO(image_bytes))
|
| 532 |
+
pixels = np.array(image) # (height, width, 3)
|
| 533 |
+
```
|
| 534 |
+
|
| 535 |
+
**Shared Memory** (zero-copy, same machine only):
|
| 536 |
+
```python
|
| 537 |
+
from multiprocessing import shared_memory
|
| 538 |
+
|
| 539 |
+
obs = env.reset()
|
| 540 |
+
# obs.screen_image = "shm://android_pool_0"
|
| 541 |
+
shm_name = obs.screen_image.replace("shm://", "")
|
| 542 |
+
shm = shared_memory.SharedMemory(name=shm_name)
|
| 543 |
+
pixels = np.ndarray(
|
| 544 |
+
(obs.screen_height, obs.screen_width, 3),
|
| 545 |
+
dtype=np.uint8,
|
| 546 |
+
buffer=shm.buf
|
| 547 |
+
)
|
| 548 |
+
```
|
| 549 |
+
|
| 550 |
+
## Configuration
|
| 551 |
+
|
| 552 |
+
### Environment Variables
|
| 553 |
+
|
| 554 |
+
| Variable | Description | Default | Required |
|
| 555 |
+
|----------|-------------|---------|----------|
|
| 556 |
+
| `ANDROID_AVD_NAME` | Android Virtual Device name | - | ✅ |
|
| 557 |
+
| `ANDROID_TASK_PATH` | Task textproto path | - | ✅ |
|
| 558 |
+
| `ANDROID_ADB_PATH` | ADB executable path | `~/Android/Sdk/platform-tools/adb` | ❌ |
|
| 559 |
+
| `ANDROID_EMULATOR_PATH` | Emulator executable path | `~/Android/Sdk/emulator/emulator` | ❌ |
|
| 560 |
+
| `ANDROID_AVD_HOME` | AVD home directory | `~/.android/avd` | ❌ |
|
| 561 |
+
| `ANDROID_SDK_ROOT` | SDK root directory | `~/Android/Sdk` | ❌ |
|
| 562 |
+
| `ANDROID_RUN_HEADLESS` | Run headless | `true` | ❌ |
|
| 563 |
+
| `ANDROID_IMAGE_FORMAT` | Image encoding | `JPEG` | ❌ |
|
| 564 |
+
| `ANDROID_IMAGE_QUALITY` | JPEG quality (1-100) | `85` | ❌ |
|
| 565 |
+
|
| 566 |
+
### Image Encoding Trade-offs
|
| 567 |
+
|
| 568 |
+
| Format | Size | Latency | Quality | Use Case |
|
| 569 |
+
|--------|------|---------|---------|----------|
|
| 570 |
+
| JPEG 85 (default) | ~150KB | ~40ms | Good | General use |
|
| 571 |
+
| JPEG 50 | ~80KB | ~35ms | Acceptable | Bandwidth-limited |
|
| 572 |
+
| PNG | ~2MB | ~60ms | Perfect | Debugging, screenshots |
|
| 573 |
+
| Shared Memory | 0 (zero-copy) | ~1ms | Perfect | High-throughput parallel training (same machine) |
|
| 574 |
+
|
| 575 |
+
## Performance Guide
|
| 576 |
+
|
| 577 |
+
### Emulator Pool Sizing
|
| 578 |
+
|
| 579 |
+
Calculate optimal pool size:
|
| 580 |
+
```python
|
| 581 |
+
# Available resources
|
| 582 |
+
num_cpu_cores = 256
|
| 583 |
+
total_ram_gb = 512
|
| 584 |
+
|
| 585 |
+
# Per-emulator requirements
|
| 586 |
+
cpu_per_emulator = 4
|
| 587 |
+
ram_per_emulator = 8 # GB
|
| 588 |
+
|
| 589 |
+
# Maximum pool sizes
|
| 590 |
+
max_pool_cpu = num_cpu_cores // cpu_per_emulator # 256 / 4 = 64
|
| 591 |
+
max_pool_ram = total_ram_gb // ram_per_emulator # 512 / 8 = 64
|
| 592 |
+
|
| 593 |
+
pool_size = min(max_pool_cpu, max_pool_ram) # 64 emulators
|
| 594 |
+
```
|
| 595 |
+
|
| 596 |
+
### Shared Memory vs Base64
|
| 597 |
+
|
| 598 |
+
**Use Shared Memory when**:
|
| 599 |
+
- Training on single machine (client + server same host)
|
| 600 |
+
- Need maximum throughput (1000+ fps)
|
| 601 |
+
- Have sufficient RAM (3× pixel buffer size per emulator)
|
| 602 |
+
|
| 603 |
+
**Use Base64 when**:
|
| 604 |
+
- Client and server on different machines
|
| 605 |
+
- Limited RAM
|
| 606 |
+
- Moderate throughput acceptable (25-100 fps)
|
| 607 |
+
|
| 608 |
+
### Expected Performance
|
| 609 |
+
|
| 610 |
+
**Single Environment** (no pool):
|
| 611 |
+
- Boot time: 30-60s (one-time per environment)
|
| 612 |
+
- Reset time: 1-2s (app reset)
|
| 613 |
+
- Step time: 50-100ms (40ms encoding + 10-60ms emulator)
|
| 614 |
+
- Throughput: ~10-20 fps
|
| 615 |
+
|
| 616 |
+
**EmulatorPool** (64 emulators, 64 workers, shared memory):
|
| 617 |
+
- Boot time: 64 × 60s = 64 min (one-time)
|
| 618 |
+
- Reset time: 1-2s (app reset)
|
| 619 |
+
- Step time: 10-60ms (1ms observation + 10-60ms emulator)
|
| 620 |
+
- Throughput: ~1000-5000 fps aggregate (64 × 15-80 fps)
|
| 621 |
+
- Speedup: 100× vs sequential
|
| 622 |
+
|
| 623 |
+
## Troubleshooting
|
| 624 |
+
|
| 625 |
+
### Emulator Won't Start
|
| 626 |
+
|
| 627 |
+
```bash
|
| 628 |
+
# Check KVM
|
| 629 |
+
ls -l /dev/kvm # Should show crw-rw-rw-
|
| 630 |
+
|
| 631 |
+
# Verify Docker has KVM access
|
| 632 |
+
docker run --rm --device /dev/kvm ubuntu ls -l /dev/kvm
|
| 633 |
+
|
| 634 |
+
# Check emulator logs
|
| 635 |
+
docker logs <container_id>
|
| 636 |
+
```
|
| 637 |
+
|
| 638 |
+
### Out of Memory
|
| 639 |
+
|
| 640 |
+
```bash
|
| 641 |
+
# Reduce AVD RAM
|
| 642 |
+
vim ~/.android/avd/<avd_name>.avd/config.ini
|
| 643 |
+
# Set: hw.ramSize=2048
|
| 644 |
+
|
| 645 |
+
# Or increase Docker memory limit
|
| 646 |
+
docker run --memory="16g" ...
|
| 647 |
+
```
|
| 648 |
+
|
| 649 |
+
### Pool Exhaustion
|
| 650 |
+
|
| 651 |
+
```python
|
| 652 |
+
# Increase timeout
|
| 653 |
+
env = pool.get(timeout=120) # Wait up to 2 min
|
| 654 |
+
|
| 655 |
+
# Or increase pool size
|
| 656 |
+
pool = EmulatorPool(pool_size=128, ...) # More emulators
|
| 657 |
+
```
|
| 658 |
+
|
| 659 |
+
### Shared Memory Errors
|
| 660 |
+
|
| 661 |
+
```bash
|
| 662 |
+
# Check shared memory size limit
|
| 663 |
+
df -h /dev/shm
|
| 664 |
+
|
| 665 |
+
# Increase if needed (requires root)
|
| 666 |
+
mount -o remount,size=32G /dev/shm
|
| 667 |
+
```
|
| 668 |
+
|
| 669 |
+
## Documentation
|
| 670 |
+
|
| 671 |
+
- **Setup Guide**: `COMPLETE_SETUP_GUIDE.md` - Step-by-step setup with troubleshooting
|
| 672 |
+
- **Integration Guide**: `INTEGRATION_COMPLETE.md` - Architecture and design decisions
|
| 673 |
+
- **Test Documentation**: `tests/COVERAGE_ANALYSIS.md` - Test coverage and strategy
|
| 674 |
+
- **Example Code**: `examples/` - Working examples and templates
|
| 675 |
+
|
| 676 |
+
## References
|
| 677 |
+
|
| 678 |
+
- [android_env GitHub](https://github.com/deepmind/android_env)
|
| 679 |
+
- [android_env Paper](https://arxiv.org/abs/2105.13231) - "AndroidEnv: A Reinforcement Learning Platform for Android"
|
| 680 |
+
- [OpenEnv RFCs](../../rfcs/) - RFC 001-004 compliance
|
| 681 |
+
- [DeepMind android_env Tasks Guide](https://github.com/deepmind/android_env/blob/main/docs/tasks_guide.md)
|
| 682 |
+
|
| 683 |
+
## License
|
| 684 |
+
|
| 685 |
+
BSD-3-Clause License (consistent with OpenEnv)
|
| 686 |
+
|
| 687 |
+
The underlying android_env is licensed under Apache 2.0 by DeepMind.
|
src/envs/android_env/__init__.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
Android Environment for OpenEnv.
|
| 9 |
+
|
| 10 |
+
This environment wraps DeepMind's android_env to provide RL agents with
|
| 11 |
+
access to Android applications and the Android operating system through
|
| 12 |
+
the OpenEnv framework.
|
| 13 |
+
|
| 14 |
+
The environment exposes Android devices as RL environments where agents
|
| 15 |
+
interact via touchscreen gestures and observe RGB pixel screens.
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
from envs.android_env.client import AndroidEnv
|
| 19 |
+
from envs.android_env.models import AndroidAction, AndroidObservation
|
| 20 |
+
|
| 21 |
+
__all__ = ["AndroidEnv", "AndroidAction", "AndroidObservation"]
|
src/envs/android_env/client.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
Android Environment HTTP Client.
|
| 9 |
+
|
| 10 |
+
This module provides the client for connecting to an Android Environment server
|
| 11 |
+
over HTTP.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from typing import Any, Dict
|
| 15 |
+
|
| 16 |
+
from core.client_types import StepResult
|
| 17 |
+
from core.env_server.types import State
|
| 18 |
+
from core.http_env_client import HTTPEnvClient
|
| 19 |
+
|
| 20 |
+
from .models import AndroidAction, AndroidObservation
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class AndroidEnv(HTTPEnvClient[AndroidAction, AndroidObservation]):
|
| 24 |
+
"""
|
| 25 |
+
HTTP client for the Android Environment.
|
| 26 |
+
|
| 27 |
+
This client connects to an AndroidEnvironment HTTP server running in a
|
| 28 |
+
container with an Android emulator. It provides methods to interact with
|
| 29 |
+
Android applications through touchscreen gestures.
|
| 30 |
+
|
| 31 |
+
Example:
|
| 32 |
+
>>> # Connect to a running server
|
| 33 |
+
>>> client = AndroidEnv(base_url="http://localhost:8000")
|
| 34 |
+
>>> result = client.reset()
|
| 35 |
+
>>> print(result.observation.screen_width, result.observation.screen_height)
|
| 36 |
+
>>>
|
| 37 |
+
>>> # Tap on the screen
|
| 38 |
+
>>> result = client.step(
|
| 39 |
+
... AndroidAction(tool_name="tap", parameters={"x": 0.5, "y": 0.3})
|
| 40 |
+
... )
|
| 41 |
+
>>> print(result.reward, result.done)
|
| 42 |
+
|
| 43 |
+
Example with Docker:
|
| 44 |
+
>>> # Automatically start container and connect
|
| 45 |
+
>>> client = AndroidEnv.from_docker_image(
|
| 46 |
+
... "android-env:latest",
|
| 47 |
+
... environment={
|
| 48 |
+
... "ANDROID_AVD_NAME": "Pixel_6_API_33",
|
| 49 |
+
... "ANDROID_TASK_PATH": "/workspace/tasks/my_task.textproto"
|
| 50 |
+
... }
|
| 51 |
+
... )
|
| 52 |
+
>>> result = client.reset()
|
| 53 |
+
>>> result = client.step(
|
| 54 |
+
... AndroidAction(tool_name="tap", parameters={"x": 0.5, "y": 0.5})
|
| 55 |
+
... )
|
| 56 |
+
>>> # View screen image (base64)
|
| 57 |
+
>>> print(result.observation.screen_image[:50]) # First 50 chars
|
| 58 |
+
>>> client.close()
|
| 59 |
+
|
| 60 |
+
Example with high-level gestures:
|
| 61 |
+
>>> # Swipe gesture
|
| 62 |
+
>>> result = client.step(AndroidAction(
|
| 63 |
+
... tool_name="swipe",
|
| 64 |
+
... parameters={"x1": 0.5, "y1": 0.8, "x2": 0.5, "y2": 0.2}
|
| 65 |
+
... ))
|
| 66 |
+
>>>
|
| 67 |
+
>>> # Type text (if supported by task)
|
| 68 |
+
>>> result = client.step(AndroidAction(
|
| 69 |
+
... tool_name="type_text",
|
| 70 |
+
... parameters={"text": "Hello Android"}
|
| 71 |
+
... ))
|
| 72 |
+
>>>
|
| 73 |
+
>>> # Press system button
|
| 74 |
+
>>> result = client.step(AndroidAction(
|
| 75 |
+
... tool_name="press_button",
|
| 76 |
+
... parameters={"button": "HOME"}
|
| 77 |
+
... ))
|
| 78 |
+
"""
|
| 79 |
+
|
| 80 |
+
def _step_payload(self, action: AndroidAction) -> Dict:
|
| 81 |
+
"""
|
| 82 |
+
Convert AndroidAction to JSON payload for step request.
|
| 83 |
+
|
| 84 |
+
Args:
|
| 85 |
+
action: AndroidAction instance with tool_name and parameters.
|
| 86 |
+
|
| 87 |
+
Returns:
|
| 88 |
+
Dictionary representation suitable for JSON encoding.
|
| 89 |
+
"""
|
| 90 |
+
return {
|
| 91 |
+
"tool_name": action.tool_name,
|
| 92 |
+
"parameters": action.parameters,
|
| 93 |
+
"metadata": action.metadata,
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
def _parse_result(self, payload: Dict) -> StepResult[AndroidObservation]:
|
| 97 |
+
"""
|
| 98 |
+
Parse server response into StepResult[AndroidObservation].
|
| 99 |
+
|
| 100 |
+
Args:
|
| 101 |
+
payload: JSON response from server.
|
| 102 |
+
|
| 103 |
+
Returns:
|
| 104 |
+
StepResult with AndroidObservation containing screen state.
|
| 105 |
+
"""
|
| 106 |
+
obs_data = payload.get("observation", {})
|
| 107 |
+
|
| 108 |
+
observation = AndroidObservation(
|
| 109 |
+
screen_image=obs_data.get("screen_image", ""),
|
| 110 |
+
screen_width=obs_data.get("screen_width", 0),
|
| 111 |
+
screen_height=obs_data.get("screen_height", 0),
|
| 112 |
+
timestamp_ms=obs_data.get("timestamp_ms", 0),
|
| 113 |
+
orientation=obs_data.get("orientation", 0),
|
| 114 |
+
extras=obs_data.get("extras", {}),
|
| 115 |
+
pixels_shape=obs_data.get("pixels_shape"),
|
| 116 |
+
done=obs_data.get("done", False),
|
| 117 |
+
reward=obs_data.get("reward"),
|
| 118 |
+
metadata=obs_data.get("metadata", {}),
|
| 119 |
+
)
|
| 120 |
+
|
| 121 |
+
return StepResult(
|
| 122 |
+
observation=observation,
|
| 123 |
+
reward=obs_data.get("reward"),
|
| 124 |
+
done=obs_data.get("done", False),
|
| 125 |
+
)
|
| 126 |
+
|
| 127 |
+
def _parse_state(self, payload: Dict) -> State:
|
| 128 |
+
"""
|
| 129 |
+
Parse server response into State object.
|
| 130 |
+
|
| 131 |
+
Args:
|
| 132 |
+
payload: JSON response from /state endpoint.
|
| 133 |
+
|
| 134 |
+
Returns:
|
| 135 |
+
State object with episode_id and step_count.
|
| 136 |
+
"""
|
| 137 |
+
return State(
|
| 138 |
+
episode_id=payload.get("episode_id"),
|
| 139 |
+
step_count=payload.get("step_count", 0),
|
| 140 |
+
)
|
src/envs/android_env/docker-compose.hpc.yml
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# High-Performance Docker Compose configuration
|
| 2 |
+
#
|
| 3 |
+
# Use this overlay for large-scale deployments with optimizations for:
|
| 4 |
+
# - High parallelism (64+ instances)
|
| 5 |
+
# - Shared memory optimization
|
| 6 |
+
# - Resource allocation tuning
|
| 7 |
+
#
|
| 8 |
+
# Usage:
|
| 9 |
+
# docker-compose -f docker-compose.yml -f docker-compose.hpc.yml up --scale android-env=64
|
| 10 |
+
|
| 11 |
+
version: '3.8'
|
| 12 |
+
|
| 13 |
+
services:
|
| 14 |
+
android-env:
|
| 15 |
+
# High-performance optimizations
|
| 16 |
+
environment:
|
| 17 |
+
# Use shared memory for zero-copy observations
|
| 18 |
+
- ANDROID_USE_SHARED_MEMORY=true
|
| 19 |
+
# Higher quality since we have resources
|
| 20 |
+
- ANDROID_IMAGE_QUALITY=95
|
| 21 |
+
|
| 22 |
+
# Resource allocation for parallel instances
|
| 23 |
+
deploy:
|
| 24 |
+
resources:
|
| 25 |
+
limits:
|
| 26 |
+
cpus: '4'
|
| 27 |
+
memory: 8G
|
| 28 |
+
reservations:
|
| 29 |
+
cpus: '2'
|
| 30 |
+
memory: 6G
|
| 31 |
+
|
| 32 |
+
# Placement constraints (optional - use specific nodes)
|
| 33 |
+
# placement:
|
| 34 |
+
# constraints:
|
| 35 |
+
# - node.labels.type == hpc
|
| 36 |
+
|
| 37 |
+
# Shared memory size for IPC
|
| 38 |
+
shm_size: '2gb'
|
| 39 |
+
|
| 40 |
+
# Privileged mode for better KVM access (use with caution)
|
| 41 |
+
# privileged: true
|
| 42 |
+
|
| 43 |
+
# CPU affinity for NUMA optimization
|
| 44 |
+
# cpuset: "0-3"
|
src/envs/android_env/docker-compose.yml
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version: '3.8'
|
| 2 |
+
|
| 3 |
+
# Docker Compose configuration for Android Environment
|
| 4 |
+
#
|
| 5 |
+
# This file enables easy deployment of multiple Android emulator instances
|
| 6 |
+
# for large-scale parallel training.
|
| 7 |
+
#
|
| 8 |
+
# Usage:
|
| 9 |
+
# # Single instance
|
| 10 |
+
# docker-compose up
|
| 11 |
+
#
|
| 12 |
+
# # Scale to 10 instances
|
| 13 |
+
# docker-compose up --scale android-env=10
|
| 14 |
+
#
|
| 15 |
+
# # With GPU nodes
|
| 16 |
+
# docker-compose -f docker-compose.yml -f docker-compose.hpc.yml up
|
| 17 |
+
|
| 18 |
+
services:
|
| 19 |
+
# Main Android environment service
|
| 20 |
+
android-env:
|
| 21 |
+
build:
|
| 22 |
+
context: ../../..
|
| 23 |
+
dockerfile: src/envs/android_env/server/Dockerfile
|
| 24 |
+
args:
|
| 25 |
+
BASE_IMAGE: openenv-base:latest
|
| 26 |
+
image: android-env:latest
|
| 27 |
+
|
| 28 |
+
# Environment configuration
|
| 29 |
+
environment:
|
| 30 |
+
# Required
|
| 31 |
+
- ANDROID_AVD_NAME=${ANDROID_AVD_NAME:-default_pixel_6}
|
| 32 |
+
- ANDROID_TASK_PATH=${ANDROID_TASK_PATH:-/workspace/tasks/calculator_basic.textproto}
|
| 33 |
+
|
| 34 |
+
# Optional
|
| 35 |
+
- ANDROID_ADB_PATH=${ANDROID_ADB_PATH:-/opt/android-sdk/platform-tools/adb}
|
| 36 |
+
- ANDROID_EMULATOR_PATH=${ANDROID_EMULATOR_PATH:-/opt/android-sdk/emulator/emulator}
|
| 37 |
+
- ANDROID_AVD_HOME=${ANDROID_AVD_HOME:-/root/.android/avd}
|
| 38 |
+
- ANDROID_SDK_ROOT=${ANDROID_SDK_ROOT:-/opt/android-sdk}
|
| 39 |
+
- ANDROID_RUN_HEADLESS=${ANDROID_RUN_HEADLESS:-true}
|
| 40 |
+
- ANDROID_IMAGE_FORMAT=${ANDROID_IMAGE_FORMAT:-JPEG}
|
| 41 |
+
- ANDROID_IMAGE_QUALITY=${ANDROID_IMAGE_QUALITY:-85}
|
| 42 |
+
|
| 43 |
+
# Port mapping
|
| 44 |
+
ports:
|
| 45 |
+
- "8000-8099:8000" # Allow port range for scaling
|
| 46 |
+
|
| 47 |
+
# Volume mounts
|
| 48 |
+
volumes:
|
| 49 |
+
# Mount tasks directory
|
| 50 |
+
- ./examples/tasks:/workspace/tasks:ro
|
| 51 |
+
# Mount apps directory (for custom APKs)
|
| 52 |
+
- ${ANDROID_APPS_DIR:-./examples/apps}:/workspace/apps:ro
|
| 53 |
+
# Optional: Persist AVD data
|
| 54 |
+
# - android-avd-data:/root/.android/avd
|
| 55 |
+
|
| 56 |
+
# Device access for KVM hardware acceleration
|
| 57 |
+
devices:
|
| 58 |
+
- /dev/kvm:/dev/kvm
|
| 59 |
+
|
| 60 |
+
# Resource limits
|
| 61 |
+
deploy:
|
| 62 |
+
resources:
|
| 63 |
+
limits:
|
| 64 |
+
cpus: '4'
|
| 65 |
+
memory: 8G
|
| 66 |
+
reservations:
|
| 67 |
+
cpus: '2'
|
| 68 |
+
memory: 4G
|
| 69 |
+
|
| 70 |
+
# Restart policy
|
| 71 |
+
restart: unless-stopped
|
| 72 |
+
|
| 73 |
+
# Health check
|
| 74 |
+
healthcheck:
|
| 75 |
+
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
| 76 |
+
interval: 30s
|
| 77 |
+
timeout: 10s
|
| 78 |
+
retries: 3
|
| 79 |
+
start_period: 120s # Emulator takes time to boot
|
| 80 |
+
|
| 81 |
+
# Logging
|
| 82 |
+
logging:
|
| 83 |
+
driver: "json-file"
|
| 84 |
+
options:
|
| 85 |
+
max-size: "10m"
|
| 86 |
+
max-file: "3"
|
| 87 |
+
|
| 88 |
+
# Optional: Uncomment to persist AVD data across container restarts
|
| 89 |
+
# volumes:
|
| 90 |
+
# android-avd-data:
|
| 91 |
+
# driver: local
|
| 92 |
+
|
| 93 |
+
networks:
|
| 94 |
+
default:
|
| 95 |
+
driver: bridge
|
src/envs/android_env/examples/tasks/README.md
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Android Environment Task Definitions
|
| 2 |
+
|
| 3 |
+
This directory contains task definition files for the Android environment. Tasks define what app to run, how to set it up, and how to reset between episodes.
|
| 4 |
+
|
| 5 |
+
## Task File Format
|
| 6 |
+
|
| 7 |
+
Tasks are defined in Protocol Buffer text format (`.textproto`). Here's the basic structure:
|
| 8 |
+
|
| 9 |
+
```protobuf
|
| 10 |
+
id: "task_id"
|
| 11 |
+
name: "Task Name"
|
| 12 |
+
description: "What this task does"
|
| 13 |
+
|
| 14 |
+
setup_steps: [
|
| 15 |
+
# Steps to set up the task (run once at environment creation)
|
| 16 |
+
{
|
| 17 |
+
adb_request: {
|
| 18 |
+
install_apk: { filesystem: { path: "/path/to/app.apk" } }
|
| 19 |
+
}
|
| 20 |
+
},
|
| 21 |
+
{
|
| 22 |
+
adb_request: {
|
| 23 |
+
start_activity: { full_activity: "com.example.app/.MainActivity" }
|
| 24 |
+
}
|
| 25 |
+
}
|
| 26 |
+
]
|
| 27 |
+
|
| 28 |
+
reset_steps: [
|
| 29 |
+
# Steps to reset between episodes
|
| 30 |
+
{
|
| 31 |
+
adb_request: {
|
| 32 |
+
force_stop: { package_name: "com.example.app" }
|
| 33 |
+
}
|
| 34 |
+
}
|
| 35 |
+
]
|
| 36 |
+
|
| 37 |
+
expected_app_screen: {
|
| 38 |
+
activity: "com.example.app/.MainActivity"
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
max_episode_sec: 120
|
| 42 |
+
max_num_steps: 200
|
| 43 |
+
```
|
| 44 |
+
|
| 45 |
+
## Available Examples
|
| 46 |
+
|
| 47 |
+
- **calculator_basic.textproto**: Simple calculator app interaction (uses built-in Android calculator)
|
| 48 |
+
|
| 49 |
+
## Common ADB Requests
|
| 50 |
+
|
| 51 |
+
### Install APK
|
| 52 |
+
```protobuf
|
| 53 |
+
adb_request: {
|
| 54 |
+
install_apk: {
|
| 55 |
+
filesystem: { path: "/workspace/apps/myapp.apk" }
|
| 56 |
+
}
|
| 57 |
+
}
|
| 58 |
+
```
|
| 59 |
+
|
| 60 |
+
### Start Activity
|
| 61 |
+
```protobuf
|
| 62 |
+
adb_request: {
|
| 63 |
+
start_activity: {
|
| 64 |
+
full_activity: "com.example.myapp/.MainActivity"
|
| 65 |
+
force_stop: true
|
| 66 |
+
}
|
| 67 |
+
}
|
| 68 |
+
```
|
| 69 |
+
|
| 70 |
+
### Force Stop
|
| 71 |
+
```protobuf
|
| 72 |
+
adb_request: {
|
| 73 |
+
force_stop: {
|
| 74 |
+
package_name: "com.example.myapp"
|
| 75 |
+
}
|
| 76 |
+
}
|
| 77 |
+
```
|
| 78 |
+
|
| 79 |
+
### Send Broadcast
|
| 80 |
+
```protobuf
|
| 81 |
+
adb_request: {
|
| 82 |
+
broadcast: {
|
| 83 |
+
action: "android.intent.action.BOOT_COMPLETED"
|
| 84 |
+
}
|
| 85 |
+
}
|
| 86 |
+
```
|
| 87 |
+
|
| 88 |
+
## Creating Custom Tasks
|
| 89 |
+
|
| 90 |
+
1. **Find your app's package and activity**:
|
| 91 |
+
```bash
|
| 92 |
+
# Get package name
|
| 93 |
+
adb shell pm list packages | grep myapp
|
| 94 |
+
|
| 95 |
+
# Get main activity
|
| 96 |
+
adb shell dumpsys package com.example.myapp | grep -A 1 "android.intent.action.MAIN"
|
| 97 |
+
```
|
| 98 |
+
|
| 99 |
+
2. **Create task file**: Copy `calculator_basic.textproto` and modify for your app
|
| 100 |
+
|
| 101 |
+
3. **Test the task**:
|
| 102 |
+
```bash
|
| 103 |
+
docker run -it --device /dev/kvm \
|
| 104 |
+
-v $(pwd):/workspace/tasks \
|
| 105 |
+
android-env:latest \
|
| 106 |
+
--task-path /workspace/tasks/my_task.textproto
|
| 107 |
+
```
|
| 108 |
+
|
| 109 |
+
4. **Use in training**: Mount your task file when creating the environment
|
| 110 |
+
|
| 111 |
+
## Task Rewards
|
| 112 |
+
|
| 113 |
+
Tasks can define custom reward signals based on:
|
| 114 |
+
- Screen content matching
|
| 115 |
+
- Log events
|
| 116 |
+
- Time-based rewards
|
| 117 |
+
- Custom reward functions
|
| 118 |
+
|
| 119 |
+
See the [android_env documentation](https://github.com/deepmind/android_env/blob/main/docs/tasks_guide.md) for full details.
|
| 120 |
+
|
| 121 |
+
## Tips
|
| 122 |
+
|
| 123 |
+
- Use `force_stop: true` in `start_activity` to ensure clean state
|
| 124 |
+
- Set reasonable `max_episode_sec` to prevent infinite episodes
|
| 125 |
+
- Test your task manually with ADB commands first
|
| 126 |
+
- Use `wait_for_app_screen` in success conditions to ensure app is ready
|
| 127 |
+
|
| 128 |
+
## References
|
| 129 |
+
|
| 130 |
+
- [android_env Tasks Guide](https://github.com/deepmind/android_env/blob/main/docs/tasks_guide.md)
|
| 131 |
+
- [android_env Task Proto Definition](https://github.com/deepmind/android_env/blob/main/android_env/proto/task.proto)
|
| 132 |
+
- [ADB Commands Reference](https://developer.android.com/tools/adb)
|
src/envs/android_env/examples/tasks/calculator_basic.textproto
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Basic Calculator Task
|
| 2 |
+
# This is a simple task for testing Android environment interaction.
|
| 3 |
+
# It opens the Android calculator app and allows free exploration.
|
| 4 |
+
|
| 5 |
+
id: "calculator_basic"
|
| 6 |
+
name: "Calculator Basic"
|
| 7 |
+
description: "Interact with the Android Calculator app"
|
| 8 |
+
|
| 9 |
+
# Setup steps: Install and launch calculator
|
| 10 |
+
setup_steps: [
|
| 11 |
+
{
|
| 12 |
+
adb_request: {
|
| 13 |
+
start_activity: {
|
| 14 |
+
full_activity: "com.google.android.calculator/.Calculator"
|
| 15 |
+
force_stop: true
|
| 16 |
+
}
|
| 17 |
+
}
|
| 18 |
+
success_condition: {
|
| 19 |
+
wait_for_app_screen: {
|
| 20 |
+
app_screen: {
|
| 21 |
+
activity: "com.google.android.calculator/.Calculator"
|
| 22 |
+
}
|
| 23 |
+
timeout_sec: 10.0
|
| 24 |
+
}
|
| 25 |
+
}
|
| 26 |
+
}
|
| 27 |
+
]
|
| 28 |
+
|
| 29 |
+
# Reset steps: Force stop and restart
|
| 30 |
+
reset_steps: [
|
| 31 |
+
{
|
| 32 |
+
adb_request: {
|
| 33 |
+
force_stop: {
|
| 34 |
+
package_name: "com.google.android.calculator"
|
| 35 |
+
}
|
| 36 |
+
}
|
| 37 |
+
},
|
| 38 |
+
{
|
| 39 |
+
adb_request: {
|
| 40 |
+
start_activity: {
|
| 41 |
+
full_activity: "com.google.android.calculator/.Calculator"
|
| 42 |
+
}
|
| 43 |
+
}
|
| 44 |
+
success_condition: {
|
| 45 |
+
wait_for_app_screen: {
|
| 46 |
+
app_screen: {
|
| 47 |
+
activity: "com.google.android.calculator/.Calculator"
|
| 48 |
+
}
|
| 49 |
+
timeout_sec: 10.0
|
| 50 |
+
}
|
| 51 |
+
}
|
| 52 |
+
}
|
| 53 |
+
]
|
| 54 |
+
|
| 55 |
+
# Expected app screen
|
| 56 |
+
expected_app_screen: {
|
| 57 |
+
activity: "com.google.android.calculator/.Calculator"
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
# Episode configuration
|
| 61 |
+
max_episode_sec: 60 # 1 minute episodes
|
| 62 |
+
max_num_steps: 100 # Maximum 100 steps per episode
|
src/envs/android_env/models.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Copyright (c) Meta Platforms, Inc. and affiliates.
|
| 2 |
+
# All rights reserved.
|
| 3 |
+
#
|
| 4 |
+
# This source code is licensed under the BSD-style license found in the
|
| 5 |
+
# LICENSE file in the root directory of this source tree.
|
| 6 |
+
|
| 7 |
+
"""
|
| 8 |
+
Data models for the Android Environment.
|
| 9 |
+
|
| 10 |
+
The Android environment provides access to Android applications and the
|
| 11 |
+
Android OS through a touchscreen interface. Actions represent touch events
|
| 12 |
+
and gestures, while observations contain screen pixels and metadata.
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
from dataclasses import dataclass, field
|
| 16 |
+
from typing import Any, Dict, Optional
|
| 17 |
+
|
| 18 |
+
from core.env_server.types import Action, Observation
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
@dataclass(kw_only=True)
|
| 22 |
+
class AndroidAction(Action):
|
| 23 |
+
"""Action for the Android environment.
|
| 24 |
+
|
| 25 |
+
Supports multiple interaction types following RFC 004's ToolCallAction pattern.
|
| 26 |
+
|
| 27 |
+
Examples:
|
| 28 |
+
# Tap at specific coordinates
|
| 29 |
+
AndroidAction(
|
| 30 |
+
tool_name="tap",
|
| 31 |
+
parameters={"x": 0.5, "y": 0.3}
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
# Swipe gesture
|
| 35 |
+
AndroidAction(
|
| 36 |
+
tool_name="swipe",
|
| 37 |
+
parameters={"x1": 0.2, "y1": 0.5, "x2": 0.8, "y2": 0.5, "duration_ms": 300}
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
# Type text
|
| 41 |
+
AndroidAction(
|
| 42 |
+
tool_name="type_text",
|
| 43 |
+
parameters={"text": "Hello World"}
|
| 44 |
+
)
|
| 45 |
+
|
| 46 |
+
# Press system button
|
| 47 |
+
AndroidAction(
|
| 48 |
+
tool_name="press_button",
|
| 49 |
+
parameters={"button": "HOME"} # HOME, BACK, MENU, etc.
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
+
# Raw touch event (for advanced control)
|
| 53 |
+
AndroidAction(
|
| 54 |
+
tool_name="touch_event",
|
| 55 |
+
parameters={
|
| 56 |
+
"action_type": "TOUCH", # TOUCH, LIFT, REPEAT
|
| 57 |
+
"touch_position": [0.5, 0.3], # normalized [0, 1]
|
| 58 |
+
"duration_ms": 100
|
| 59 |
+
}
|
| 60 |
+
)
|
| 61 |
+
"""
|
| 62 |
+
|
| 63 |
+
tool_name: str # Action type: "tap", "swipe", "type_text", "press_button", "touch_event"
|
| 64 |
+
parameters: Dict[str, Any] = field(default_factory=dict)
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
@dataclass(kw_only=True)
|
| 68 |
+
class AndroidObservation(Observation):
|
| 69 |
+
"""Observation from the Android environment.
|
| 70 |
+
|
| 71 |
+
Contains the current screen state as an image plus additional metadata
|
| 72 |
+
about the Android system and task state.
|
| 73 |
+
|
| 74 |
+
Attributes:
|
| 75 |
+
screen_image: Base64-encoded image (JPEG or PNG) of current screen.
|
| 76 |
+
screen_width: Width of the screen in pixels.
|
| 77 |
+
screen_height: Height of the screen in pixels.
|
| 78 |
+
timestamp_ms: Timestamp of the observation in milliseconds.
|
| 79 |
+
orientation: Screen orientation (0, 90, 180, 270 degrees).
|
| 80 |
+
extras: Additional task-specific information (e.g., accessibility tree,
|
| 81 |
+
current app package, system state).
|
| 82 |
+
"""
|
| 83 |
+
|
| 84 |
+
screen_image: str # Base64-encoded image
|
| 85 |
+
screen_width: int
|
| 86 |
+
screen_height: int
|
| 87 |
+
timestamp_ms: int = 0
|
| 88 |
+
orientation: int = 0 # degrees: 0, 90, 180, 270
|
| 89 |
+
|
| 90 |
+
# Task extras from android_env (accessibility info, package names, etc.)
|
| 91 |
+
extras: Dict[str, Any] = field(default_factory=dict)
|
| 92 |
+
|
| 93 |
+
# Optional: Include raw pixels shape for reference
|
| 94 |
+
pixels_shape: Optional[tuple[int, int, int]] = None # (height, width, channels)
|
src/envs/android_env/server/Dockerfile
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Android Environment for OpenEnv
|
| 2 |
+
# Build with: docker build -t android-env:latest -f src/envs/android_env/server/Dockerfile .
|
| 3 |
+
#
|
| 4 |
+
# This Dockerfile creates a container with:
|
| 5 |
+
# - Android SDK and command-line tools
|
| 6 |
+
# - Android Emulator
|
| 7 |
+
# - android_env Python package
|
| 8 |
+
# - OpenEnv wrapper for android_env
|
| 9 |
+
#
|
| 10 |
+
# The container requires:
|
| 11 |
+
# - KVM access for hardware acceleration (Linux hosts)
|
| 12 |
+
# - Significant resources (4GB+ RAM, 4+ CPU cores)
|
| 13 |
+
#
|
| 14 |
+
# Environment Variables Required:
|
| 15 |
+
# - ANDROID_AVD_NAME: Name of the Android Virtual Device
|
| 16 |
+
# - ANDROID_TASK_PATH: Path to the task textproto file
|
| 17 |
+
#
|
| 18 |
+
# Example build:
|
| 19 |
+
# docker build -t android-env:latest -f src/envs/android_env/server/Dockerfile .
|
| 20 |
+
#
|
| 21 |
+
# Example run:
|
| 22 |
+
# docker run -p 8000:8000 \
|
| 23 |
+
# -e ANDROID_AVD_NAME=Pixel_6_API_33 \
|
| 24 |
+
# -e ANDROID_TASK_PATH=/workspace/tasks/my_task.textproto \
|
| 25 |
+
# -v /path/to/tasks:/workspace/tasks \
|
| 26 |
+
# --device /dev/kvm \
|
| 27 |
+
# android-env:latest
|
| 28 |
+
|
| 29 |
+
# Accept base image as build argument
|
| 30 |
+
ARG BASE_IMAGE=openenv-base:latest
|
| 31 |
+
FROM ${BASE_IMAGE}
|
| 32 |
+
|
| 33 |
+
# Install system dependencies for Android SDK and emulator
|
| 34 |
+
RUN apt-get update && apt-get install -y \
|
| 35 |
+
# Android SDK dependencies
|
| 36 |
+
wget \
|
| 37 |
+
unzip \
|
| 38 |
+
openjdk-11-jdk \
|
| 39 |
+
# Emulator dependencies
|
| 40 |
+
libgl1-mesa-dev \
|
| 41 |
+
libglu1-mesa-dev \
|
| 42 |
+
xvfb \
|
| 43 |
+
libxkbcommon-x11-0 \
|
| 44 |
+
libpulse0 \
|
| 45 |
+
libxcomposite1 \
|
| 46 |
+
libxcursor1 \
|
| 47 |
+
# Build tools
|
| 48 |
+
build-essential \
|
| 49 |
+
# Hardware acceleration
|
| 50 |
+
qemu-kvm \
|
| 51 |
+
libvirt-daemon-system \
|
| 52 |
+
libvirt-clients \
|
| 53 |
+
bridge-utils \
|
| 54 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 55 |
+
|
| 56 |
+
# Set up environment variables for Android
|
| 57 |
+
ENV ANDROID_SDK_ROOT=/opt/android-sdk
|
| 58 |
+
ENV ANDROID_AVD_HOME=/root/.android/avd
|
| 59 |
+
ENV ANDROID_HOME=${ANDROID_SDK_ROOT}
|
| 60 |
+
ENV PATH=${PATH}:${ANDROID_SDK_ROOT}/cmdline-tools/latest/bin:${ANDROID_SDK_ROOT}/platform-tools:${ANDROID_SDK_ROOT}/emulator
|
| 61 |
+
|
| 62 |
+
# Create SDK directory
|
| 63 |
+
RUN mkdir -p ${ANDROID_SDK_ROOT}
|
| 64 |
+
|
| 65 |
+
# Download and install Android command-line tools
|
| 66 |
+
# Using commandlinetools version 11076708 (latest as of 2024)
|
| 67 |
+
WORKDIR /tmp
|
| 68 |
+
RUN wget https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip && \
|
| 69 |
+
unzip commandlinetools-linux-11076708_latest.zip && \
|
| 70 |
+
mkdir -p ${ANDROID_SDK_ROOT}/cmdline-tools && \
|
| 71 |
+
mv cmdline-tools ${ANDROID_SDK_ROOT}/cmdline-tools/latest && \
|
| 72 |
+
rm commandlinetools-linux-11076708_latest.zip
|
| 73 |
+
|
| 74 |
+
# Accept Android SDK licenses
|
| 75 |
+
RUN yes | sdkmanager --licenses || true
|
| 76 |
+
|
| 77 |
+
# Install Android SDK components
|
| 78 |
+
# - platform-tools: includes adb
|
| 79 |
+
# - emulator: Android emulator
|
| 80 |
+
# - system-images: Android system image (using API 33 / Android 13 as default)
|
| 81 |
+
# - platforms: Android platform for building
|
| 82 |
+
RUN sdkmanager \
|
| 83 |
+
"platform-tools" \
|
| 84 |
+
"emulator" \
|
| 85 |
+
"system-images;android-33;google_apis;x86_64" \
|
| 86 |
+
"platforms;android-33" \
|
| 87 |
+
"build-tools;33.0.0"
|
| 88 |
+
|
| 89 |
+
# Create a default AVD (can be overridden by user)
|
| 90 |
+
# This creates a baseline AVD that can be used if custom one is not provided
|
| 91 |
+
RUN echo "no" | avdmanager create avd \
|
| 92 |
+
--force \
|
| 93 |
+
--name "default_pixel_6" \
|
| 94 |
+
--package "system-images;android-33;google_apis;x86_64" \
|
| 95 |
+
--device "pixel_6" || true
|
| 96 |
+
|
| 97 |
+
# Install Python dependencies
|
| 98 |
+
COPY src/envs/android_env/server/requirements.txt /tmp/requirements.txt
|
| 99 |
+
RUN pip install --no-cache-dir -r /tmp/requirements.txt && rm /tmp/requirements.txt
|
| 100 |
+
|
| 101 |
+
# Copy OpenEnv core and android_env code
|
| 102 |
+
WORKDIR /app
|
| 103 |
+
COPY src/core/ /app/src/core/
|
| 104 |
+
COPY src/envs/android_env/ /app/src/envs/android_env/
|
| 105 |
+
|
| 106 |
+
# Create workspace directory for tasks and data
|
| 107 |
+
RUN mkdir -p /workspace/tasks /workspace/data
|
| 108 |
+
|
| 109 |
+
# Health check
|
| 110 |
+
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
|
| 111 |
+
CMD curl -f http://localhost:8000/health || exit 1
|
| 112 |
+
|
| 113 |
+
# Expose HTTP port
|
| 114 |
+
EXPOSE 8000
|
| 115 |
+
|
| 116 |
+
# Set up entrypoint script to handle emulator startup if needed
|
| 117 |
+
# Note: The emulator is started by android_env loader, not here
|
| 118 |
+
# We just run the FastAPI server
|
| 119 |
+
|
| 120 |
+
# Run server
|
| 121 |
+
CMD ["uvicorn", "envs.android_env.server.app:app", "--host", "0.0.0.0", "--port", "8000"]
|
src/envs/android_env/server/__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 |
+
"""Server package for Android environment."""
|
src/envs/android_env/server/android_environment.py
ADDED
|
@@ -0,0 +1,408 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
Enhanced Android Environment Server Implementation with complete features.
|
| 9 |
+
|
| 10 |
+
This module wraps DeepMind's android_env with:
|
| 11 |
+
- Full gesture support (tap, swipe, scroll, etc.)
|
| 12 |
+
- ADB integration for text input and button presses
|
| 13 |
+
- Shared memory optimization for parallel training
|
| 14 |
+
- Gesture sequencing
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
import base64
|
| 18 |
+
import io
|
| 19 |
+
import logging
|
| 20 |
+
import subprocess
|
| 21 |
+
import time
|
| 22 |
+
from multiprocessing import shared_memory
|
| 23 |
+
from typing import Any, Dict, List, Optional
|
| 24 |
+
from uuid import uuid4
|
| 25 |
+
|
| 26 |
+
import numpy as np
|
| 27 |
+
from android_env import loader
|
| 28 |
+
from android_env.components import config_classes
|
| 29 |
+
from android_env.proto import adb_pb2
|
| 30 |
+
from dm_env import specs
|
| 31 |
+
from PIL import Image
|
| 32 |
+
|
| 33 |
+
from core.env_server.interfaces import Environment
|
| 34 |
+
from core.env_server.types import State
|
| 35 |
+
|
| 36 |
+
from ..models import AndroidAction, AndroidObservation
|
| 37 |
+
from .gestures import ADBCommands, GestureBuilder
|
| 38 |
+
|
| 39 |
+
logger = logging.getLogger(__name__)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
class AndroidEnvironment(Environment):
|
| 43 |
+
"""
|
| 44 |
+
Enhanced Android environment wrapper for OpenEnv.
|
| 45 |
+
|
| 46 |
+
Features:
|
| 47 |
+
- Complete gesture support (swipe, scroll, long press, etc.)
|
| 48 |
+
- ADB text input and button press
|
| 49 |
+
- Gesture sequencing (multi-step gestures)
|
| 50 |
+
- Optional shared memory for high-performance deployments
|
| 51 |
+
- Action buffering for gesture composition
|
| 52 |
+
"""
|
| 53 |
+
|
| 54 |
+
def __init__(
|
| 55 |
+
self,
|
| 56 |
+
task_path: str,
|
| 57 |
+
avd_name: str,
|
| 58 |
+
adb_path: str = "~/Android/Sdk/platform-tools/adb",
|
| 59 |
+
emulator_path: str = "~/Android/Sdk/emulator/emulator",
|
| 60 |
+
android_avd_home: str = "~/.android/avd",
|
| 61 |
+
android_sdk_root: str = "~/Android/Sdk",
|
| 62 |
+
run_headless: bool = True,
|
| 63 |
+
image_format: str = "JPEG",
|
| 64 |
+
image_quality: int = 85,
|
| 65 |
+
use_shared_memory: bool = False,
|
| 66 |
+
shared_memory_name: Optional[str] = None,
|
| 67 |
+
):
|
| 68 |
+
"""Initialize the Android environment.
|
| 69 |
+
|
| 70 |
+
Args:
|
| 71 |
+
task_path: Path to the android_env task textproto file.
|
| 72 |
+
avd_name: Name of the Android Virtual Device to use.
|
| 73 |
+
adb_path: Path to the ADB executable.
|
| 74 |
+
emulator_path: Path to the Android emulator executable.
|
| 75 |
+
android_avd_home: Path to the AVD home directory.
|
| 76 |
+
android_sdk_root: Path to the Android SDK root.
|
| 77 |
+
run_headless: Whether to run the emulator in headless mode.
|
| 78 |
+
image_format: Format for encoding screen images ("JPEG" or "PNG").
|
| 79 |
+
image_quality: Quality for JPEG encoding (1-100).
|
| 80 |
+
use_shared_memory: Use shared memory for zero-copy observations.
|
| 81 |
+
shared_memory_name: Name for shared memory segment.
|
| 82 |
+
"""
|
| 83 |
+
super().__init__()
|
| 84 |
+
|
| 85 |
+
self._task_path = task_path
|
| 86 |
+
self._avd_name = avd_name
|
| 87 |
+
self._adb_path = adb_path
|
| 88 |
+
self._image_format = image_format
|
| 89 |
+
self._image_quality = image_quality
|
| 90 |
+
self._use_shared_memory = use_shared_memory
|
| 91 |
+
|
| 92 |
+
# Gesture sequencing state
|
| 93 |
+
self._gesture_queue: List[dict] = []
|
| 94 |
+
self._executing_gesture = False
|
| 95 |
+
|
| 96 |
+
# Create android_env configuration
|
| 97 |
+
config = config_classes.AndroidEnvConfig(
|
| 98 |
+
task=config_classes.FilesystemTaskConfig(path=task_path),
|
| 99 |
+
simulator=config_classes.EmulatorConfig(
|
| 100 |
+
emulator_launcher=config_classes.EmulatorLauncherConfig(
|
| 101 |
+
emulator_path=emulator_path,
|
| 102 |
+
android_sdk_root=android_sdk_root,
|
| 103 |
+
android_avd_home=android_avd_home,
|
| 104 |
+
avd_name=avd_name,
|
| 105 |
+
run_headless=run_headless,
|
| 106 |
+
),
|
| 107 |
+
adb_controller=config_classes.AdbControllerConfig(adb_path=adb_path),
|
| 108 |
+
),
|
| 109 |
+
)
|
| 110 |
+
|
| 111 |
+
# Load the android_env environment
|
| 112 |
+
logger.info(f"Loading Android environment with AVD: {avd_name}")
|
| 113 |
+
self._android_env = loader.load(config)
|
| 114 |
+
|
| 115 |
+
# Get action and observation specs
|
| 116 |
+
self._action_spec = self._android_env.action_spec()
|
| 117 |
+
self._observation_spec = self._android_env.observation_spec()
|
| 118 |
+
|
| 119 |
+
# Get screen dimensions from first observation
|
| 120 |
+
initial_obs = self._android_env.reset().observation
|
| 121 |
+
pixels = initial_obs.get("pixels")
|
| 122 |
+
if pixels is not None:
|
| 123 |
+
self._screen_height, self._screen_width, _ = pixels.shape
|
| 124 |
+
else:
|
| 125 |
+
self._screen_height, self._screen_width = 1920, 1080 # Default
|
| 126 |
+
|
| 127 |
+
# Set up shared memory if requested
|
| 128 |
+
self._shared_mem = None
|
| 129 |
+
if use_shared_memory:
|
| 130 |
+
mem_size = self._screen_height * self._screen_width * 3 # RGB
|
| 131 |
+
self._shared_mem_name = shared_memory_name or f"android_env_{uuid4().hex[:8]}"
|
| 132 |
+
try:
|
| 133 |
+
self._shared_mem = shared_memory.SharedMemory(
|
| 134 |
+
name=self._shared_mem_name,
|
| 135 |
+
create=True,
|
| 136 |
+
size=mem_size
|
| 137 |
+
)
|
| 138 |
+
logger.info(f"Created shared memory: {self._shared_mem_name}")
|
| 139 |
+
except Exception as e:
|
| 140 |
+
logger.warning(f"Could not create shared memory: {e}. Falling back to encoding.")
|
| 141 |
+
self._use_shared_memory = False
|
| 142 |
+
|
| 143 |
+
# Initialize state
|
| 144 |
+
self._state = State(episode_id=str(uuid4()), step_count=0)
|
| 145 |
+
self._latest_timestep = None
|
| 146 |
+
|
| 147 |
+
logger.info(f"Android environment initialized successfully")
|
| 148 |
+
logger.info(f"Screen size: {self._screen_width}x{self._screen_height}")
|
| 149 |
+
logger.info(f"Action spec: {list(self._action_spec.keys())}")
|
| 150 |
+
|
| 151 |
+
def reset(self) -> AndroidObservation:
|
| 152 |
+
"""Reset the Android environment for a new episode."""
|
| 153 |
+
logger.info("Resetting Android environment...")
|
| 154 |
+
|
| 155 |
+
# Clear gesture queue
|
| 156 |
+
self._gesture_queue = []
|
| 157 |
+
self._executing_gesture = False
|
| 158 |
+
|
| 159 |
+
# Reset android_env
|
| 160 |
+
self._latest_timestep = self._android_env.reset()
|
| 161 |
+
|
| 162 |
+
# Update state
|
| 163 |
+
self._state = State(episode_id=str(uuid4()), step_count=0)
|
| 164 |
+
|
| 165 |
+
# Convert timestep to observation
|
| 166 |
+
observation = self._convert_timestep_to_observation(self._latest_timestep)
|
| 167 |
+
|
| 168 |
+
logger.info(f"Reset complete. Episode ID: {self._state.episode_id}")
|
| 169 |
+
return observation
|
| 170 |
+
|
| 171 |
+
def step(self, action: AndroidAction) -> AndroidObservation: # type: ignore[override]
|
| 172 |
+
"""Execute an action in the Android environment."""
|
| 173 |
+
# Convert OpenEnv action to gesture sequence or direct action
|
| 174 |
+
gesture_actions = self._convert_action_to_gestures(action)
|
| 175 |
+
|
| 176 |
+
# Execute all actions in the gesture sequence
|
| 177 |
+
for i, gesture_action in enumerate(gesture_actions):
|
| 178 |
+
android_action = self._create_android_action(gesture_action)
|
| 179 |
+
self._latest_timestep = self._android_env.step(android_action)
|
| 180 |
+
|
| 181 |
+
# Update state on last action of sequence
|
| 182 |
+
if i == len(gesture_actions) - 1:
|
| 183 |
+
self._state.step_count += 1
|
| 184 |
+
|
| 185 |
+
# Convert final timestep to observation
|
| 186 |
+
observation = self._convert_timestep_to_observation(self._latest_timestep)
|
| 187 |
+
|
| 188 |
+
# Check if episode is done
|
| 189 |
+
if self._latest_timestep.last():
|
| 190 |
+
observation.done = True
|
| 191 |
+
logger.info(f"Episode ended after {self._state.step_count} steps")
|
| 192 |
+
|
| 193 |
+
return observation
|
| 194 |
+
|
| 195 |
+
@property
|
| 196 |
+
def state(self) -> State:
|
| 197 |
+
"""Get the current environment state."""
|
| 198 |
+
return self._state
|
| 199 |
+
|
| 200 |
+
def close(self) -> None:
|
| 201 |
+
"""Clean up the Android environment."""
|
| 202 |
+
logger.info("Closing Android environment...")
|
| 203 |
+
if hasattr(self, "_android_env"):
|
| 204 |
+
self._android_env.close()
|
| 205 |
+
if self._shared_mem:
|
| 206 |
+
try:
|
| 207 |
+
self._shared_mem.close()
|
| 208 |
+
self._shared_mem.unlink()
|
| 209 |
+
except:
|
| 210 |
+
pass
|
| 211 |
+
logger.info("Android environment closed")
|
| 212 |
+
|
| 213 |
+
def _convert_action_to_gestures(self, action: AndroidAction) -> List[dict]:
|
| 214 |
+
"""Convert high-level action to sequence of primitive gestures."""
|
| 215 |
+
tool_name = action.tool_name
|
| 216 |
+
params = action.parameters
|
| 217 |
+
|
| 218 |
+
# Use GestureBuilder for complex gestures
|
| 219 |
+
if tool_name == "tap":
|
| 220 |
+
return GestureBuilder.tap(params["x"], params["y"])
|
| 221 |
+
|
| 222 |
+
elif tool_name == "swipe":
|
| 223 |
+
return GestureBuilder.swipe(
|
| 224 |
+
params["x1"], params["y1"],
|
| 225 |
+
params["x2"], params["y2"],
|
| 226 |
+
params.get("duration_ms", 300)
|
| 227 |
+
)
|
| 228 |
+
|
| 229 |
+
elif tool_name == "long_press":
|
| 230 |
+
return GestureBuilder.long_press(
|
| 231 |
+
params["x"], params["y"],
|
| 232 |
+
params.get("duration_ms", 1000)
|
| 233 |
+
)
|
| 234 |
+
|
| 235 |
+
elif tool_name == "double_tap":
|
| 236 |
+
return GestureBuilder.double_tap(params["x"], params["y"])
|
| 237 |
+
|
| 238 |
+
elif tool_name == "scroll_down":
|
| 239 |
+
return GestureBuilder.scroll_down(
|
| 240 |
+
params.get("x", 0.5),
|
| 241 |
+
params.get("distance", 0.5)
|
| 242 |
+
)
|
| 243 |
+
|
| 244 |
+
elif tool_name == "scroll_up":
|
| 245 |
+
return GestureBuilder.scroll_up(
|
| 246 |
+
params.get("x", 0.5),
|
| 247 |
+
params.get("distance", 0.5)
|
| 248 |
+
)
|
| 249 |
+
|
| 250 |
+
elif tool_name == "swipe_left":
|
| 251 |
+
return GestureBuilder.swipe_left(
|
| 252 |
+
params.get("y", 0.5),
|
| 253 |
+
params.get("distance", 0.5)
|
| 254 |
+
)
|
| 255 |
+
|
| 256 |
+
elif tool_name == "swipe_right":
|
| 257 |
+
return GestureBuilder.swipe_right(
|
| 258 |
+
params.get("y", 0.5),
|
| 259 |
+
params.get("distance", 0.5)
|
| 260 |
+
)
|
| 261 |
+
|
| 262 |
+
elif tool_name == "type_text":
|
| 263 |
+
# Execute ADB text input command
|
| 264 |
+
self._execute_adb_text(params["text"])
|
| 265 |
+
# Return a no-op touch action
|
| 266 |
+
return [{"action_type": 2, "x": 0.5, "y": 0.5, "duration_ms": 100}]
|
| 267 |
+
|
| 268 |
+
elif tool_name == "press_button":
|
| 269 |
+
# Execute ADB keyevent command
|
| 270 |
+
self._execute_adb_button(params["button"])
|
| 271 |
+
# Return a no-op touch action
|
| 272 |
+
return [{"action_type": 2, "x": 0.5, "y": 0.5, "duration_ms": 100}]
|
| 273 |
+
|
| 274 |
+
else:
|
| 275 |
+
raise ValueError(f"Unknown action tool_name: {tool_name}")
|
| 276 |
+
|
| 277 |
+
def _create_android_action(self, gesture_action: dict) -> Dict[str, np.ndarray]:
|
| 278 |
+
"""Create android_env action from gesture primitive."""
|
| 279 |
+
action = {}
|
| 280 |
+
action_type = gesture_action["action_type"]
|
| 281 |
+
x = gesture_action["x"]
|
| 282 |
+
y = gesture_action["y"]
|
| 283 |
+
|
| 284 |
+
for key, spec in self._action_spec.items():
|
| 285 |
+
if key == "action_type":
|
| 286 |
+
action[key] = np.array(action_type, dtype=spec.dtype)
|
| 287 |
+
elif key == "touch_position":
|
| 288 |
+
action[key] = np.array([np.clip(x, 0.0, 1.0), np.clip(y, 0.0, 1.0)], dtype=spec.dtype)
|
| 289 |
+
else:
|
| 290 |
+
# Fill other fields with defaults
|
| 291 |
+
if isinstance(spec, specs.DiscreteArray):
|
| 292 |
+
action[key] = np.array(0, dtype=spec.dtype)
|
| 293 |
+
else:
|
| 294 |
+
action[key] = np.zeros(spec.shape, dtype=spec.dtype)
|
| 295 |
+
|
| 296 |
+
return action
|
| 297 |
+
|
| 298 |
+
def _execute_adb_text(self, text: str) -> None:
|
| 299 |
+
"""Execute ADB text input command."""
|
| 300 |
+
try:
|
| 301 |
+
cmd = ADBCommands.text_input(text)
|
| 302 |
+
adb_request = adb_pb2.AdbRequest()
|
| 303 |
+
adb_request.generic.command = cmd
|
| 304 |
+
self._android_env.execute_adb_call(adb_request)
|
| 305 |
+
logger.info(f"Executed ADB text input: {text[:20]}...")
|
| 306 |
+
except Exception as e:
|
| 307 |
+
logger.error(f"ADB text input failed: {e}")
|
| 308 |
+
|
| 309 |
+
def _execute_adb_button(self, button: str) -> None:
|
| 310 |
+
"""Execute ADB button press command."""
|
| 311 |
+
try:
|
| 312 |
+
# Map common button names to keycodes
|
| 313 |
+
button_map = {
|
| 314 |
+
"HOME": ADBCommands.KEYCODE_HOME,
|
| 315 |
+
"BACK": ADBCommands.KEYCODE_BACK,
|
| 316 |
+
"MENU": ADBCommands.KEYCODE_MENU,
|
| 317 |
+
"ENTER": ADBCommands.KEYCODE_ENTER,
|
| 318 |
+
"SEARCH": ADBCommands.KEYCODE_SEARCH,
|
| 319 |
+
"DELETE": ADBCommands.KEYCODE_DEL,
|
| 320 |
+
"TAB": ADBCommands.KEYCODE_TAB,
|
| 321 |
+
"SPACE": ADBCommands.KEYCODE_SPACE,
|
| 322 |
+
}
|
| 323 |
+
keycode = button_map.get(button.upper(), button)
|
| 324 |
+
|
| 325 |
+
cmd = ADBCommands.keyevent(keycode)
|
| 326 |
+
adb_request = adb_pb2.AdbRequest()
|
| 327 |
+
adb_request.generic.command = cmd
|
| 328 |
+
self._android_env.execute_adb_call(adb_request)
|
| 329 |
+
logger.info(f"Executed ADB button press: {button}")
|
| 330 |
+
except Exception as e:
|
| 331 |
+
logger.error(f"ADB button press failed: {e}")
|
| 332 |
+
|
| 333 |
+
def _convert_timestep_to_observation(self, timestep: Any) -> AndroidObservation:
|
| 334 |
+
"""Convert android_env TimeStep to AndroidObservation."""
|
| 335 |
+
obs_dict = timestep.observation
|
| 336 |
+
pixels = obs_dict.get("pixels")
|
| 337 |
+
|
| 338 |
+
if pixels is None:
|
| 339 |
+
raise ValueError("No pixels found in android_env observation")
|
| 340 |
+
|
| 341 |
+
height, width, channels = pixels.shape
|
| 342 |
+
|
| 343 |
+
# Handle observation encoding
|
| 344 |
+
if self._use_shared_memory and self._shared_mem:
|
| 345 |
+
# Write pixels to shared memory
|
| 346 |
+
screen_image_b64 = self._write_to_shared_memory(pixels)
|
| 347 |
+
else:
|
| 348 |
+
# Encode to base64
|
| 349 |
+
screen_image_b64 = self._encode_image(pixels)
|
| 350 |
+
|
| 351 |
+
# Extract extras
|
| 352 |
+
extras = {k: v for k, v in obs_dict.items() if k != "pixels"}
|
| 353 |
+
if hasattr(self._android_env, "task_extras"):
|
| 354 |
+
task_extras = self._android_env.task_extras(latest_only=True)
|
| 355 |
+
extras.update({"task_extras": task_extras})
|
| 356 |
+
|
| 357 |
+
observation = AndroidObservation(
|
| 358 |
+
screen_image=screen_image_b64,
|
| 359 |
+
screen_width=width,
|
| 360 |
+
screen_height=height,
|
| 361 |
+
timestamp_ms=int(time.time() * 1000),
|
| 362 |
+
orientation=0,
|
| 363 |
+
pixels_shape=(height, width, channels),
|
| 364 |
+
extras=extras,
|
| 365 |
+
done=timestep.last(),
|
| 366 |
+
reward=float(timestep.reward) if timestep.reward is not None else 0.0,
|
| 367 |
+
)
|
| 368 |
+
|
| 369 |
+
return observation
|
| 370 |
+
|
| 371 |
+
def _encode_image(self, pixels: np.ndarray) -> str:
|
| 372 |
+
"""Encode numpy pixel array to base64 string."""
|
| 373 |
+
image = Image.fromarray(pixels.astype(np.uint8))
|
| 374 |
+
buffer = io.BytesIO()
|
| 375 |
+
|
| 376 |
+
if self._image_format == "JPEG":
|
| 377 |
+
image.save(buffer, format="JPEG", quality=self._image_quality)
|
| 378 |
+
elif self._image_format == "PNG":
|
| 379 |
+
image.save(buffer, format="PNG")
|
| 380 |
+
else:
|
| 381 |
+
raise ValueError(f"Unsupported image format: {self._image_format}")
|
| 382 |
+
|
| 383 |
+
buffer.seek(0)
|
| 384 |
+
image_bytes = buffer.read()
|
| 385 |
+
return base64.b64encode(image_bytes).decode("utf-8")
|
| 386 |
+
|
| 387 |
+
def _write_to_shared_memory(self, pixels: np.ndarray) -> str:
|
| 388 |
+
"""Write pixels to shared memory and return memory name."""
|
| 389 |
+
if not self._shared_mem:
|
| 390 |
+
return self._encode_image(pixels) # Fallback
|
| 391 |
+
|
| 392 |
+
try:
|
| 393 |
+
# Write pixels directly to shared memory
|
| 394 |
+
np_array = np.ndarray(
|
| 395 |
+
pixels.shape,
|
| 396 |
+
dtype=pixels.dtype,
|
| 397 |
+
buffer=self._shared_mem.buf
|
| 398 |
+
)
|
| 399 |
+
np_array[:] = pixels[:]
|
| 400 |
+
# Return shared memory name instead of image data
|
| 401 |
+
return f"shm://{self._shared_mem_name}"
|
| 402 |
+
except Exception as e:
|
| 403 |
+
logger.error(f"Shared memory write failed: {e}, falling back to encoding")
|
| 404 |
+
return self._encode_image(pixels)
|
| 405 |
+
|
| 406 |
+
def __del__(self):
|
| 407 |
+
"""Cleanup on deletion."""
|
| 408 |
+
self.close()
|
src/envs/android_env/server/app.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Copyright (c) Meta Platforms, Inc. and affiliates.
|
| 2 |
+
# All rights reserved.
|
| 3 |
+
#
|
| 4 |
+
# This source code is licensed under the BSD-style license found in the
|
| 5 |
+
# LICENSE file in the root directory of this source tree.
|
| 6 |
+
|
| 7 |
+
"""
|
| 8 |
+
FastAPI application for the Android Environment.
|
| 9 |
+
|
| 10 |
+
This module creates an HTTP server that exposes the AndroidEnvironment
|
| 11 |
+
over HTTP endpoints, making it accessible via HTTPEnvClient.
|
| 12 |
+
|
| 13 |
+
The server is configured via environment variables:
|
| 14 |
+
- ANDROID_AVD_NAME: Name of the Android Virtual Device (required)
|
| 15 |
+
- ANDROID_TASK_PATH: Path to task textproto file (required)
|
| 16 |
+
- ANDROID_ADB_PATH: Path to ADB (default: ~/Android/Sdk/platform-tools/adb)
|
| 17 |
+
- ANDROID_EMULATOR_PATH: Path to emulator (default: ~/Android/Sdk/emulator/emulator)
|
| 18 |
+
- ANDROID_AVD_HOME: AVD home directory (default: ~/.android/avd)
|
| 19 |
+
- ANDROID_SDK_ROOT: SDK root directory (default: ~/Android/Sdk)
|
| 20 |
+
- ANDROID_RUN_HEADLESS: Run headless (default: true)
|
| 21 |
+
- ANDROID_IMAGE_FORMAT: Image encoding format (default: JPEG)
|
| 22 |
+
- ANDROID_IMAGE_QUALITY: JPEG quality 1-100 (default: 85)
|
| 23 |
+
|
| 24 |
+
Usage:
|
| 25 |
+
# Development (with environment variables):
|
| 26 |
+
export ANDROID_AVD_NAME=Pixel_6_API_33
|
| 27 |
+
export ANDROID_TASK_PATH=/workspace/tasks/my_task.textproto
|
| 28 |
+
uvicorn envs.android_env.server.app:app --reload --host 0.0.0.0 --port 8000
|
| 29 |
+
|
| 30 |
+
# Production:
|
| 31 |
+
uvicorn envs.android_env.server.app:app --host 0.0.0.0 --port 8000
|
| 32 |
+
|
| 33 |
+
# Or run directly:
|
| 34 |
+
python -m envs.android_env.server.app
|
| 35 |
+
"""
|
| 36 |
+
|
| 37 |
+
import os
|
| 38 |
+
from pathlib import Path
|
| 39 |
+
|
| 40 |
+
from core.env_server.http_server import create_app
|
| 41 |
+
|
| 42 |
+
from ..models import AndroidAction, AndroidObservation
|
| 43 |
+
from .android_environment import AndroidEnvironment
|
| 44 |
+
|
| 45 |
+
# Get configuration from environment variables
|
| 46 |
+
AVD_NAME = os.getenv("ANDROID_AVD_NAME")
|
| 47 |
+
TASK_PATH = os.getenv("ANDROID_TASK_PATH")
|
| 48 |
+
ADB_PATH = os.getenv("ANDROID_ADB_PATH", "~/Android/Sdk/platform-tools/adb")
|
| 49 |
+
EMULATOR_PATH = os.getenv(
|
| 50 |
+
"ANDROID_EMULATOR_PATH", "~/Android/Sdk/emulator/emulator"
|
| 51 |
+
)
|
| 52 |
+
AVD_HOME = os.getenv("ANDROID_AVD_HOME", "~/.android/avd")
|
| 53 |
+
SDK_ROOT = os.getenv("ANDROID_SDK_ROOT", "~/Android/Sdk")
|
| 54 |
+
RUN_HEADLESS = os.getenv("ANDROID_RUN_HEADLESS", "true").lower() == "true"
|
| 55 |
+
IMAGE_FORMAT = os.getenv("ANDROID_IMAGE_FORMAT", "JPEG")
|
| 56 |
+
IMAGE_QUALITY = int(os.getenv("ANDROID_IMAGE_QUALITY", "85"))
|
| 57 |
+
|
| 58 |
+
# Validate required configuration
|
| 59 |
+
if not AVD_NAME:
|
| 60 |
+
raise ValueError(
|
| 61 |
+
"ANDROID_AVD_NAME environment variable is required. "
|
| 62 |
+
"Set it to the name of your Android Virtual Device."
|
| 63 |
+
)
|
| 64 |
+
|
| 65 |
+
if not TASK_PATH:
|
| 66 |
+
raise ValueError(
|
| 67 |
+
"ANDROID_TASK_PATH environment variable is required. "
|
| 68 |
+
"Set it to the path of your task textproto file."
|
| 69 |
+
)
|
| 70 |
+
|
| 71 |
+
# Expand paths
|
| 72 |
+
ADB_PATH = str(Path(ADB_PATH).expanduser())
|
| 73 |
+
EMULATOR_PATH = str(Path(EMULATOR_PATH).expanduser())
|
| 74 |
+
AVD_HOME = str(Path(AVD_HOME).expanduser())
|
| 75 |
+
SDK_ROOT = str(Path(SDK_ROOT).expanduser())
|
| 76 |
+
TASK_PATH = str(Path(TASK_PATH).expanduser())
|
| 77 |
+
|
| 78 |
+
print(f"Initializing Android Environment with:")
|
| 79 |
+
print(f" AVD Name: {AVD_NAME}")
|
| 80 |
+
print(f" Task Path: {TASK_PATH}")
|
| 81 |
+
print(f" ADB Path: {ADB_PATH}")
|
| 82 |
+
print(f" Emulator Path: {EMULATOR_PATH}")
|
| 83 |
+
print(f" AVD Home: {AVD_HOME}")
|
| 84 |
+
print(f" SDK Root: {SDK_ROOT}")
|
| 85 |
+
print(f" Headless: {RUN_HEADLESS}")
|
| 86 |
+
print(f" Image Format: {IMAGE_FORMAT} (Quality: {IMAGE_QUALITY})")
|
| 87 |
+
|
| 88 |
+
# Create the environment instance
|
| 89 |
+
env = AndroidEnvironment(
|
| 90 |
+
task_path=TASK_PATH,
|
| 91 |
+
avd_name=AVD_NAME,
|
| 92 |
+
adb_path=ADB_PATH,
|
| 93 |
+
emulator_path=EMULATOR_PATH,
|
| 94 |
+
android_avd_home=AVD_HOME,
|
| 95 |
+
android_sdk_root=SDK_ROOT,
|
| 96 |
+
run_headless=RUN_HEADLESS,
|
| 97 |
+
image_format=IMAGE_FORMAT,
|
| 98 |
+
image_quality=IMAGE_QUALITY,
|
| 99 |
+
)
|
| 100 |
+
|
| 101 |
+
# Create the FastAPI app with web interface
|
| 102 |
+
app = create_app(env, AndroidAction, AndroidObservation, env_name="android_env")
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
if __name__ == "__main__":
|
| 106 |
+
import uvicorn
|
| 107 |
+
|
| 108 |
+
uvicorn.run(app, host="0.0.0.0", port=8000)
|
src/envs/android_env/server/emulator_pool.py
ADDED
|
@@ -0,0 +1,314 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
Emulator Pool Manager for parallel training.
|
| 9 |
+
|
| 10 |
+
This module provides a pool of pre-warmed Android emulators for
|
| 11 |
+
high-throughput parallel training on multi-core systems.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
import logging
|
| 15 |
+
import queue
|
| 16 |
+
import threading
|
| 17 |
+
import time
|
| 18 |
+
from typing import Dict, List, Optional
|
| 19 |
+
|
| 20 |
+
from .android_environment import AndroidEnvironment
|
| 21 |
+
|
| 22 |
+
logger = logging.getLogger(__name__)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class EmulatorPool:
|
| 26 |
+
"""
|
| 27 |
+
Pool of pre-warmed Android emulators for parallel training.
|
| 28 |
+
|
| 29 |
+
The pool:
|
| 30 |
+
1. Boots N emulators at startup (amortizes 30-60s boot time)
|
| 31 |
+
2. Keeps emulators running across episodes
|
| 32 |
+
3. Resets app state (not full emulator) between episodes
|
| 33 |
+
4. Provides instant environment access via get/put
|
| 34 |
+
|
| 35 |
+
Optimized for systems with 100+ CPU cores and high memory capacity.
|
| 36 |
+
|
| 37 |
+
Example:
|
| 38 |
+
>>> # Boot 64 emulators once at startup (10 min one-time cost)
|
| 39 |
+
>>> pool = EmulatorPool(
|
| 40 |
+
... pool_size=64,
|
| 41 |
+
... task_path="/workspace/tasks/my_task.textproto",
|
| 42 |
+
... avd_name="default_pixel_6"
|
| 43 |
+
... )
|
| 44 |
+
>>>
|
| 45 |
+
>>> # Training loop - instant access!
|
| 46 |
+
>>> for episode in range(10000):
|
| 47 |
+
... env = pool.get() # <1ms
|
| 48 |
+
... # ... run episode ...
|
| 49 |
+
... pool.put(env) # Returns env to pool (resets app state)
|
| 50 |
+
>>>
|
| 51 |
+
>>> pool.close()
|
| 52 |
+
"""
|
| 53 |
+
|
| 54 |
+
def __init__(
|
| 55 |
+
self,
|
| 56 |
+
pool_size: int,
|
| 57 |
+
task_path: str,
|
| 58 |
+
avd_name: str,
|
| 59 |
+
adb_path: str = "~/Android/Sdk/platform-tools/adb",
|
| 60 |
+
emulator_path: str = "~/Android/Sdk/emulator/emulator",
|
| 61 |
+
android_avd_home: str = "~/.android/avd",
|
| 62 |
+
android_sdk_root: str = "~/Android/Sdk",
|
| 63 |
+
run_headless: bool = True,
|
| 64 |
+
image_format: str = "JPEG",
|
| 65 |
+
image_quality: int = 85,
|
| 66 |
+
use_shared_memory: bool = False,
|
| 67 |
+
):
|
| 68 |
+
"""Initialize emulator pool.
|
| 69 |
+
|
| 70 |
+
Args:
|
| 71 |
+
pool_size: Number of emulators to pre-warm.
|
| 72 |
+
task_path: Path to task textproto.
|
| 73 |
+
avd_name: Name of Android Virtual Device.
|
| 74 |
+
adb_path: Path to ADB executable.
|
| 75 |
+
emulator_path: Path to emulator executable.
|
| 76 |
+
android_avd_home: AVD home directory.
|
| 77 |
+
android_sdk_root: SDK root directory.
|
| 78 |
+
run_headless: Run emulators headless.
|
| 79 |
+
image_format: Image encoding format.
|
| 80 |
+
image_quality: JPEG quality (1-100).
|
| 81 |
+
use_shared_memory: Use shared memory optimization.
|
| 82 |
+
"""
|
| 83 |
+
self.pool_size = pool_size
|
| 84 |
+
self.task_path = task_path
|
| 85 |
+
self.avd_name = avd_name
|
| 86 |
+
self.adb_path = adb_path
|
| 87 |
+
self.emulator_path = emulator_path
|
| 88 |
+
self.android_avd_home = android_avd_home
|
| 89 |
+
self.android_sdk_root = android_sdk_root
|
| 90 |
+
self.run_headless = run_headless
|
| 91 |
+
self.image_format = image_format
|
| 92 |
+
self.image_quality = image_quality
|
| 93 |
+
self.use_shared_memory = use_shared_memory
|
| 94 |
+
|
| 95 |
+
# Thread-safe queue for available emulators
|
| 96 |
+
self._available: queue.Queue = queue.Queue(maxsize=pool_size)
|
| 97 |
+
self._all_emulators: List[AndroidEnvironment] = []
|
| 98 |
+
self._lock = threading.Lock()
|
| 99 |
+
self._closed = False
|
| 100 |
+
|
| 101 |
+
# Boot all emulators
|
| 102 |
+
logger.info(f"Booting {pool_size} emulators... (this will take ~{pool_size} minutes)")
|
| 103 |
+
self._boot_pool()
|
| 104 |
+
logger.info(f"Emulator pool ready with {pool_size} instances!")
|
| 105 |
+
|
| 106 |
+
def _boot_pool(self):
|
| 107 |
+
"""Boot all emulators in the pool."""
|
| 108 |
+
start_time = time.time()
|
| 109 |
+
|
| 110 |
+
for i in range(self.pool_size):
|
| 111 |
+
logger.info(f"Booting emulator {i+1}/{self.pool_size}...")
|
| 112 |
+
|
| 113 |
+
# Create unique shared memory name if using shared memory
|
| 114 |
+
shm_name = f"android_pool_{i}" if self.use_shared_memory else None
|
| 115 |
+
|
| 116 |
+
env = AndroidEnvironment(
|
| 117 |
+
task_path=self.task_path,
|
| 118 |
+
avd_name=self.avd_name,
|
| 119 |
+
adb_path=self.adb_path,
|
| 120 |
+
emulator_path=self.emulator_path,
|
| 121 |
+
android_avd_home=self.android_avd_home,
|
| 122 |
+
android_sdk_root=self.android_sdk_root,
|
| 123 |
+
run_headless=self.run_headless,
|
| 124 |
+
image_format=self.image_format,
|
| 125 |
+
image_quality=self.image_quality,
|
| 126 |
+
use_shared_memory=self.use_shared_memory,
|
| 127 |
+
shared_memory_name=shm_name,
|
| 128 |
+
)
|
| 129 |
+
|
| 130 |
+
# Reset to ensure ready state
|
| 131 |
+
env.reset()
|
| 132 |
+
|
| 133 |
+
self._all_emulators.append(env)
|
| 134 |
+
self._available.put(env)
|
| 135 |
+
|
| 136 |
+
elapsed = time.time() - start_time
|
| 137 |
+
logger.info(f"Pool boot complete in {elapsed:.1f} seconds ({elapsed/60:.1f} minutes)")
|
| 138 |
+
logger.info(f"Average boot time per emulator: {elapsed/self.pool_size:.1f} seconds")
|
| 139 |
+
|
| 140 |
+
def get(self, timeout: Optional[float] = None) -> AndroidEnvironment:
|
| 141 |
+
"""Get an emulator from the pool.
|
| 142 |
+
|
| 143 |
+
Args:
|
| 144 |
+
timeout: Max time to wait for available emulator (seconds).
|
| 145 |
+
None = wait forever.
|
| 146 |
+
|
| 147 |
+
Returns:
|
| 148 |
+
AndroidEnvironment ready for use.
|
| 149 |
+
|
| 150 |
+
Raises:
|
| 151 |
+
queue.Empty: If timeout expires and no emulator available.
|
| 152 |
+
RuntimeError: If pool is closed.
|
| 153 |
+
"""
|
| 154 |
+
if self._closed:
|
| 155 |
+
raise RuntimeError("Emulator pool is closed")
|
| 156 |
+
|
| 157 |
+
try:
|
| 158 |
+
env = self._available.get(timeout=timeout)
|
| 159 |
+
logger.debug(f"Dispatched emulator from pool ({self._available.qsize()} remaining)")
|
| 160 |
+
return env
|
| 161 |
+
except queue.Empty:
|
| 162 |
+
raise queue.Empty(
|
| 163 |
+
f"No emulator available after {timeout}s. "
|
| 164 |
+
f"Pool size={self.pool_size}, all in use."
|
| 165 |
+
)
|
| 166 |
+
|
| 167 |
+
def put(self, env: AndroidEnvironment, reset: bool = True):
|
| 168 |
+
"""Return an emulator to the pool.
|
| 169 |
+
|
| 170 |
+
Args:
|
| 171 |
+
env: Environment to return.
|
| 172 |
+
reset: Whether to reset the environment before returning to pool.
|
| 173 |
+
Set to False if you've already reset it.
|
| 174 |
+
"""
|
| 175 |
+
if self._closed:
|
| 176 |
+
logger.warning("Attempted to return emulator to closed pool")
|
| 177 |
+
return
|
| 178 |
+
|
| 179 |
+
if reset:
|
| 180 |
+
# Fast reset: just reset app state, not full emulator
|
| 181 |
+
# This takes ~1s vs 30-60s for full emulator boot
|
| 182 |
+
try:
|
| 183 |
+
env.reset()
|
| 184 |
+
except Exception as e:
|
| 185 |
+
logger.error(f"Error resetting emulator: {e}")
|
| 186 |
+
# Still return to pool, it might recover
|
| 187 |
+
|
| 188 |
+
self._available.put(env)
|
| 189 |
+
logger.debug(f"Returned emulator to pool ({self._available.qsize()} available)")
|
| 190 |
+
|
| 191 |
+
def get_stats(self) -> Dict[str, int]:
|
| 192 |
+
"""Get pool statistics.
|
| 193 |
+
|
| 194 |
+
Returns:
|
| 195 |
+
Dict with pool_size, available, in_use counts.
|
| 196 |
+
"""
|
| 197 |
+
available = self._available.qsize()
|
| 198 |
+
return {
|
| 199 |
+
"pool_size": self.pool_size,
|
| 200 |
+
"available": available,
|
| 201 |
+
"in_use": self.pool_size - available,
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
def close(self):
|
| 205 |
+
"""Close all emulators in the pool."""
|
| 206 |
+
if self._closed:
|
| 207 |
+
return
|
| 208 |
+
|
| 209 |
+
logger.info("Closing emulator pool...")
|
| 210 |
+
self._closed = True
|
| 211 |
+
|
| 212 |
+
# Close all emulators
|
| 213 |
+
for env in self._all_emulators:
|
| 214 |
+
try:
|
| 215 |
+
env.close()
|
| 216 |
+
except Exception as e:
|
| 217 |
+
logger.error(f"Error closing emulator: {e}")
|
| 218 |
+
|
| 219 |
+
logger.info("Emulator pool closed")
|
| 220 |
+
|
| 221 |
+
def __enter__(self):
|
| 222 |
+
"""Context manager entry."""
|
| 223 |
+
return self
|
| 224 |
+
|
| 225 |
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
| 226 |
+
"""Context manager exit."""
|
| 227 |
+
self.close()
|
| 228 |
+
|
| 229 |
+
def __del__(self):
|
| 230 |
+
"""Cleanup on deletion."""
|
| 231 |
+
self.close()
|
| 232 |
+
|
| 233 |
+
|
| 234 |
+
class EmulatorPoolManager:
|
| 235 |
+
"""
|
| 236 |
+
Manager for multiple emulator pools (for multi-task training).
|
| 237 |
+
|
| 238 |
+
Allows running multiple tasks simultaneously with separate pools.
|
| 239 |
+
|
| 240 |
+
Example:
|
| 241 |
+
>>> manager = EmulatorPoolManager()
|
| 242 |
+
>>> manager.create_pool("task1", pool_size=32, task_path="/tasks/task1.textproto", ...)
|
| 243 |
+
>>> manager.create_pool("task2", pool_size=32, task_path="/tasks/task2.textproto", ...)
|
| 244 |
+
>>>
|
| 245 |
+
>>> # Get emulator for specific task
|
| 246 |
+
>>> env = manager.get("task1")
|
| 247 |
+
>>> # ... use env ...
|
| 248 |
+
>>> manager.put("task1", env)
|
| 249 |
+
"""
|
| 250 |
+
|
| 251 |
+
def __init__(self):
|
| 252 |
+
"""Initialize the pool manager."""
|
| 253 |
+
self._pools: Dict[str, EmulatorPool] = {}
|
| 254 |
+
self._lock = threading.Lock()
|
| 255 |
+
|
| 256 |
+
def create_pool(self, name: str, **pool_kwargs) -> EmulatorPool:
|
| 257 |
+
"""Create a new emulator pool.
|
| 258 |
+
|
| 259 |
+
Args:
|
| 260 |
+
name: Unique name for this pool.
|
| 261 |
+
**pool_kwargs: Arguments passed to EmulatorPool constructor.
|
| 262 |
+
|
| 263 |
+
Returns:
|
| 264 |
+
Created EmulatorPool.
|
| 265 |
+
"""
|
| 266 |
+
with self._lock:
|
| 267 |
+
if name in self._pools:
|
| 268 |
+
raise ValueError(f"Pool '{name}' already exists")
|
| 269 |
+
|
| 270 |
+
pool = EmulatorPool(**pool_kwargs)
|
| 271 |
+
self._pools[name] = pool
|
| 272 |
+
logger.info(f"Created pool '{name}' with {pool.pool_size} emulators")
|
| 273 |
+
return pool
|
| 274 |
+
|
| 275 |
+
def get(self, pool_name: str, timeout: Optional[float] = None) -> AndroidEnvironment:
|
| 276 |
+
"""Get emulator from named pool."""
|
| 277 |
+
pool = self._pools.get(pool_name)
|
| 278 |
+
if not pool:
|
| 279 |
+
raise ValueError(f"Pool '{pool_name}' not found")
|
| 280 |
+
return pool.get(timeout=timeout)
|
| 281 |
+
|
| 282 |
+
def put(self, pool_name: str, env: AndroidEnvironment, reset: bool = True):
|
| 283 |
+
"""Return emulator to named pool."""
|
| 284 |
+
pool = self._pools.get(pool_name)
|
| 285 |
+
if not pool:
|
| 286 |
+
raise ValueError(f"Pool '{pool_name}' not found")
|
| 287 |
+
pool.put(env, reset=reset)
|
| 288 |
+
|
| 289 |
+
def get_stats(self, pool_name: Optional[str] = None) -> Dict:
|
| 290 |
+
"""Get statistics for one or all pools."""
|
| 291 |
+
if pool_name:
|
| 292 |
+
pool = self._pools.get(pool_name)
|
| 293 |
+
if not pool:
|
| 294 |
+
raise ValueError(f"Pool '{pool_name}' not found")
|
| 295 |
+
return {pool_name: pool.get_stats()}
|
| 296 |
+
else:
|
| 297 |
+
return {name: pool.get_stats() for name, pool in self._pools.items()}
|
| 298 |
+
|
| 299 |
+
def close(self, pool_name: Optional[str] = None):
|
| 300 |
+
"""Close one or all pools."""
|
| 301 |
+
if pool_name:
|
| 302 |
+
pool = self._pools.pop(pool_name, None)
|
| 303 |
+
if pool:
|
| 304 |
+
pool.close()
|
| 305 |
+
else:
|
| 306 |
+
for pool in self._pools.values():
|
| 307 |
+
pool.close()
|
| 308 |
+
self._pools.clear()
|
| 309 |
+
|
| 310 |
+
def __enter__(self):
|
| 311 |
+
return self
|
| 312 |
+
|
| 313 |
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
| 314 |
+
self.close()
|
src/envs/android_env/server/gestures.py
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
Gesture and action utilities for Android environment.
|
| 9 |
+
|
| 10 |
+
This module provides helper classes for composing complex gestures
|
| 11 |
+
from primitive touch events.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
import time
|
| 15 |
+
from dataclasses import dataclass
|
| 16 |
+
from typing import List, Tuple
|
| 17 |
+
|
| 18 |
+
import numpy as np
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
@dataclass
|
| 22 |
+
class TouchPoint:
|
| 23 |
+
"""A point in a touch gesture with timing."""
|
| 24 |
+
x: float # Normalized x coordinate [0, 1]
|
| 25 |
+
y: float # Normalized y coordinate [0, 1]
|
| 26 |
+
duration_ms: int = 100 # How long to hold this position
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class GestureBuilder:
|
| 30 |
+
"""Helper class for building complex gestures from touch primitives."""
|
| 31 |
+
|
| 32 |
+
@staticmethod
|
| 33 |
+
def tap(x: float, y: float, duration_ms: int = 100) -> List[dict]:
|
| 34 |
+
"""Create a tap gesture (touch + lift).
|
| 35 |
+
|
| 36 |
+
Args:
|
| 37 |
+
x: Normalized x coordinate [0, 1]
|
| 38 |
+
y: Normalized y coordinate [0, 1]
|
| 39 |
+
duration_ms: How long to hold the touch
|
| 40 |
+
|
| 41 |
+
Returns:
|
| 42 |
+
List of action dicts representing the tap sequence
|
| 43 |
+
"""
|
| 44 |
+
return [
|
| 45 |
+
{"action_type": 0, "x": x, "y": y, "duration_ms": duration_ms}, # TOUCH
|
| 46 |
+
{"action_type": 1, "x": x, "y": y, "duration_ms": 50}, # LIFT
|
| 47 |
+
]
|
| 48 |
+
|
| 49 |
+
@staticmethod
|
| 50 |
+
def swipe(
|
| 51 |
+
x1: float, y1: float, x2: float, y2: float,
|
| 52 |
+
duration_ms: int = 300, steps: int = 10
|
| 53 |
+
) -> List[dict]:
|
| 54 |
+
"""Create a swipe gesture from (x1, y1) to (x2, y2).
|
| 55 |
+
|
| 56 |
+
Args:
|
| 57 |
+
x1, y1: Start position (normalized [0, 1])
|
| 58 |
+
x2, y2: End position (normalized [0, 1])
|
| 59 |
+
duration_ms: Total duration of the swipe
|
| 60 |
+
steps: Number of intermediate points
|
| 61 |
+
|
| 62 |
+
Returns:
|
| 63 |
+
List of action dicts representing the swipe sequence
|
| 64 |
+
"""
|
| 65 |
+
actions = []
|
| 66 |
+
step_duration = duration_ms // steps
|
| 67 |
+
|
| 68 |
+
# Touch down at start
|
| 69 |
+
actions.append({"action_type": 0, "x": x1, "y": y1, "duration_ms": step_duration})
|
| 70 |
+
|
| 71 |
+
# Move through intermediate points
|
| 72 |
+
for i in range(1, steps):
|
| 73 |
+
t = i / steps
|
| 74 |
+
x = x1 + t * (x2 - x1)
|
| 75 |
+
y = y1 + t * (y2 - y1)
|
| 76 |
+
actions.append({"action_type": 2, "x": x, "y": y, "duration_ms": step_duration}) # REPEAT
|
| 77 |
+
|
| 78 |
+
# Lift at end
|
| 79 |
+
actions.append({"action_type": 1, "x": x2, "y": y2, "duration_ms": 50})
|
| 80 |
+
|
| 81 |
+
return actions
|
| 82 |
+
|
| 83 |
+
@staticmethod
|
| 84 |
+
def long_press(x: float, y: float, duration_ms: int = 1000) -> List[dict]:
|
| 85 |
+
"""Create a long press gesture.
|
| 86 |
+
|
| 87 |
+
Args:
|
| 88 |
+
x, y: Position (normalized [0, 1])
|
| 89 |
+
duration_ms: How long to hold
|
| 90 |
+
|
| 91 |
+
Returns:
|
| 92 |
+
List of action dicts representing the long press
|
| 93 |
+
"""
|
| 94 |
+
return [
|
| 95 |
+
{"action_type": 0, "x": x, "y": y, "duration_ms": duration_ms}, # TOUCH
|
| 96 |
+
{"action_type": 1, "x": x, "y": y, "duration_ms": 50}, # LIFT
|
| 97 |
+
]
|
| 98 |
+
|
| 99 |
+
@staticmethod
|
| 100 |
+
def double_tap(x: float, y: float, gap_ms: int = 100) -> List[dict]:
|
| 101 |
+
"""Create a double tap gesture.
|
| 102 |
+
|
| 103 |
+
Args:
|
| 104 |
+
x, y: Position (normalized [0, 1])
|
| 105 |
+
gap_ms: Time between taps
|
| 106 |
+
|
| 107 |
+
Returns:
|
| 108 |
+
List of action dicts representing the double tap
|
| 109 |
+
"""
|
| 110 |
+
actions = []
|
| 111 |
+
|
| 112 |
+
# First tap
|
| 113 |
+
actions.extend(GestureBuilder.tap(x, y, duration_ms=100))
|
| 114 |
+
|
| 115 |
+
# Gap (represented as a REPEAT at same position)
|
| 116 |
+
actions.append({"action_type": 2, "x": x, "y": y, "duration_ms": gap_ms})
|
| 117 |
+
|
| 118 |
+
# Second tap
|
| 119 |
+
actions.extend(GestureBuilder.tap(x, y, duration_ms=100))
|
| 120 |
+
|
| 121 |
+
return actions
|
| 122 |
+
|
| 123 |
+
@staticmethod
|
| 124 |
+
def scroll_down(x: float = 0.5, distance: float = 0.5, duration_ms: int = 300) -> List[dict]:
|
| 125 |
+
"""Scroll down (swipe up).
|
| 126 |
+
|
| 127 |
+
Args:
|
| 128 |
+
x: Horizontal position (normalized [0, 1])
|
| 129 |
+
distance: How far to scroll (normalized [0, 1])
|
| 130 |
+
duration_ms: Duration of scroll
|
| 131 |
+
|
| 132 |
+
Returns:
|
| 133 |
+
List of action dicts representing the scroll
|
| 134 |
+
"""
|
| 135 |
+
y_start = 0.7
|
| 136 |
+
y_end = max(0.2, y_start - distance)
|
| 137 |
+
return GestureBuilder.swipe(x, y_start, x, y_end, duration_ms=duration_ms)
|
| 138 |
+
|
| 139 |
+
@staticmethod
|
| 140 |
+
def scroll_up(x: float = 0.5, distance: float = 0.5, duration_ms: int = 300) -> List[dict]:
|
| 141 |
+
"""Scroll up (swipe down).
|
| 142 |
+
|
| 143 |
+
Args:
|
| 144 |
+
x: Horizontal position (normalized [0, 1])
|
| 145 |
+
distance: How far to scroll (normalized [0, 1])
|
| 146 |
+
duration_ms: Duration of scroll
|
| 147 |
+
|
| 148 |
+
Returns:
|
| 149 |
+
List of action dicts representing the scroll
|
| 150 |
+
"""
|
| 151 |
+
y_start = 0.3
|
| 152 |
+
y_end = min(0.8, y_start + distance)
|
| 153 |
+
return GestureBuilder.swipe(x, y_start, x, y_end, duration_ms=duration_ms)
|
| 154 |
+
|
| 155 |
+
@staticmethod
|
| 156 |
+
def swipe_left(y: float = 0.5, distance: float = 0.5, duration_ms: int = 300) -> List[dict]:
|
| 157 |
+
"""Swipe left.
|
| 158 |
+
|
| 159 |
+
Args:
|
| 160 |
+
y: Vertical position (normalized [0, 1])
|
| 161 |
+
distance: How far to swipe (normalized [0, 1])
|
| 162 |
+
duration_ms: Duration of swipe
|
| 163 |
+
|
| 164 |
+
Returns:
|
| 165 |
+
List of action dicts representing the swipe
|
| 166 |
+
"""
|
| 167 |
+
x_start = 0.7
|
| 168 |
+
x_end = max(0.2, x_start - distance)
|
| 169 |
+
return GestureBuilder.swipe(x_start, y, x_end, y, duration_ms=duration_ms)
|
| 170 |
+
|
| 171 |
+
@staticmethod
|
| 172 |
+
def swipe_right(y: float = 0.5, distance: float = 0.5, duration_ms: int = 300) -> List[dict]:
|
| 173 |
+
"""Swipe right.
|
| 174 |
+
|
| 175 |
+
Args:
|
| 176 |
+
y: Vertical position (normalized [0, 1])
|
| 177 |
+
distance: How far to swipe (normalized [0, 1])
|
| 178 |
+
duration_ms: Duration of swipe
|
| 179 |
+
|
| 180 |
+
Returns:
|
| 181 |
+
List of action dicts representing the swipe
|
| 182 |
+
"""
|
| 183 |
+
x_start = 0.3
|
| 184 |
+
x_end = min(0.8, x_start + distance)
|
| 185 |
+
return GestureBuilder.swipe(x_start, y, x_end, y, duration_ms=duration_ms)
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
class ADBCommands:
|
| 189 |
+
"""Helper class for ADB commands."""
|
| 190 |
+
|
| 191 |
+
@staticmethod
|
| 192 |
+
def text_input(text: str) -> str:
|
| 193 |
+
"""Generate ADB command for text input.
|
| 194 |
+
|
| 195 |
+
Args:
|
| 196 |
+
text: Text to input
|
| 197 |
+
|
| 198 |
+
Returns:
|
| 199 |
+
ADB command string
|
| 200 |
+
"""
|
| 201 |
+
# Escape special characters for ADB
|
| 202 |
+
# Use double quotes and escape backslashes, double quotes, and spaces
|
| 203 |
+
escaped = text.replace("\\", "\\\\").replace('"', '\\"').replace(" ", "%s")
|
| 204 |
+
return f'input text "{escaped}"'
|
| 205 |
+
|
| 206 |
+
@staticmethod
|
| 207 |
+
def keyevent(keycode: str) -> str:
|
| 208 |
+
"""Generate ADB command for key event.
|
| 209 |
+
|
| 210 |
+
Args:
|
| 211 |
+
keycode: Android keycode (e.g., "KEYCODE_HOME", "KEYCODE_BACK")
|
| 212 |
+
|
| 213 |
+
Returns:
|
| 214 |
+
ADB command string
|
| 215 |
+
"""
|
| 216 |
+
return f"input keyevent {keycode}"
|
| 217 |
+
|
| 218 |
+
@staticmethod
|
| 219 |
+
def tap_coordinates(x: int, y: int) -> str:
|
| 220 |
+
"""Generate ADB command for tap at pixel coordinates.
|
| 221 |
+
|
| 222 |
+
Args:
|
| 223 |
+
x, y: Pixel coordinates
|
| 224 |
+
|
| 225 |
+
Returns:
|
| 226 |
+
ADB command string
|
| 227 |
+
"""
|
| 228 |
+
return f"input tap {x} {y}"
|
| 229 |
+
|
| 230 |
+
@staticmethod
|
| 231 |
+
def swipe_coordinates(x1: int, y1: int, x2: int, y2: int, duration_ms: int = 300) -> str:
|
| 232 |
+
"""Generate ADB command for swipe.
|
| 233 |
+
|
| 234 |
+
Args:
|
| 235 |
+
x1, y1: Start pixel coordinates
|
| 236 |
+
x2, y2: End pixel coordinates
|
| 237 |
+
duration_ms: Duration in milliseconds
|
| 238 |
+
|
| 239 |
+
Returns:
|
| 240 |
+
ADB command string
|
| 241 |
+
"""
|
| 242 |
+
return f"input swipe {x1} {y1} {x2} {y2} {duration_ms}"
|
| 243 |
+
|
| 244 |
+
# Common Android keycodes
|
| 245 |
+
KEYCODE_HOME = "KEYCODE_HOME"
|
| 246 |
+
KEYCODE_BACK = "KEYCODE_BACK"
|
| 247 |
+
KEYCODE_MENU = "KEYCODE_MENU"
|
| 248 |
+
KEYCODE_SEARCH = "KEYCODE_SEARCH"
|
| 249 |
+
KEYCODE_ENTER = "KEYCODE_ENTER"
|
| 250 |
+
KEYCODE_DEL = "KEYCODE_DEL"
|
| 251 |
+
KEYCODE_VOLUME_UP = "KEYCODE_VOLUME_UP"
|
| 252 |
+
KEYCODE_VOLUME_DOWN = "KEYCODE_VOLUME_DOWN"
|
| 253 |
+
KEYCODE_POWER = "KEYCODE_POWER"
|
| 254 |
+
KEYCODE_CAMERA = "KEYCODE_CAMERA"
|
| 255 |
+
KEYCODE_TAB = "KEYCODE_TAB"
|
| 256 |
+
KEYCODE_SPACE = "KEYCODE_SPACE"
|
src/envs/android_env/server/requirements.txt
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Server-side Python dependencies for Android Environment
|
| 2 |
+
# This file is used by the Dockerfile to install necessary packages
|
| 3 |
+
|
| 4 |
+
# Core android_env dependency
|
| 5 |
+
android-env>=1.0.0
|
| 6 |
+
|
| 7 |
+
# Image processing for screen encoding
|
| 8 |
+
Pillow>=10.0.0
|
| 9 |
+
|
| 10 |
+
# Additional dependencies that might be needed
|
| 11 |
+
numpy>=1.24.0
|
| 12 |
+
dm-env>=1.6
|