Upload folder using huggingface_hub
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- Dockerfile +5 -5
- README.md +95 -189
- client.py +2 -1
- envs/repl_env/README.md +73 -172
- envs/repl_env/client.py +2 -1
- envs/repl_env/pyproject.toml +4 -2
- envs/repl_env/runner.py +12 -4
- envs/repl_env/server/Dockerfile +3 -3
- envs/repl_env/server/app.py +26 -7
- envs/repl_env/server/gradio_ui.py +330 -77
- envs/repl_env/server/repl_environment.py +39 -9
- pyproject.toml +4 -2
- runner.py +12 -4
- server/Dockerfile +3 -3
- server/app.py +26 -7
- server/gradio_ui.py +330 -77
- server/repl_environment.py +39 -9
- src/__init__.py +1 -5
- src/core/README.md +46 -21
- src/core/__init__.py +1 -5
- src/core/client_types.py +10 -4
- src/core/containers/__init__.py +1 -5
- src/core/containers/images/Dockerfile +1 -5
- src/core/containers/runtime/__init__.py +6 -5
- src/core/containers/runtime/_server_config.py +53 -0
- src/core/containers/runtime/aca_provider.py +690 -0
- src/core/containers/runtime/daytona_provider.py +31 -56
- src/core/containers/runtime/hf_sandbox_provider.py +342 -0
- src/core/containers/runtime/modal_provider.py +697 -0
- src/core/containers/runtime/novita_provider.py +1216 -0
- src/core/containers/runtime/providers.py +128 -70
- src/core/containers/runtime/uv_provider.py +128 -43
- src/core/containers/test_local_docker_provider.py +1 -1
- src/core/env_client.py +914 -201
- src/core/env_server/__init__.py +11 -6
- src/core/env_server/_utils.py +10 -0
- src/core/env_server/base_transforms.py +1 -5
- src/core/env_server/exceptions.py +1 -5
- src/core/env_server/gradio_theme.py +105 -91
- src/core/env_server/gradio_ui.py +642 -172
- src/core/env_server/http_server.py +737 -160
- src/core/env_server/interfaces.py +239 -65
- src/core/env_server/mcp_environment.py +64 -55
- src/core/env_server/mcp_types.py +3 -8
- src/core/env_server/route_config.py +17 -17
- src/core/env_server/serialization.py +64 -50
- src/core/env_server/types.py +45 -6
- src/core/env_server/web_interface.py +265 -139
- src/core/evals/__init__.py +1 -5
- src/core/evals/base.py +1 -5
Dockerfile
CHANGED
|
@@ -10,8 +10,8 @@
|
|
| 10 |
# - Standalone environments (with openenv from pip)
|
| 11 |
# The build script (openenv build) handles context detection and sets appropriate build args.
|
| 12 |
|
| 13 |
-
ARG BASE_IMAGE=ghcr.io/
|
| 14 |
-
FROM ghcr.io/
|
| 15 |
|
| 16 |
WORKDIR /app
|
| 17 |
|
|
@@ -22,8 +22,8 @@ ARG ENV_NAME=repl_env
|
|
| 22 |
# Copy environment code (always at root of build context)
|
| 23 |
COPY . /app/env
|
| 24 |
|
| 25 |
-
# For in-repo builds, openenv
|
| 26 |
-
# For standalone builds, openenv
|
| 27 |
WORKDIR /app/env
|
| 28 |
|
| 29 |
# Ensure uv is available (for local builds where base image lacks it)
|
|
@@ -59,7 +59,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
|
|
| 59 |
fi
|
| 60 |
|
| 61 |
# Final runtime stage
|
| 62 |
-
FROM ghcr.io/
|
| 63 |
|
| 64 |
WORKDIR /app
|
| 65 |
|
|
|
|
| 10 |
# - Standalone environments (with openenv from pip)
|
| 11 |
# The build script (openenv build) handles context detection and sets appropriate build args.
|
| 12 |
|
| 13 |
+
ARG BASE_IMAGE=ghcr.io/huggingface/openenv-base:latest
|
| 14 |
+
FROM ghcr.io/huggingface/openenv-base:latest AS builder
|
| 15 |
|
| 16 |
WORKDIR /app
|
| 17 |
|
|
|
|
| 22 |
# Copy environment code (always at root of build context)
|
| 23 |
COPY . /app/env
|
| 24 |
|
| 25 |
+
# For in-repo builds, openenv is already in the pyproject.toml dependencies
|
| 26 |
+
# For standalone builds, openenv will be installed from pip via pyproject.toml
|
| 27 |
WORKDIR /app/env
|
| 28 |
|
| 29 |
# Ensure uv is available (for local builds where base image lacks it)
|
|
|
|
| 59 |
fi
|
| 60 |
|
| 61 |
# Final runtime stage
|
| 62 |
+
FROM ghcr.io/huggingface/openenv-base:latest
|
| 63 |
|
| 64 |
WORKDIR /app
|
| 65 |
|
README.md
CHANGED
|
@@ -8,124 +8,34 @@ pinned: false
|
|
| 8 |
app_port: 8000
|
| 9 |
base_path: /web
|
| 10 |
tags:
|
| 11 |
-
- openenv-0.
|
| 12 |
- openenv
|
| 13 |
---
|
| 14 |
|
| 15 |
-
## Hugging Face Space Deployment
|
| 16 |
-
|
| 17 |
-
This Space is built from OpenEnv environment `repl_env`.
|
| 18 |
-
|
| 19 |
-
- Space URL: `https://huggingface.co/spaces/openenv/repl`
|
| 20 |
-
- OpenEnv pinned ref: `0.2.3`
|
| 21 |
-
- Hub tag: `openenv`
|
| 22 |
-
|
| 23 |
-
### Connecting from Code
|
| 24 |
-
|
| 25 |
-
```python
|
| 26 |
-
from envs.repl_env import Env
|
| 27 |
-
|
| 28 |
-
env = Env(base_url="https://huggingface.co/spaces/openenv/repl")
|
| 29 |
-
```
|
| 30 |
-
|
| 31 |
# REPL Environment for OpenEnv
|
| 32 |
|
| 33 |
-
`repl_env` is
|
| 34 |
-
|
| 35 |
-
- `REPLEnv` is the remote async `EnvClient`
|
| 36 |
-
- `.sync()` is the sync wrapper for remote usage
|
| 37 |
-
- `LocalREPLEnv` is the explicit in-process helper
|
| 38 |
-
- `LocalRLMRunner` is the higher-level orchestration loop for local recursive RLM runs
|
| 39 |
-
|
| 40 |
-
The architecture is intentionally split the same way the official `rlm` and DSPy implementations split things:
|
| 41 |
-
|
| 42 |
-
- the environment executes code and exposes tools
|
| 43 |
-
- the runner owns the iterative prompting loop
|
| 44 |
-
- recursive behavior lives in backend/controller modules, not in the executor
|
| 45 |
-
|
| 46 |
-
## Overview
|
| 47 |
-
|
| 48 |
-
Inside the REPL, the model can:
|
| 49 |
|
| 50 |
- inspect `context`
|
| 51 |
-
- execute Python
|
| 52 |
-
- call `llm_query(...)` and `llm_query_batched(...)`
|
| 53 |
-
- call `rlm_query(...)` and `rlm_query_batched(...)` for recursive child runs when configured
|
| 54 |
- finish with `FINAL(...)`, `FINAL_VAR(...)`, or `answer = {"content": ..., "ready": True}`
|
| 55 |
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
Main modules:
|
| 59 |
-
|
| 60 |
-
- [`client.py`](client.py): remote async OpenEnv client
|
| 61 |
-
- [`local.py`](local.py): explicit in-process local env helper
|
| 62 |
-
- [`runner.py`](runner.py): local RLM orchestration loop
|
| 63 |
-
- [`recursive_backends.py`](recursive_backends.py): direct and recursive backend implementations
|
| 64 |
-
- [`recursive_controller.py`](recursive_controller.py): server-side backend/broker composition
|
| 65 |
-
- [`rubrics.py`](rubrics.py): reward rubrics (OpenEnv RFC 004)
|
| 66 |
-
- [`server/repl_environment.py`](server/repl_environment.py): server-side execution environment
|
| 67 |
-
- [`server/app.py`](server/app.py): OpenEnv HTTP server app and env factory
|
| 68 |
-
|
| 69 |
-
## What Works Today
|
| 70 |
-
|
| 71 |
-
- Standard remote OpenEnv usage through `REPLEnv`
|
| 72 |
-
- Local in-process execution through `LocalREPLEnv`
|
| 73 |
-
- Local recursive RLM runs through `LocalRLMRunner`
|
| 74 |
-
- Server-backed recursive calls through the current controller/broker path
|
| 75 |
-
- Explicit recursion controls:
|
| 76 |
-
- `max_depth`
|
| 77 |
-
- `max_children_total`
|
| 78 |
-
- `max_children_per_batch`
|
| 79 |
-
- `per_child_timeout_s`
|
| 80 |
-
- `result_truncation_limit`
|
| 81 |
-
- Lightweight child trace metadata on local runner results
|
| 82 |
-
- Rubric-based rewards (OpenEnv RFC 004):
|
| 83 |
-
- `ExactMatchRubric`: binary outcome reward against ground truth
|
| 84 |
-
- `FuzzyMatchRubric`: partial credit for containment matches
|
| 85 |
-
- `CustomMetricRubric`: user-provided `metric(expected, predicted) -> float`
|
| 86 |
-
- `CodeExecutionRubric`: per-step process reward for code errors
|
| 87 |
-
- `REPLRubric`: composite rubric combining outcome + process
|
| 88 |
-
- Ground truth injectable at reset via `expected_answer`
|
| 89 |
-
|
| 90 |
-
## Rewards
|
| 91 |
-
|
| 92 |
-
Rewards follow the OpenEnv Rubric system (RFC 004). The environment uses
|
| 93 |
-
`REPLRubric` by default, which combines:
|
| 94 |
-
|
| 95 |
-
- **Outcome reward** (on terminal steps): compares `final_answer` against
|
| 96 |
-
`expected_answer` if provided. Returns 1.0 for match, 0.0 otherwise.
|
| 97 |
-
- **Process reward** (on non-terminal steps): returns -0.05 for code
|
| 98 |
-
execution errors, 0.0 for successful steps.
|
| 99 |
-
- **Failure reward**: returns -0.1 when max iterations exhausted without an answer.
|
| 100 |
-
|
| 101 |
-
For RL training (GRPO, etc.), pass `expected_answer` at reset time:
|
| 102 |
-
|
| 103 |
-
```python
|
| 104 |
-
with LocalREPLEnv() as env:
|
| 105 |
-
env.reset(
|
| 106 |
-
context="...",
|
| 107 |
-
task_prompt="...",
|
| 108 |
-
expected_answer="42", # ground truth for rubric scoring
|
| 109 |
-
)
|
| 110 |
-
result = env.execute("print(FINAL(42))")
|
| 111 |
-
print(result.reward) # 1.0 (correct)
|
| 112 |
-
```
|
| 113 |
|
| 114 |
-
|
|
|
|
|
|
|
| 115 |
|
| 116 |
-
|
| 117 |
-
from repl_env import LocalREPLEnv, CustomMetricRubric, REPLRubric
|
| 118 |
|
| 119 |
-
|
| 120 |
-
return 1.0 if expected.strip() == predicted.strip() else 0.0
|
| 121 |
|
| 122 |
-
|
|
|
|
| 123 |
```
|
| 124 |
|
| 125 |
-
## Quick Start
|
| 126 |
-
|
| 127 |
-
### Remote Server Usage
|
| 128 |
-
|
| 129 |
Async:
|
| 130 |
|
| 131 |
```python
|
|
@@ -162,7 +72,7 @@ with REPLEnv(base_url="http://127.0.0.1:8000").sync() as env:
|
|
| 162 |
print(result.observation.result.stdout)
|
| 163 |
```
|
| 164 |
|
| 165 |
-
###
|
| 166 |
|
| 167 |
```python
|
| 168 |
from repl_env import LocalREPLEnv
|
|
@@ -177,7 +87,56 @@ with LocalREPLEnv() as env:
|
|
| 177 |
print(env.state().final_answer)
|
| 178 |
```
|
| 179 |
|
| 180 |
-
##
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 181 |
|
| 182 |
`LocalRLMRunner` takes any `chat_fn(messages, model=None) -> str`. It works
|
| 183 |
with HF Inference API, vLLM, SGLang, Ollama, or any OpenAI-compatible server.
|
|
@@ -226,7 +185,7 @@ runner = LocalRLMRunner(chat_fn, max_iterations=30, max_depth=2)
|
|
| 226 |
result = runner.run(context, task)
|
| 227 |
```
|
| 228 |
|
| 229 |
-
###
|
| 230 |
|
| 231 |
The outer loop (code generation) can use a large model while inner
|
| 232 |
`llm_query`/`rlm_query` calls use a smaller, faster model. Pass a
|
|
@@ -277,45 +236,9 @@ runner = LocalRLMRunner(
|
|
| 277 |
result = runner.run(context, task)
|
| 278 |
```
|
| 279 |
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
Run the local server:
|
| 283 |
-
|
| 284 |
-
```bash
|
| 285 |
-
PYTHONPATH=src:envs uvicorn envs.repl_env.server.app:app --host 127.0.0.1 --port 8000
|
| 286 |
-
```
|
| 287 |
-
|
| 288 |
-
The server uses a proper OpenEnv environment factory in [`server/app.py`](server/app.py).
|
| 289 |
|
| 290 |
-
##
|
| 291 |
-
|
| 292 |
-
### Remote Client
|
| 293 |
-
|
| 294 |
-
```python
|
| 295 |
-
class REPLEnv(EnvClient[REPLAction, REPLObservation, REPLState]):
|
| 296 |
-
async def reset(...)
|
| 297 |
-
async def execute(code: str)
|
| 298 |
-
async def submit_final_answer(answer: str)
|
| 299 |
-
async def state()
|
| 300 |
-
```
|
| 301 |
-
|
| 302 |
-
Use `.sync()` for synchronous code.
|
| 303 |
-
|
| 304 |
-
### Local Helpers
|
| 305 |
-
|
| 306 |
-
```python
|
| 307 |
-
class LocalREPLEnv:
|
| 308 |
-
def reset(...)
|
| 309 |
-
def execute(code: str)
|
| 310 |
-
def state()
|
| 311 |
-
```
|
| 312 |
-
|
| 313 |
-
```python
|
| 314 |
-
class LocalRLMRunner:
|
| 315 |
-
def run(context: str, task_prompt: str, *, model: str | None = None) -> RLMRunResult
|
| 316 |
-
```
|
| 317 |
-
|
| 318 |
-
### Actions and Observations
|
| 319 |
|
| 320 |
`REPLAction`
|
| 321 |
|
|
@@ -339,25 +262,13 @@ reward: float | None
|
|
| 339 |
metadata: dict
|
| 340 |
```
|
| 341 |
|
| 342 |
-
##
|
| 343 |
|
| 344 |
When configured, the REPL namespace exposes:
|
| 345 |
|
| 346 |
-
- `llm_query(prompt, model=None)`
|
| 347 |
-
- `
|
| 348 |
-
- `
|
| 349 |
-
- `rlm_query_batched(prompts, model=None)`
|
| 350 |
-
- `FINAL(value)`
|
| 351 |
-
- `FINAL_VAR(name)`
|
| 352 |
-
- `SHOW_VARS()`
|
| 353 |
-
|
| 354 |
-
Notes:
|
| 355 |
-
|
| 356 |
-
- `rlm_query` is the recursive child-run surface.
|
| 357 |
-
- At max recursion depth, recursion falls back to direct LM calls rather than spawning more children.
|
| 358 |
-
- Lifecycle callbacks follow the official `rlm` pattern:
|
| 359 |
-
- `on_subcall_start(depth, model, prompt_preview)`
|
| 360 |
-
- `on_subcall_complete(depth, model, duration, error_or_none)`
|
| 361 |
|
| 362 |
## Finalization Patterns
|
| 363 |
|
|
@@ -382,44 +293,39 @@ result = env.execute("answer['content'] = '42'")
|
|
| 382 |
result = env.execute("answer['ready'] = True")
|
| 383 |
```
|
| 384 |
|
| 385 |
-
##
|
| 386 |
-
|
| 387 |
-
[`prompts.py`](prompts.py) contains the current message-building and parsing helpers used by the examples and runner.
|
| 388 |
|
| 389 |
-
|
| 390 |
|
| 391 |
-
- `
|
| 392 |
-
- `RLM_SYSTEM_PROMPT_QWEN`
|
| 393 |
-
- `QueryMetadata`
|
| 394 |
-
- `build_rlm_system_prompt(...)`
|
| 395 |
-
- `build_user_prompt(...)`
|
| 396 |
-
- `extract_code_blocks(...)`
|
| 397 |
-
- `format_observations(...)`
|
| 398 |
-
|
| 399 |
-
These prompts were updated to reflect the actual helper surface the environment provides, rather than documenting tools that do not exist.
|
| 400 |
-
|
| 401 |
-
## Examples
|
| 402 |
|
| 403 |
- [`examples/repl_with_llm.py`](../../examples/repl_with_llm.py)
|
| 404 |
- [`examples/repl_oolong_simple.py`](../../examples/repl_oolong_simple.py)
|
| 405 |
|
| 406 |
-
Default hosted model in the examples is currently `Qwen/Qwen3.5-9B`, but real hosted inference still depends on provider availability and token access.
|
| 407 |
-
|
| 408 |
-
## Environment Variables
|
| 409 |
-
|
| 410 |
-
Server-side configuration in [`server/app.py`](server/app.py):
|
| 411 |
-
|
| 412 |
-
- `LLM_MODEL`
|
| 413 |
-
- `HF_TOKEN`
|
| 414 |
-
- `REPL_MAX_ITERATIONS`
|
| 415 |
-
- `REPL_MAX_OUTPUT_LENGTH`
|
| 416 |
-
- `REPL_CONTEXT_PREVIEW_LENGTH`
|
| 417 |
-
- `REPL_RLM_MAX_DEPTH`
|
| 418 |
-
- `REPL_RLM_MAX_ITERATIONS`
|
| 419 |
-
|
| 420 |
## References
|
| 421 |
|
| 422 |
- [RLM Paper (arXiv:2512.24601)](https://huggingface.co/papers/2512.24601)
|
| 423 |
- [RLM Implementation](https://github.com/alexzhang13/rlm)
|
| 424 |
- [Alex Zhang's RLM Blog](https://alexzhang13.github.io/blog/2025/rlm/)
|
| 425 |
- [Prime Intellect RLM Blog](https://www.primeintellect.ai/blog/rlm)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
app_port: 8000
|
| 9 |
base_path: /web
|
| 10 |
tags:
|
| 11 |
+
- openenv-0.10.0
|
| 12 |
- openenv
|
| 13 |
---
|
| 14 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
# REPL Environment for OpenEnv
|
| 16 |
|
| 17 |
+
`repl_env` is a Python REPL environment for [Recursive Language Model](https://huggingface.co/papers/2512.24601) (RLM) style execution. The model writes code that runs in a persistent namespace and can:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
|
| 19 |
- inspect `context`
|
| 20 |
+
- execute Python across multiple turns with persistent state
|
| 21 |
+
- call `llm_query(...)` and `llm_query_batched(...)` to query a language model
|
| 22 |
+
- call `rlm_query(...)` and `rlm_query_batched(...)` for recursive child runs, when configured
|
| 23 |
- finish with `FINAL(...)`, `FINAL_VAR(...)`, or `answer = {"content": ..., "ready": True}`
|
| 24 |
|
| 25 |
+
The package provides:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
|
| 27 |
+
- `REPLEnv`: the async client for a remote server (`.sync()` for synchronous code), with `execute(code)`, `submit_final_answer(answer)`, `get_variable(name)` and `list_variables()` on top of `reset()`/`step()`
|
| 28 |
+
- `LocalREPLEnv`: the same environment, in process
|
| 29 |
+
- `LocalRLMRunner`: a local RLM loop that prompts a model, runs its code and handles recursion
|
| 30 |
|
| 31 |
+
## Quick Start
|
|
|
|
| 32 |
|
| 33 |
+
Start a server:
|
|
|
|
| 34 |
|
| 35 |
+
```bash
|
| 36 |
+
PYTHONPATH=src:envs uvicorn envs.repl_env.server.app:app --host 127.0.0.1 --port 8000
|
| 37 |
```
|
| 38 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
Async:
|
| 40 |
|
| 41 |
```python
|
|
|
|
| 72 |
print(result.observation.result.stdout)
|
| 73 |
```
|
| 74 |
|
| 75 |
+
### In Process
|
| 76 |
|
| 77 |
```python
|
| 78 |
from repl_env import LocalREPLEnv
|
|
|
|
| 87 |
print(env.state().final_answer)
|
| 88 |
```
|
| 89 |
|
| 90 |
+
## Server Configuration
|
| 91 |
+
|
| 92 |
+
Environment variables read by [`server/app.py`](server/app.py):
|
| 93 |
+
|
| 94 |
+
| Variable | Default | Description |
|
| 95 |
+
|----------|---------|-------------|
|
| 96 |
+
| `HF_TOKEN` | unset | Enables `llm_query` on the server. Without it, a client can pass `hf_token` to `reset()` |
|
| 97 |
+
| `LLM_MODEL` | `Qwen/Qwen3.5-9B` | Default model for `llm_query`. A client can pass `llm_model` to `reset()` |
|
| 98 |
+
| `REPL_MAX_ITERATIONS` | `30` | Maximum steps per episode |
|
| 99 |
+
| `REPL_MAX_OUTPUT_LENGTH` | `8192` | Maximum captured output per step |
|
| 100 |
+
| `REPL_CONTEXT_PREVIEW_LENGTH` | `500` | Length of `context_preview` in observations |
|
| 101 |
+
| `REPL_RLM_MAX_DEPTH` | `2` | Maximum recursion depth for `rlm_query` |
|
| 102 |
+
| `REPL_RLM_MAX_ITERATIONS` | `30` | Maximum iterations for recursive child runs |
|
| 103 |
+
| `MAX_CONCURRENT_ENVS` | `8` | Maximum concurrent sessions |
|
| 104 |
+
|
| 105 |
+
## Reward
|
| 106 |
+
|
| 107 |
+
Rewards use the OpenEnv [rubric system](https://huggingface.co/docs/openenv/guides/rewards). The default `REPLRubric` combines:
|
| 108 |
+
|
| 109 |
+
- **Outcome reward** (on terminal steps): compares `final_answer` against
|
| 110 |
+
`expected_answer` if provided. Returns 1.0 for match, 0.0 otherwise.
|
| 111 |
+
- **Process reward** (on non-terminal steps): returns -0.05 for code
|
| 112 |
+
execution errors, 0.0 for successful steps.
|
| 113 |
+
- **Failure reward**: returns -0.1 when max iterations exhausted without an answer.
|
| 114 |
+
|
| 115 |
+
For RL training (GRPO, etc.), pass `expected_answer` at reset time:
|
| 116 |
+
|
| 117 |
+
```python
|
| 118 |
+
with LocalREPLEnv() as env:
|
| 119 |
+
env.reset(
|
| 120 |
+
context="...",
|
| 121 |
+
task_prompt="...",
|
| 122 |
+
expected_answer="42", # ground truth for rubric scoring
|
| 123 |
+
)
|
| 124 |
+
result = env.execute("print(FINAL(42))")
|
| 125 |
+
print(result.reward) # 1.0 (correct)
|
| 126 |
+
```
|
| 127 |
+
|
| 128 |
+
Other rubrics: `ExactMatchRubric` (binary match), `FuzzyMatchRubric` (1.0 for an exact match, 0.5 when the expected answer is contained in the final answer), `CustomMetricRubric` (your `metric(expected, predicted) -> float`) and `CodeExecutionRubric` (per-step error penalty). Pass one at construction:
|
| 129 |
+
|
| 130 |
+
```python
|
| 131 |
+
from repl_env import LocalREPLEnv, CustomMetricRubric, REPLRubric
|
| 132 |
+
|
| 133 |
+
def my_metric(expected, predicted):
|
| 134 |
+
return 1.0 if expected.strip() == predicted.strip() else 0.0
|
| 135 |
+
|
| 136 |
+
env = LocalREPLEnv(rubric=REPLRubric(outcome=CustomMetricRubric(my_metric)))
|
| 137 |
+
```
|
| 138 |
+
|
| 139 |
+
## Running an RLM Locally
|
| 140 |
|
| 141 |
`LocalRLMRunner` takes any `chat_fn(messages, model=None) -> str`. It works
|
| 142 |
with HF Inference API, vLLM, SGLang, Ollama, or any OpenAI-compatible server.
|
|
|
|
| 185 |
result = runner.run(context, task)
|
| 186 |
```
|
| 187 |
|
| 188 |
+
### Different Models for Outer and Inner Loops
|
| 189 |
|
| 190 |
The outer loop (code generation) can use a large model while inner
|
| 191 |
`llm_query`/`rlm_query` calls use a smaller, faster model. Pass a
|
|
|
|
| 236 |
result = runner.run(context, task)
|
| 237 |
```
|
| 238 |
|
| 239 |
+
`LocalRLMRunner` also takes recursion limits (`max_children_total`, `max_children_per_batch`, `per_child_timeout_s`, `result_truncation_limit`) and lifecycle callbacks (`on_subcall_start(depth, model, prompt_preview)`, `on_subcall_complete(depth, model, duration, error_or_none)`). Its results carry lightweight child trace metadata.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 240 |
|
| 241 |
+
## Actions and Observations
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 242 |
|
| 243 |
`REPLAction`
|
| 244 |
|
|
|
|
| 262 |
metadata: dict
|
| 263 |
```
|
| 264 |
|
| 265 |
+
## REPL Helpers
|
| 266 |
|
| 267 |
When configured, the REPL namespace exposes:
|
| 268 |
|
| 269 |
+
- `llm_query(prompt, model=None)` and `llm_query_batched(prompts, model=None)`
|
| 270 |
+
- `rlm_query(prompt, model=None)` and `rlm_query_batched(prompts, model=None)`: recursive child runs. At the maximum depth they fall back to direct model calls.
|
| 271 |
+
- `FINAL(value)`, `FINAL_VAR(name)` and `SHOW_VARS()`
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 272 |
|
| 273 |
## Finalization Patterns
|
| 274 |
|
|
|
|
| 293 |
result = env.execute("answer['ready'] = True")
|
| 294 |
```
|
| 295 |
|
| 296 |
+
## Prompts and Examples
|
|
|
|
|
|
|
| 297 |
|
| 298 |
+
[`prompts.py`](prompts.py) has the system prompts and helpers used by the runner: `RLM_SYSTEM_PROMPT`, `RLM_SYSTEM_PROMPT_QWEN`, `QueryMetadata`, `build_rlm_system_prompt(...)`, `build_user_prompt(...)`, `extract_code_blocks(...)` and `format_observations(...)`.
|
| 299 |
|
| 300 |
+
Examples, which default to `Qwen/Qwen3.5-9B` through Hugging Face inference (needs `HF_TOKEN`):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 301 |
|
| 302 |
- [`examples/repl_with_llm.py`](../../examples/repl_with_llm.py)
|
| 303 |
- [`examples/repl_oolong_simple.py`](../../examples/repl_oolong_simple.py)
|
| 304 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 305 |
## References
|
| 306 |
|
| 307 |
- [RLM Paper (arXiv:2512.24601)](https://huggingface.co/papers/2512.24601)
|
| 308 |
- [RLM Implementation](https://github.com/alexzhang13/rlm)
|
| 309 |
- [Alex Zhang's RLM Blog](https://alexzhang13.github.io/blog/2025/rlm/)
|
| 310 |
- [Prime Intellect RLM Blog](https://www.primeintellect.ai/blog/rlm)
|
| 311 |
+
|
| 312 |
+
## Hugging Face Space Deployment
|
| 313 |
+
|
| 314 |
+
This Space is built from OpenEnv environment `repl_env`.
|
| 315 |
+
|
| 316 |
+
- Space URL: `https://huggingface.co/spaces/openenv/repl`
|
| 317 |
+
- OpenEnv pinned ref: `0.10.0`
|
| 318 |
+
- Hub tag: `openenv`
|
| 319 |
+
|
| 320 |
+
### Connecting from Code
|
| 321 |
+
|
| 322 |
+
```bash
|
| 323 |
+
pip install git+https://huggingface.co/spaces/openenv/repl
|
| 324 |
+
```
|
| 325 |
+
|
| 326 |
+
```python
|
| 327 |
+
from repl_env import REPLEnv
|
| 328 |
+
|
| 329 |
+
with REPLEnv(base_url="https://openenv-repl.hf.space").sync() as env:
|
| 330 |
+
result = env.reset()
|
| 331 |
+
```
|
client.py
CHANGED
|
@@ -76,13 +76,14 @@ class REPLEnv(EnvClient[REPLAction, REPLObservation, REPLState]):
|
|
| 76 |
max_iterations=obs_data.get("max_iterations", 30),
|
| 77 |
done=payload.get("done", False),
|
| 78 |
reward=payload.get("reward"),
|
| 79 |
-
metadata=obs_data.get("metadata", {}),
|
| 80 |
)
|
| 81 |
|
| 82 |
return StepResult(
|
| 83 |
observation=observation,
|
| 84 |
reward=payload.get("reward"),
|
| 85 |
done=payload.get("done", False),
|
|
|
|
| 86 |
)
|
| 87 |
|
| 88 |
def _parse_state(self, payload: dict[str, Any]) -> REPLState:
|
|
|
|
| 76 |
max_iterations=obs_data.get("max_iterations", 30),
|
| 77 |
done=payload.get("done", False),
|
| 78 |
reward=payload.get("reward"),
|
| 79 |
+
metadata=payload.get("metadata", obs_data.get("metadata", {})),
|
| 80 |
)
|
| 81 |
|
| 82 |
return StepResult(
|
| 83 |
observation=observation,
|
| 84 |
reward=payload.get("reward"),
|
| 85 |
done=payload.get("done", False),
|
| 86 |
+
metadata=payload.get("metadata"),
|
| 87 |
)
|
| 88 |
|
| 89 |
def _parse_state(self, payload: dict[str, Any]) -> REPLState:
|
envs/repl_env/README.md
CHANGED
|
@@ -13,102 +13,28 @@ tags:
|
|
| 13 |
|
| 14 |
# REPL Environment for OpenEnv
|
| 15 |
|
| 16 |
-
`repl_env` is
|
| 17 |
-
|
| 18 |
-
- `REPLEnv` is the remote async `EnvClient`
|
| 19 |
-
- `.sync()` is the sync wrapper for remote usage
|
| 20 |
-
- `LocalREPLEnv` is the explicit in-process helper
|
| 21 |
-
- `LocalRLMRunner` is the higher-level orchestration loop for local recursive RLM runs
|
| 22 |
-
|
| 23 |
-
The architecture is intentionally split the same way the official `rlm` and DSPy implementations split things:
|
| 24 |
-
|
| 25 |
-
- the environment executes code and exposes tools
|
| 26 |
-
- the runner owns the iterative prompting loop
|
| 27 |
-
- recursive behavior lives in backend/controller modules, not in the executor
|
| 28 |
-
|
| 29 |
-
## Overview
|
| 30 |
-
|
| 31 |
-
Inside the REPL, the model can:
|
| 32 |
|
| 33 |
- inspect `context`
|
| 34 |
-
- execute Python
|
| 35 |
-
- call `llm_query(...)` and `llm_query_batched(...)`
|
| 36 |
-
- call `rlm_query(...)` and `rlm_query_batched(...)` for recursive child runs when configured
|
| 37 |
- finish with `FINAL(...)`, `FINAL_VAR(...)`, or `answer = {"content": ..., "ready": True}`
|
| 38 |
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
Main modules:
|
| 42 |
-
|
| 43 |
-
- [`client.py`](client.py): remote async OpenEnv client
|
| 44 |
-
- [`local.py`](local.py): explicit in-process local env helper
|
| 45 |
-
- [`runner.py`](runner.py): local RLM orchestration loop
|
| 46 |
-
- [`recursive_backends.py`](recursive_backends.py): direct and recursive backend implementations
|
| 47 |
-
- [`recursive_controller.py`](recursive_controller.py): server-side backend/broker composition
|
| 48 |
-
- [`rubrics.py`](rubrics.py): reward rubrics (OpenEnv RFC 004)
|
| 49 |
-
- [`server/repl_environment.py`](server/repl_environment.py): server-side execution environment
|
| 50 |
-
- [`server/app.py`](server/app.py): OpenEnv HTTP server app and env factory
|
| 51 |
-
|
| 52 |
-
## What Works Today
|
| 53 |
-
|
| 54 |
-
- Standard remote OpenEnv usage through `REPLEnv`
|
| 55 |
-
- Local in-process execution through `LocalREPLEnv`
|
| 56 |
-
- Local recursive RLM runs through `LocalRLMRunner`
|
| 57 |
-
- Server-backed recursive calls through the current controller/broker path
|
| 58 |
-
- Explicit recursion controls:
|
| 59 |
-
- `max_depth`
|
| 60 |
-
- `max_children_total`
|
| 61 |
-
- `max_children_per_batch`
|
| 62 |
-
- `per_child_timeout_s`
|
| 63 |
-
- `result_truncation_limit`
|
| 64 |
-
- Lightweight child trace metadata on local runner results
|
| 65 |
-
- Rubric-based rewards (OpenEnv RFC 004):
|
| 66 |
-
- `ExactMatchRubric`: binary outcome reward against ground truth
|
| 67 |
-
- `FuzzyMatchRubric`: partial credit for containment matches
|
| 68 |
-
- `CustomMetricRubric`: user-provided `metric(expected, predicted) -> float`
|
| 69 |
-
- `CodeExecutionRubric`: per-step process reward for code errors
|
| 70 |
-
- `REPLRubric`: composite rubric combining outcome + process
|
| 71 |
-
- Ground truth injectable at reset via `expected_answer`
|
| 72 |
-
|
| 73 |
-
## Rewards
|
| 74 |
-
|
| 75 |
-
Rewards follow the OpenEnv Rubric system (RFC 004). The environment uses
|
| 76 |
-
`REPLRubric` by default, which combines:
|
| 77 |
|
| 78 |
-
-
|
| 79 |
-
|
| 80 |
-
-
|
| 81 |
-
execution errors, 0.0 for successful steps.
|
| 82 |
-
- **Failure reward**: returns -0.1 when max iterations exhausted without an answer.
|
| 83 |
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
```python
|
| 87 |
-
with LocalREPLEnv() as env:
|
| 88 |
-
env.reset(
|
| 89 |
-
context="...",
|
| 90 |
-
task_prompt="...",
|
| 91 |
-
expected_answer="42", # ground truth for rubric scoring
|
| 92 |
-
)
|
| 93 |
-
result = env.execute("print(FINAL(42))")
|
| 94 |
-
print(result.reward) # 1.0 (correct)
|
| 95 |
-
```
|
| 96 |
-
|
| 97 |
-
Custom rubrics can be injected at construction:
|
| 98 |
-
|
| 99 |
-
```python
|
| 100 |
-
from repl_env import LocalREPLEnv, CustomMetricRubric, REPLRubric
|
| 101 |
|
| 102 |
-
|
| 103 |
-
return 1.0 if expected.strip() == predicted.strip() else 0.0
|
| 104 |
|
| 105 |
-
|
|
|
|
| 106 |
```
|
| 107 |
|
| 108 |
-
## Quick Start
|
| 109 |
-
|
| 110 |
-
### Remote Server Usage
|
| 111 |
-
|
| 112 |
Async:
|
| 113 |
|
| 114 |
```python
|
|
@@ -145,7 +71,7 @@ with REPLEnv(base_url="http://127.0.0.1:8000").sync() as env:
|
|
| 145 |
print(result.observation.result.stdout)
|
| 146 |
```
|
| 147 |
|
| 148 |
-
###
|
| 149 |
|
| 150 |
```python
|
| 151 |
from repl_env import LocalREPLEnv
|
|
@@ -160,7 +86,56 @@ with LocalREPLEnv() as env:
|
|
| 160 |
print(env.state().final_answer)
|
| 161 |
```
|
| 162 |
|
| 163 |
-
##
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 164 |
|
| 165 |
`LocalRLMRunner` takes any `chat_fn(messages, model=None) -> str`. It works
|
| 166 |
with HF Inference API, vLLM, SGLang, Ollama, or any OpenAI-compatible server.
|
|
@@ -209,7 +184,7 @@ runner = LocalRLMRunner(chat_fn, max_iterations=30, max_depth=2)
|
|
| 209 |
result = runner.run(context, task)
|
| 210 |
```
|
| 211 |
|
| 212 |
-
###
|
| 213 |
|
| 214 |
The outer loop (code generation) can use a large model while inner
|
| 215 |
`llm_query`/`rlm_query` calls use a smaller, faster model. Pass a
|
|
@@ -260,45 +235,9 @@ runner = LocalRLMRunner(
|
|
| 260 |
result = runner.run(context, task)
|
| 261 |
```
|
| 262 |
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
Run the local server:
|
| 266 |
-
|
| 267 |
-
```bash
|
| 268 |
-
PYTHONPATH=src:envs uvicorn envs.repl_env.server.app:app --host 127.0.0.1 --port 8000
|
| 269 |
-
```
|
| 270 |
-
|
| 271 |
-
The server uses a proper OpenEnv environment factory in [`server/app.py`](server/app.py).
|
| 272 |
-
|
| 273 |
-
## API Surface
|
| 274 |
-
|
| 275 |
-
### Remote Client
|
| 276 |
-
|
| 277 |
-
```python
|
| 278 |
-
class REPLEnv(EnvClient[REPLAction, REPLObservation, REPLState]):
|
| 279 |
-
async def reset(...)
|
| 280 |
-
async def execute(code: str)
|
| 281 |
-
async def submit_final_answer(answer: str)
|
| 282 |
-
async def state()
|
| 283 |
-
```
|
| 284 |
-
|
| 285 |
-
Use `.sync()` for synchronous code.
|
| 286 |
-
|
| 287 |
-
### Local Helpers
|
| 288 |
-
|
| 289 |
-
```python
|
| 290 |
-
class LocalREPLEnv:
|
| 291 |
-
def reset(...)
|
| 292 |
-
def execute(code: str)
|
| 293 |
-
def state()
|
| 294 |
-
```
|
| 295 |
-
|
| 296 |
-
```python
|
| 297 |
-
class LocalRLMRunner:
|
| 298 |
-
def run(context: str, task_prompt: str, *, model: str | None = None) -> RLMRunResult
|
| 299 |
-
```
|
| 300 |
|
| 301 |
-
##
|
| 302 |
|
| 303 |
`REPLAction`
|
| 304 |
|
|
@@ -322,25 +261,13 @@ reward: float | None
|
|
| 322 |
metadata: dict
|
| 323 |
```
|
| 324 |
|
| 325 |
-
##
|
| 326 |
|
| 327 |
When configured, the REPL namespace exposes:
|
| 328 |
|
| 329 |
-
- `llm_query(prompt, model=None)`
|
| 330 |
-
- `
|
| 331 |
-
- `
|
| 332 |
-
- `rlm_query_batched(prompts, model=None)`
|
| 333 |
-
- `FINAL(value)`
|
| 334 |
-
- `FINAL_VAR(name)`
|
| 335 |
-
- `SHOW_VARS()`
|
| 336 |
-
|
| 337 |
-
Notes:
|
| 338 |
-
|
| 339 |
-
- `rlm_query` is the recursive child-run surface.
|
| 340 |
-
- At max recursion depth, recursion falls back to direct LM calls rather than spawning more children.
|
| 341 |
-
- Lifecycle callbacks follow the official `rlm` pattern:
|
| 342 |
-
- `on_subcall_start(depth, model, prompt_preview)`
|
| 343 |
-
- `on_subcall_complete(depth, model, duration, error_or_none)`
|
| 344 |
|
| 345 |
## Finalization Patterns
|
| 346 |
|
|
@@ -365,41 +292,15 @@ result = env.execute("answer['content'] = '42'")
|
|
| 365 |
result = env.execute("answer['ready'] = True")
|
| 366 |
```
|
| 367 |
|
| 368 |
-
##
|
| 369 |
-
|
| 370 |
-
[`prompts.py`](prompts.py) contains the current message-building and parsing helpers used by the examples and runner.
|
| 371 |
|
| 372 |
-
|
| 373 |
|
| 374 |
-
- `
|
| 375 |
-
- `RLM_SYSTEM_PROMPT_QWEN`
|
| 376 |
-
- `QueryMetadata`
|
| 377 |
-
- `build_rlm_system_prompt(...)`
|
| 378 |
-
- `build_user_prompt(...)`
|
| 379 |
-
- `extract_code_blocks(...)`
|
| 380 |
-
- `format_observations(...)`
|
| 381 |
-
|
| 382 |
-
These prompts were updated to reflect the actual helper surface the environment provides, rather than documenting tools that do not exist.
|
| 383 |
-
|
| 384 |
-
## Examples
|
| 385 |
|
| 386 |
- [`examples/repl_with_llm.py`](../../examples/repl_with_llm.py)
|
| 387 |
- [`examples/repl_oolong_simple.py`](../../examples/repl_oolong_simple.py)
|
| 388 |
|
| 389 |
-
Default hosted model in the examples is currently `Qwen/Qwen3.5-9B`, but real hosted inference still depends on provider availability and token access.
|
| 390 |
-
|
| 391 |
-
## Environment Variables
|
| 392 |
-
|
| 393 |
-
Server-side configuration in [`server/app.py`](server/app.py):
|
| 394 |
-
|
| 395 |
-
- `LLM_MODEL`
|
| 396 |
-
- `HF_TOKEN`
|
| 397 |
-
- `REPL_MAX_ITERATIONS`
|
| 398 |
-
- `REPL_MAX_OUTPUT_LENGTH`
|
| 399 |
-
- `REPL_CONTEXT_PREVIEW_LENGTH`
|
| 400 |
-
- `REPL_RLM_MAX_DEPTH`
|
| 401 |
-
- `REPL_RLM_MAX_ITERATIONS`
|
| 402 |
-
|
| 403 |
## References
|
| 404 |
|
| 405 |
- [RLM Paper (arXiv:2512.24601)](https://huggingface.co/papers/2512.24601)
|
|
|
|
| 13 |
|
| 14 |
# REPL Environment for OpenEnv
|
| 15 |
|
| 16 |
+
`repl_env` is a Python REPL environment for [Recursive Language Model](https://huggingface.co/papers/2512.24601) (RLM) style execution. The model writes code that runs in a persistent namespace and can:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
|
| 18 |
- inspect `context`
|
| 19 |
+
- execute Python across multiple turns with persistent state
|
| 20 |
+
- call `llm_query(...)` and `llm_query_batched(...)` to query a language model
|
| 21 |
+
- call `rlm_query(...)` and `rlm_query_batched(...)` for recursive child runs, when configured
|
| 22 |
- finish with `FINAL(...)`, `FINAL_VAR(...)`, or `answer = {"content": ..., "ready": True}`
|
| 23 |
|
| 24 |
+
The package provides:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
|
| 26 |
+
- `REPLEnv`: the async client for a remote server (`.sync()` for synchronous code), with `execute(code)`, `submit_final_answer(answer)`, `get_variable(name)` and `list_variables()` on top of `reset()`/`step()`
|
| 27 |
+
- `LocalREPLEnv`: the same environment, in process
|
| 28 |
+
- `LocalRLMRunner`: a local RLM loop that prompts a model, runs its code and handles recursion
|
|
|
|
|
|
|
| 29 |
|
| 30 |
+
## Quick Start
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
|
| 32 |
+
Start a server:
|
|
|
|
| 33 |
|
| 34 |
+
```bash
|
| 35 |
+
PYTHONPATH=src:envs uvicorn envs.repl_env.server.app:app --host 127.0.0.1 --port 8000
|
| 36 |
```
|
| 37 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
Async:
|
| 39 |
|
| 40 |
```python
|
|
|
|
| 71 |
print(result.observation.result.stdout)
|
| 72 |
```
|
| 73 |
|
| 74 |
+
### In Process
|
| 75 |
|
| 76 |
```python
|
| 77 |
from repl_env import LocalREPLEnv
|
|
|
|
| 86 |
print(env.state().final_answer)
|
| 87 |
```
|
| 88 |
|
| 89 |
+
## Server Configuration
|
| 90 |
+
|
| 91 |
+
Environment variables read by [`server/app.py`](server/app.py):
|
| 92 |
+
|
| 93 |
+
| Variable | Default | Description |
|
| 94 |
+
|----------|---------|-------------|
|
| 95 |
+
| `HF_TOKEN` | unset | Enables `llm_query` on the server. Without it, a client can pass `hf_token` to `reset()` |
|
| 96 |
+
| `LLM_MODEL` | `Qwen/Qwen3.5-9B` | Default model for `llm_query`. A client can pass `llm_model` to `reset()` |
|
| 97 |
+
| `REPL_MAX_ITERATIONS` | `30` | Maximum steps per episode |
|
| 98 |
+
| `REPL_MAX_OUTPUT_LENGTH` | `8192` | Maximum captured output per step |
|
| 99 |
+
| `REPL_CONTEXT_PREVIEW_LENGTH` | `500` | Length of `context_preview` in observations |
|
| 100 |
+
| `REPL_RLM_MAX_DEPTH` | `2` | Maximum recursion depth for `rlm_query` |
|
| 101 |
+
| `REPL_RLM_MAX_ITERATIONS` | `30` | Maximum iterations for recursive child runs |
|
| 102 |
+
| `MAX_CONCURRENT_ENVS` | `8` | Maximum concurrent sessions |
|
| 103 |
+
|
| 104 |
+
## Reward
|
| 105 |
+
|
| 106 |
+
Rewards use the OpenEnv [rubric system](https://huggingface.co/docs/openenv/guides/rewards). The default `REPLRubric` combines:
|
| 107 |
+
|
| 108 |
+
- **Outcome reward** (on terminal steps): compares `final_answer` against
|
| 109 |
+
`expected_answer` if provided. Returns 1.0 for match, 0.0 otherwise.
|
| 110 |
+
- **Process reward** (on non-terminal steps): returns -0.05 for code
|
| 111 |
+
execution errors, 0.0 for successful steps.
|
| 112 |
+
- **Failure reward**: returns -0.1 when max iterations exhausted without an answer.
|
| 113 |
+
|
| 114 |
+
For RL training (GRPO, etc.), pass `expected_answer` at reset time:
|
| 115 |
+
|
| 116 |
+
```python
|
| 117 |
+
with LocalREPLEnv() as env:
|
| 118 |
+
env.reset(
|
| 119 |
+
context="...",
|
| 120 |
+
task_prompt="...",
|
| 121 |
+
expected_answer="42", # ground truth for rubric scoring
|
| 122 |
+
)
|
| 123 |
+
result = env.execute("print(FINAL(42))")
|
| 124 |
+
print(result.reward) # 1.0 (correct)
|
| 125 |
+
```
|
| 126 |
+
|
| 127 |
+
Other rubrics: `ExactMatchRubric` (binary match), `FuzzyMatchRubric` (1.0 for an exact match, 0.5 when the expected answer is contained in the final answer), `CustomMetricRubric` (your `metric(expected, predicted) -> float`) and `CodeExecutionRubric` (per-step error penalty). Pass one at construction:
|
| 128 |
+
|
| 129 |
+
```python
|
| 130 |
+
from repl_env import LocalREPLEnv, CustomMetricRubric, REPLRubric
|
| 131 |
+
|
| 132 |
+
def my_metric(expected, predicted):
|
| 133 |
+
return 1.0 if expected.strip() == predicted.strip() else 0.0
|
| 134 |
+
|
| 135 |
+
env = LocalREPLEnv(rubric=REPLRubric(outcome=CustomMetricRubric(my_metric)))
|
| 136 |
+
```
|
| 137 |
+
|
| 138 |
+
## Running an RLM Locally
|
| 139 |
|
| 140 |
`LocalRLMRunner` takes any `chat_fn(messages, model=None) -> str`. It works
|
| 141 |
with HF Inference API, vLLM, SGLang, Ollama, or any OpenAI-compatible server.
|
|
|
|
| 184 |
result = runner.run(context, task)
|
| 185 |
```
|
| 186 |
|
| 187 |
+
### Different Models for Outer and Inner Loops
|
| 188 |
|
| 189 |
The outer loop (code generation) can use a large model while inner
|
| 190 |
`llm_query`/`rlm_query` calls use a smaller, faster model. Pass a
|
|
|
|
| 235 |
result = runner.run(context, task)
|
| 236 |
```
|
| 237 |
|
| 238 |
+
`LocalRLMRunner` also takes recursion limits (`max_children_total`, `max_children_per_batch`, `per_child_timeout_s`, `result_truncation_limit`) and lifecycle callbacks (`on_subcall_start(depth, model, prompt_preview)`, `on_subcall_complete(depth, model, duration, error_or_none)`). Its results carry lightweight child trace metadata.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 239 |
|
| 240 |
+
## Actions and Observations
|
| 241 |
|
| 242 |
`REPLAction`
|
| 243 |
|
|
|
|
| 261 |
metadata: dict
|
| 262 |
```
|
| 263 |
|
| 264 |
+
## REPL Helpers
|
| 265 |
|
| 266 |
When configured, the REPL namespace exposes:
|
| 267 |
|
| 268 |
+
- `llm_query(prompt, model=None)` and `llm_query_batched(prompts, model=None)`
|
| 269 |
+
- `rlm_query(prompt, model=None)` and `rlm_query_batched(prompts, model=None)`: recursive child runs. At the maximum depth they fall back to direct model calls.
|
| 270 |
+
- `FINAL(value)`, `FINAL_VAR(name)` and `SHOW_VARS()`
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 271 |
|
| 272 |
## Finalization Patterns
|
| 273 |
|
|
|
|
| 292 |
result = env.execute("answer['ready'] = True")
|
| 293 |
```
|
| 294 |
|
| 295 |
+
## Prompts and Examples
|
|
|
|
|
|
|
| 296 |
|
| 297 |
+
[`prompts.py`](prompts.py) has the system prompts and helpers used by the runner: `RLM_SYSTEM_PROMPT`, `RLM_SYSTEM_PROMPT_QWEN`, `QueryMetadata`, `build_rlm_system_prompt(...)`, `build_user_prompt(...)`, `extract_code_blocks(...)` and `format_observations(...)`.
|
| 298 |
|
| 299 |
+
Examples, which default to `Qwen/Qwen3.5-9B` through Hugging Face inference (needs `HF_TOKEN`):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 300 |
|
| 301 |
- [`examples/repl_with_llm.py`](../../examples/repl_with_llm.py)
|
| 302 |
- [`examples/repl_oolong_simple.py`](../../examples/repl_oolong_simple.py)
|
| 303 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 304 |
## References
|
| 305 |
|
| 306 |
- [RLM Paper (arXiv:2512.24601)](https://huggingface.co/papers/2512.24601)
|
envs/repl_env/client.py
CHANGED
|
@@ -76,13 +76,14 @@ class REPLEnv(EnvClient[REPLAction, REPLObservation, REPLState]):
|
|
| 76 |
max_iterations=obs_data.get("max_iterations", 30),
|
| 77 |
done=payload.get("done", False),
|
| 78 |
reward=payload.get("reward"),
|
| 79 |
-
metadata=obs_data.get("metadata", {}),
|
| 80 |
)
|
| 81 |
|
| 82 |
return StepResult(
|
| 83 |
observation=observation,
|
| 84 |
reward=payload.get("reward"),
|
| 85 |
done=payload.get("done", False),
|
|
|
|
| 86 |
)
|
| 87 |
|
| 88 |
def _parse_state(self, payload: dict[str, Any]) -> REPLState:
|
|
|
|
| 76 |
max_iterations=obs_data.get("max_iterations", 30),
|
| 77 |
done=payload.get("done", False),
|
| 78 |
reward=payload.get("reward"),
|
| 79 |
+
metadata=payload.get("metadata", obs_data.get("metadata", {})),
|
| 80 |
)
|
| 81 |
|
| 82 |
return StepResult(
|
| 83 |
observation=observation,
|
| 84 |
reward=payload.get("reward"),
|
| 85 |
done=payload.get("done", False),
|
| 86 |
+
metadata=payload.get("metadata"),
|
| 87 |
)
|
| 88 |
|
| 89 |
def _parse_state(self, payload: dict[str, Any]) -> REPLState:
|
envs/repl_env/pyproject.toml
CHANGED
|
@@ -15,7 +15,7 @@ description = "Recursive Language Model REPL Environment for OpenEnv"
|
|
| 15 |
requires-python = ">=3.10"
|
| 16 |
dependencies = [
|
| 17 |
# Core OpenEnv dependencies (required for server functionality)
|
| 18 |
-
"openenv
|
| 19 |
"fastapi>=0.115.0",
|
| 20 |
"pydantic>=2.0.0",
|
| 21 |
"uvicorn>=0.24.0",
|
|
@@ -26,11 +26,13 @@ dependencies = [
|
|
| 26 |
"huggingface_hub>=0.20.0",
|
| 27 |
# REPL custom Gradio tab on /web
|
| 28 |
"gradio>=4.0.0",
|
|
|
|
|
|
|
| 29 |
]
|
| 30 |
|
| 31 |
[project.optional-dependencies]
|
| 32 |
dev = [
|
| 33 |
-
"pytest>=
|
| 34 |
"pytest-cov>=4.0.0",
|
| 35 |
]
|
| 36 |
|
|
|
|
| 15 |
requires-python = ">=3.10"
|
| 16 |
dependencies = [
|
| 17 |
# Core OpenEnv dependencies (required for server functionality)
|
| 18 |
+
"openenv @ git+https://github.com/huggingface/OpenEnv.git@v0.10.0",
|
| 19 |
"fastapi>=0.115.0",
|
| 20 |
"pydantic>=2.0.0",
|
| 21 |
"uvicorn>=0.24.0",
|
|
|
|
| 26 |
"huggingface_hub>=0.20.0",
|
| 27 |
# REPL custom Gradio tab on /web
|
| 28 |
"gradio>=4.0.0",
|
| 29 |
+
# Document upload support in the Gradio tab (server-side only, not in the sandbox)
|
| 30 |
+
"pypdf>=6.16.1",
|
| 31 |
]
|
| 32 |
|
| 33 |
[project.optional-dependencies]
|
| 34 |
dev = [
|
| 35 |
+
"pytest>=9.0.3",
|
| 36 |
"pytest-cov>=4.0.0",
|
| 37 |
]
|
| 38 |
|
envs/repl_env/runner.py
CHANGED
|
@@ -243,10 +243,18 @@ class LocalRLMRunner:
|
|
| 243 |
]
|
| 244 |
try:
|
| 245 |
response = self._chat(final_prompt, model)
|
| 246 |
-
# Try to extract FINAL(...) from the response
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 250 |
# If no FINAL pattern, return the raw response as best-effort
|
| 251 |
return response.strip() if response.strip() else None
|
| 252 |
except Exception:
|
|
|
|
| 243 |
]
|
| 244 |
try:
|
| 245 |
response = self._chat(final_prompt, model)
|
| 246 |
+
# Try to extract FINAL(...) from the response. Paren-counting handles
|
| 247 |
+
# nested parens and mid-sentence FINAL without regex flag trade-offs.
|
| 248 |
+
idx = response.find("FINAL(")
|
| 249 |
+
if idx != -1:
|
| 250 |
+
depth, start = 0, idx + len("FINAL")
|
| 251 |
+
for i, ch in enumerate(response[idx + len("FINAL"):], start=idx + len("FINAL")):
|
| 252 |
+
if ch == "(":
|
| 253 |
+
depth += 1
|
| 254 |
+
elif ch == ")":
|
| 255 |
+
depth -= 1
|
| 256 |
+
if depth == 0:
|
| 257 |
+
return response[start + 1 : i].strip()
|
| 258 |
# If no FINAL pattern, return the raw response as best-effort
|
| 259 |
return response.strip() if response.strip() else None
|
| 260 |
except Exception:
|
envs/repl_env/server/Dockerfile
CHANGED
|
@@ -10,7 +10,7 @@
|
|
| 10 |
# - Standalone environments (with openenv from pip)
|
| 11 |
# The build script (openenv build) handles context detection and sets appropriate build args.
|
| 12 |
|
| 13 |
-
ARG BASE_IMAGE=ghcr.io/
|
| 14 |
FROM ${BASE_IMAGE} AS builder
|
| 15 |
|
| 16 |
WORKDIR /app
|
|
@@ -22,8 +22,8 @@ ARG ENV_NAME=repl_env
|
|
| 22 |
# Copy environment code (always at root of build context)
|
| 23 |
COPY . /app/env
|
| 24 |
|
| 25 |
-
# For in-repo builds, openenv
|
| 26 |
-
# For standalone builds, openenv
|
| 27 |
WORKDIR /app/env
|
| 28 |
|
| 29 |
# Ensure uv is available (for local builds where base image lacks it)
|
|
|
|
| 10 |
# - Standalone environments (with openenv from pip)
|
| 11 |
# The build script (openenv build) handles context detection and sets appropriate build args.
|
| 12 |
|
| 13 |
+
ARG BASE_IMAGE=ghcr.io/huggingface/openenv-base:latest
|
| 14 |
FROM ${BASE_IMAGE} AS builder
|
| 15 |
|
| 16 |
WORKDIR /app
|
|
|
|
| 22 |
# Copy environment code (always at root of build context)
|
| 23 |
COPY . /app/env
|
| 24 |
|
| 25 |
+
# For in-repo builds, openenv is already in the pyproject.toml dependencies
|
| 26 |
+
# For standalone builds, openenv will be installed from pip via pyproject.toml
|
| 27 |
WORKDIR /app/env
|
| 28 |
|
| 29 |
# Ensure uv is available (for local builds where base image lacks it)
|
envs/repl_env/server/app.py
CHANGED
|
@@ -33,6 +33,7 @@ Usage:
|
|
| 33 |
Environment Variables:
|
| 34 |
HF_TOKEN: Fallback HuggingFace API token (client token takes priority)
|
| 35 |
LLM_MODEL: Model to use for llm_query/llm_query_batched (default: Qwen/Qwen3.5-9B)
|
|
|
|
| 36 |
"""
|
| 37 |
|
| 38 |
import inspect
|
|
@@ -42,11 +43,11 @@ import os
|
|
| 42 |
try:
|
| 43 |
from openenv.core.env_server.http_server import create_app
|
| 44 |
|
| 45 |
-
from ..models import REPLAction, REPLObservation
|
| 46 |
from .gradio_ui import build_repl_gradio_app
|
| 47 |
from .repl_environment import REPLEnvironment
|
| 48 |
except ImportError:
|
| 49 |
-
from models import REPLAction, REPLObservation
|
| 50 |
from openenv.core.env_server.http_server import create_app
|
| 51 |
from server.gradio_ui import build_repl_gradio_app
|
| 52 |
from server.repl_environment import REPLEnvironment
|
|
@@ -60,6 +61,7 @@ REPL_MAX_OUTPUT_LENGTH = int(os.environ.get("REPL_MAX_OUTPUT_LENGTH", "8192"))
|
|
| 60 |
REPL_CONTEXT_PREVIEW_LENGTH = int(os.environ.get("REPL_CONTEXT_PREVIEW_LENGTH", "500"))
|
| 61 |
REPL_RLM_MAX_DEPTH = int(os.environ.get("REPL_RLM_MAX_DEPTH", "2"))
|
| 62 |
REPL_RLM_MAX_ITERATIONS = int(os.environ.get("REPL_RLM_MAX_ITERATIONS", "30"))
|
|
|
|
| 63 |
# ==========================================
|
| 64 |
|
| 65 |
_logger = logging.getLogger(__name__)
|
|
@@ -94,17 +96,34 @@ def create_repl_environment() -> REPLEnvironment:
|
|
| 94 |
# Create the app with web interface and README integration.
|
| 95 |
_sig = inspect.signature(create_app)
|
| 96 |
if "gradio_builder" in _sig.parameters:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
app = create_app(
|
| 98 |
create_repl_environment,
|
| 99 |
REPLAction,
|
| 100 |
REPLObservation,
|
| 101 |
-
|
| 102 |
-
max_concurrent_envs=8,
|
| 103 |
-
gradio_builder=build_repl_gradio_app,
|
| 104 |
)
|
| 105 |
else:
|
| 106 |
_logger.warning(
|
| 107 |
-
"Installed openenv
|
| 108 |
"custom REPL Gradio tab will not be available."
|
| 109 |
)
|
| 110 |
app = create_app(
|
|
@@ -112,7 +131,7 @@ else:
|
|
| 112 |
REPLAction,
|
| 113 |
REPLObservation,
|
| 114 |
env_name="repl_env",
|
| 115 |
-
max_concurrent_envs=
|
| 116 |
)
|
| 117 |
|
| 118 |
|
|
|
|
| 33 |
Environment Variables:
|
| 34 |
HF_TOKEN: Fallback HuggingFace API token (client token takes priority)
|
| 35 |
LLM_MODEL: Model to use for llm_query/llm_query_batched (default: Qwen/Qwen3.5-9B)
|
| 36 |
+
MAX_CONCURRENT_ENVS: Maximum concurrent WebSocket sessions (default: 8)
|
| 37 |
"""
|
| 38 |
|
| 39 |
import inspect
|
|
|
|
| 43 |
try:
|
| 44 |
from openenv.core.env_server.http_server import create_app
|
| 45 |
|
| 46 |
+
from ..models import REPLAction, REPLObservation, REPLState
|
| 47 |
from .gradio_ui import build_repl_gradio_app
|
| 48 |
from .repl_environment import REPLEnvironment
|
| 49 |
except ImportError:
|
| 50 |
+
from models import REPLAction, REPLObservation, REPLState
|
| 51 |
from openenv.core.env_server.http_server import create_app
|
| 52 |
from server.gradio_ui import build_repl_gradio_app
|
| 53 |
from server.repl_environment import REPLEnvironment
|
|
|
|
| 61 |
REPL_CONTEXT_PREVIEW_LENGTH = int(os.environ.get("REPL_CONTEXT_PREVIEW_LENGTH", "500"))
|
| 62 |
REPL_RLM_MAX_DEPTH = int(os.environ.get("REPL_RLM_MAX_DEPTH", "2"))
|
| 63 |
REPL_RLM_MAX_ITERATIONS = int(os.environ.get("REPL_RLM_MAX_ITERATIONS", "30"))
|
| 64 |
+
MAX_CONCURRENT_ENVS = int(os.environ.get("MAX_CONCURRENT_ENVS", "8"))
|
| 65 |
# ==========================================
|
| 66 |
|
| 67 |
_logger = logging.getLogger(__name__)
|
|
|
|
| 96 |
# Create the app with web interface and README integration.
|
| 97 |
_sig = inspect.signature(create_app)
|
| 98 |
if "gradio_builder" in _sig.parameters:
|
| 99 |
+
# Each kwarg is guarded by inspect.signature so older openenv
|
| 100 |
+
# releases that predate the param still boot this env.
|
| 101 |
+
create_app_kwargs: dict = {
|
| 102 |
+
"env_name": "repl_env",
|
| 103 |
+
"max_concurrent_envs": MAX_CONCURRENT_ENVS,
|
| 104 |
+
"gradio_builder": build_repl_gradio_app,
|
| 105 |
+
}
|
| 106 |
+
if "custom_tab_name" in _sig.parameters:
|
| 107 |
+
create_app_kwargs["custom_tab_name"] = "REPL"
|
| 108 |
+
if "custom_tab_primary" in _sig.parameters:
|
| 109 |
+
create_app_kwargs["custom_tab_primary"] = True
|
| 110 |
+
if "show_default_tab" in _sig.parameters:
|
| 111 |
+
create_app_kwargs["show_default_tab"] = False
|
| 112 |
+
if "title_override" in _sig.parameters:
|
| 113 |
+
create_app_kwargs["title_override"] = (
|
| 114 |
+
"OpenEnv REPL — Recursive Language Model playground"
|
| 115 |
+
)
|
| 116 |
+
if "state_cls" in _sig.parameters:
|
| 117 |
+
create_app_kwargs["state_cls"] = REPLState
|
| 118 |
app = create_app(
|
| 119 |
create_repl_environment,
|
| 120 |
REPLAction,
|
| 121 |
REPLObservation,
|
| 122 |
+
**create_app_kwargs,
|
|
|
|
|
|
|
| 123 |
)
|
| 124 |
else:
|
| 125 |
_logger.warning(
|
| 126 |
+
"Installed openenv does not support gradio_builder; "
|
| 127 |
"custom REPL Gradio tab will not be available."
|
| 128 |
)
|
| 129 |
app = create_app(
|
|
|
|
| 131 |
REPLAction,
|
| 132 |
REPLObservation,
|
| 133 |
env_name="repl_env",
|
| 134 |
+
max_concurrent_envs=MAX_CONCURRENT_ENVS,
|
| 135 |
)
|
| 136 |
|
| 137 |
|
envs/repl_env/server/gradio_ui.py
CHANGED
|
@@ -8,57 +8,210 @@
|
|
| 8 |
|
| 9 |
from __future__ import annotations
|
| 10 |
|
|
|
|
| 11 |
import json
|
|
|
|
|
|
|
|
|
|
| 12 |
from typing import Any, Dict, List, Optional
|
| 13 |
|
| 14 |
import gradio as gr
|
| 15 |
from openenv.core.env_server.types import EnvironmentMetadata
|
| 16 |
|
| 17 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
def _code_block(title: str, content: str) -> str:
|
| 19 |
if not content:
|
| 20 |
return ""
|
| 21 |
return f"**{title}:**\n```text\n{content}\n```"
|
| 22 |
|
| 23 |
|
| 24 |
-
def _format_repl_response(
|
| 25 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
observation = data.get("observation", {})
|
| 27 |
result = observation.get("result", {})
|
| 28 |
-
sections: List[str] = ["# REPL Session"]
|
| 29 |
|
| 30 |
-
|
| 31 |
-
if context_preview:
|
| 32 |
-
sections.append(_code_block("Context Preview", context_preview))
|
| 33 |
|
| 34 |
-
|
| 35 |
-
if
|
| 36 |
-
|
|
|
|
| 37 |
|
| 38 |
-
|
| 39 |
-
if
|
| 40 |
-
sections.append(
|
| 41 |
-
"**Available Variables:** " + ", ".join(f"`{name}`" for name in available_variables)
|
| 42 |
-
)
|
| 43 |
|
| 44 |
-
if
|
| 45 |
-
sections.append(
|
| 46 |
-
"**Locals Snapshot:**\n```json\n"
|
| 47 |
-
+ json.dumps(result["locals_snapshot"], indent=2, sort_keys=True)
|
| 48 |
-
+ "\n```"
|
| 49 |
-
)
|
| 50 |
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
sections.append(_code_block("Stderr", stderr))
|
| 55 |
|
|
|
|
| 56 |
reward = data.get("reward")
|
|
|
|
|
|
|
| 57 |
done = data.get("done")
|
| 58 |
-
|
| 59 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
|
| 61 |
-
|
|
|
|
|
|
|
| 62 |
|
| 63 |
|
| 64 |
def build_repl_gradio_app(
|
|
@@ -70,48 +223,62 @@ def build_repl_gradio_app(
|
|
| 70 |
quick_start_md: str,
|
| 71 |
) -> gr.Blocks:
|
| 72 |
"""Build the REPL-specific Gradio tab."""
|
| 73 |
-
del action_fields, is_chat_env
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
|
| 75 |
async def reset_repl(
|
| 76 |
-
context: str,
|
| 77 |
-
task_prompt: str,
|
| 78 |
-
|
| 79 |
-
|
|
|
|
| 80 |
):
|
|
|
|
| 81 |
reset_kwargs: Dict[str, Any] = {}
|
| 82 |
-
if context.strip():
|
| 83 |
reset_kwargs["context"] = context
|
| 84 |
-
if task_prompt.strip():
|
| 85 |
reset_kwargs["task_prompt"] = task_prompt
|
| 86 |
-
if
|
| 87 |
-
reset_kwargs["
|
| 88 |
-
if
|
|
|
|
|
|
|
| 89 |
reset_kwargs["llm_model"] = llm_model
|
| 90 |
|
| 91 |
try:
|
|
|
|
| 92 |
data = await web_manager.reset_environment(reset_kwargs)
|
|
|
|
| 93 |
state = web_manager.get_state()
|
| 94 |
return (
|
| 95 |
-
_format_repl_response(data),
|
| 96 |
json.dumps(data, indent=2, sort_keys=True),
|
| 97 |
json.dumps(state, indent=2, sort_keys=True),
|
| 98 |
-
"
|
| 99 |
)
|
| 100 |
except Exception as exc:
|
| 101 |
return ("", "", "", f"Error: {exc}")
|
| 102 |
|
| 103 |
-
async def run_code(code: str):
|
| 104 |
-
if not code.strip():
|
| 105 |
return ("", "", "", "Enter Python code to run.")
|
| 106 |
|
| 107 |
try:
|
|
|
|
| 108 |
data = await web_manager.step_environment({"code": code})
|
|
|
|
| 109 |
state = web_manager.get_state()
|
| 110 |
return (
|
| 111 |
-
_format_repl_response(data),
|
| 112 |
json.dumps(data, indent=2, sort_keys=True),
|
| 113 |
json.dumps(state, indent=2, sort_keys=True),
|
| 114 |
-
"Code executed.",
|
| 115 |
)
|
| 116 |
except Exception as exc:
|
| 117 |
return ("", "", "", f"Error: {exc}")
|
|
@@ -122,62 +289,132 @@ def build_repl_gradio_app(
|
|
| 122 |
except Exception as exc:
|
| 123 |
return f"Error: {exc}"
|
| 124 |
|
| 125 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
gr.Markdown(
|
| 127 |
-
"# REPL Control Panel\n
|
| 128 |
-
"
|
| 129 |
-
"leaving the Space."
|
| 130 |
)
|
|
|
|
| 131 |
with gr.Row():
|
| 132 |
with gr.Column(scale=2):
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
task_prompt = gr.Textbox(
|
| 139 |
label="Task Prompt",
|
| 140 |
placeholder="What should the agent solve?",
|
| 141 |
-
lines=
|
| 142 |
)
|
| 143 |
-
with gr.Accordion("
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 144 |
hf_token = gr.Textbox(
|
| 145 |
-
label="Hugging Face Token",
|
| 146 |
-
placeholder="
|
| 147 |
type="password",
|
| 148 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 149 |
llm_model = gr.Textbox(
|
| 150 |
label="LLM Model",
|
| 151 |
-
placeholder="
|
|
|
|
|
|
|
|
|
|
|
|
|
| 152 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 153 |
code = gr.Textbox(
|
| 154 |
label="Python Code",
|
| 155 |
placeholder="count = len(context.split())",
|
| 156 |
-
lines=
|
| 157 |
)
|
| 158 |
with gr.Row():
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 177 |
|
| 178 |
reset_btn.click(
|
| 179 |
fn=reset_repl,
|
| 180 |
-
inputs=[context, task_prompt, hf_token, llm_model],
|
| 181 |
outputs=[session_view, raw_json, state_json, status],
|
| 182 |
)
|
| 183 |
run_btn.click(
|
|
@@ -191,5 +428,21 @@ def build_repl_gradio_app(
|
|
| 191 |
outputs=[session_view, raw_json, state_json, status],
|
| 192 |
)
|
| 193 |
state_btn.click(fn=get_state_sync, outputs=[state_json])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 194 |
|
| 195 |
return blocks
|
|
|
|
| 8 |
|
| 9 |
from __future__ import annotations
|
| 10 |
|
| 11 |
+
import io
|
| 12 |
import json
|
| 13 |
+
import re
|
| 14 |
+
import time
|
| 15 |
+
from pathlib import Path
|
| 16 |
from typing import Any, Dict, List, Optional
|
| 17 |
|
| 18 |
import gradio as gr
|
| 19 |
from openenv.core.env_server.types import EnvironmentMetadata
|
| 20 |
|
| 21 |
|
| 22 |
+
_FINAL_RE = re.compile(r"FINAL\(([^\n]+?)\)\s*$", re.MULTILINE)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def _extract_final(stdout: str) -> Optional[str]:
|
| 26 |
+
"""Pull the final answer from the last `FINAL(...)` line of stdout."""
|
| 27 |
+
matches = _FINAL_RE.findall(stdout or "")
|
| 28 |
+
return matches[-1].strip() if matches else None
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
try:
|
| 32 |
+
import pypdf
|
| 33 |
+
except ImportError: # pragma: no cover - optional dep
|
| 34 |
+
pypdf = None # type: ignore[assignment]
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
_EX1_NEEDLE_CONTEXT = """ACME Robotics — Q1 internal changelog, build notes, and incident log.
|
| 38 |
+
|
| 39 |
+
Week 1. Fleet rollout reached 412 units across warehouses A and B. Latency regression on the manipulator firmware was traced back to the new servo driver; hotfix 1.4.2 shipped Wednesday. No customer impact.
|
| 40 |
+
|
| 41 |
+
Week 2. Vision team rotated the training dataset to include low-light scenes. Offline eval improved 3.1% on the occluded-grasping benchmark but regressed 0.8% on the clean-table benchmark. Product signed off on the trade-off.
|
| 42 |
+
|
| 43 |
+
Week 3. Security audit flagged credential handling in the pairing flow. The legacy MQTT broker was retired. Any robot still on firmware < 1.3.0 will refuse to pair after May 1 — document this in the migration note. The activation code is BANANA-747. Do not share externally. Re-keying ceremony for the HSM happens next sprint.
|
| 44 |
+
|
| 45 |
+
Week 4. Onboarding four new SREs. Runbook for the arm-joint recalibration was rewritten from scratch after the Tuesday incident. New runbook lives under ops/arm/joint-recal/v2.md. The Tuesday incident: a collision with a pallet rack during autonomous pickup. Root cause: stale depth-map cache. Mitigation: cache TTL dropped from 5 minutes to 20 seconds.
|
| 46 |
+
|
| 47 |
+
Week 5. Starting the unification work on the control plane. Two clusters are being collapsed into one, with staged traffic shifting. Rollback criteria are defined in the RFC. Early signal looks good: no increase in p99 pick latency. Next milestone: full cutover.
|
| 48 |
+
|
| 49 |
+
Week 6. Offsite in Porto. Platform team proposed a new interface between the planner and the low-level controller. Discussion deferred to the RFC process. Interesting prior art from the MIT paper on whole-body MPC. Follow-up reading list shared in the team channel."""
|
| 50 |
+
|
| 51 |
+
_EX1_TASK = "Find the activation code hidden somewhere in the changelog."
|
| 52 |
+
|
| 53 |
+
_EX1_CODE = """# Needle in a haystack: fan out direct LM calls over chunks in parallel.
|
| 54 |
+
chunks = [context[i:i+300] for i in range(0, len(context), 300)]
|
| 55 |
+
prompts = [
|
| 56 |
+
"In the text below, find any hyphenated identifier of the form LETTERS-DIGITS "
|
| 57 |
+
"(for example: ABC-123, FOO-42). Reply with just that identifier. "
|
| 58 |
+
f"If there is none, reply with the single word NONE.\\n\\n---\\n{c}"
|
| 59 |
+
for c in chunks
|
| 60 |
+
]
|
| 61 |
+
answers = llm_query_batched(prompts)
|
| 62 |
+
hit = next((a.strip() for a in answers if "NONE" not in a.upper()), "not found")
|
| 63 |
+
print(f"spawned {len(chunks)} children")
|
| 64 |
+
print(FINAL(hit))"""
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
_EX2_CONTEXT = ""
|
| 68 |
+
_EX2_TASK = "Show the recursive primitive: the child runs its own REPL agent."
|
| 69 |
+
_EX2_CODE = """# rlm_query spawns a full recursive REPL agent as a child.
|
| 70 |
+
# The child can reason, run code, and finalize with FINAL(...).
|
| 71 |
+
answer = rlm_query("What is 15 * 23? Work it out step by step and respond with only the final number.")
|
| 72 |
+
print(f"Child returned: {answer!r}")
|
| 73 |
+
print(FINAL(answer.strip()))"""
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
_EXAMPLES: Dict[str, tuple[str, str, str, str]] = {
|
| 77 |
+
# 4th element is `expected_answer`: RFC 004 ground truth, matched
|
| 78 |
+
# against FINAL(...) for reward scoring.
|
| 79 |
+
"Needle in a haystack (llm_query_batched)": (
|
| 80 |
+
_EX1_NEEDLE_CONTEXT,
|
| 81 |
+
_EX1_TASK,
|
| 82 |
+
_EX1_CODE,
|
| 83 |
+
"BANANA-747",
|
| 84 |
+
),
|
| 85 |
+
"Recursive child agent (rlm_query)": (
|
| 86 |
+
_EX2_CONTEXT,
|
| 87 |
+
_EX2_TASK,
|
| 88 |
+
_EX2_CODE,
|
| 89 |
+
"345",
|
| 90 |
+
),
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
_HELPERS_MD = """
|
| 95 |
+
### REPL helpers
|
| 96 |
+
|
| 97 |
+
These names are injected into the Python namespace once you Reset:
|
| 98 |
+
|
| 99 |
+
- `context` — the string you passed in the *Context* field, available as a Python variable.
|
| 100 |
+
- `llm_query(prompt, model=None)` — single direct call to the configured LLM.
|
| 101 |
+
- `llm_query_batched(prompts, model=None)` — fan out N direct LLM calls in parallel.
|
| 102 |
+
- `rlm_query(prompt)` / `rlm_query_batched(prompts)` — each child runs a full recursive REPL loop (deeper RLM pattern).
|
| 103 |
+
- `FINAL(value)` — finalize the episode with `value` as the answer.
|
| 104 |
+
- `FINAL_VAR("name")` — finalize with the value of the named variable.
|
| 105 |
+
- `answer = {"content": ..., "ready": True}` — dict-based finalization.
|
| 106 |
+
|
| 107 |
+
### Rewards (RFC 004)
|
| 108 |
+
|
| 109 |
+
Pass **Expected answer** at Reset to enable rubric-based scoring: the observation's `reward` field becomes `1.0` when `FINAL(...)` matches (exact match, case-insensitive strip) and `0.0` otherwise. Leave blank to skip scoring.
|
| 110 |
+
|
| 111 |
+
### Typical flow
|
| 112 |
+
|
| 113 |
+
1. Paste your **Hugging Face Token** — required for any example that calls `llm_query` / `rlm_query`.
|
| 114 |
+
2. Write Context + Task Prompt (or pick a demo from **💡 Load example**).
|
| 115 |
+
3. Click **🔁 Reset episode** — loads your context and wires up the helpers above.
|
| 116 |
+
4. Write Python in *Python Code* that uses those helpers and ends with `FINAL(...)`.
|
| 117 |
+
5. Click **▶ Run**. Inspect *Stdout* and *Raw JSON response* (the latter contains child-call metadata for recursive runs).
|
| 118 |
+
"""
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
_MAX_UPLOAD_BYTES = 20 * 1024 * 1024 # 20 MB — comfortably fits any arXiv PDF
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def _extract_text_from_upload(file_path: str) -> str:
|
| 125 |
+
"""Read a .txt or .pdf upload from disk and return its text content.
|
| 126 |
+
|
| 127 |
+
Runs in the Gradio server process (outside the smolagents sandbox),
|
| 128 |
+
so it can use `pypdf` and the filesystem without triggering the
|
| 129 |
+
REPL's import restrictions.
|
| 130 |
+
"""
|
| 131 |
+
path = Path(file_path)
|
| 132 |
+
size = path.stat().st_size
|
| 133 |
+
if size > _MAX_UPLOAD_BYTES:
|
| 134 |
+
return (
|
| 135 |
+
f"[File too large ({size // (1024 * 1024)} MB). "
|
| 136 |
+
f"Cap is {_MAX_UPLOAD_BYTES // (1024 * 1024)} MB to protect "
|
| 137 |
+
"the shared Space process from OOM.]"
|
| 138 |
+
)
|
| 139 |
+
suffix = path.suffix.lower()
|
| 140 |
+
if suffix == ".txt":
|
| 141 |
+
return path.read_text(encoding="utf-8", errors="replace")
|
| 142 |
+
if suffix == ".pdf":
|
| 143 |
+
if pypdf is None:
|
| 144 |
+
return "[pypdf is not installed in this deployment; cannot parse PDFs.]"
|
| 145 |
+
with path.open("rb") as fh:
|
| 146 |
+
reader = pypdf.PdfReader(io.BytesIO(fh.read()))
|
| 147 |
+
pages = []
|
| 148 |
+
for page in reader.pages:
|
| 149 |
+
try:
|
| 150 |
+
pages.append(page.extract_text() or "")
|
| 151 |
+
except Exception as exc: # pypdf can raise on malformed PDFs
|
| 152 |
+
pages.append(f"[page extraction failed: {exc}]")
|
| 153 |
+
text = "\n\n".join(pages).strip()
|
| 154 |
+
return text or "[No extractable text — PDF may be image-only.]"
|
| 155 |
+
return f"[Unsupported file type: {suffix}. Use .txt or .pdf.]"
|
| 156 |
+
|
| 157 |
+
|
| 158 |
def _code_block(title: str, content: str) -> str:
|
| 159 |
if not content:
|
| 160 |
return ""
|
| 161 |
return f"**{title}:**\n```text\n{content}\n```"
|
| 162 |
|
| 163 |
|
| 164 |
+
def _format_repl_response(
|
| 165 |
+
data: Dict[str, Any],
|
| 166 |
+
elapsed_s: Optional[float] = None,
|
| 167 |
+
) -> str:
|
| 168 |
+
"""Render REPL observations as a compact, screenshot-friendly view.
|
| 169 |
+
|
| 170 |
+
The FINAL answer is surfaced at the top as a quoted block, followed by
|
| 171 |
+
the task prompt echo, then stdout / stderr, and a one-line metadata
|
| 172 |
+
strip. Context preview / locals snapshot / available variables are
|
| 173 |
+
still in the raw JSON pane for debugging.
|
| 174 |
+
"""
|
| 175 |
observation = data.get("observation", {})
|
| 176 |
result = observation.get("result", {})
|
|
|
|
| 177 |
|
| 178 |
+
sections: List[str] = []
|
|
|
|
|
|
|
| 179 |
|
| 180 |
+
stdout = (result.get("stdout") or "").rstrip()
|
| 181 |
+
final_answer = _extract_final(stdout) if stdout else None
|
| 182 |
+
if final_answer:
|
| 183 |
+
sections.append(f"### 🎯 Answer\n\n> **{final_answer}**")
|
| 184 |
|
| 185 |
+
task_prompt = observation.get("metadata", {}).get("task_prompt")
|
| 186 |
+
if task_prompt:
|
| 187 |
+
sections.append(f"**Task:** {task_prompt}")
|
|
|
|
|
|
|
| 188 |
|
| 189 |
+
if stdout:
|
| 190 |
+
sections.append(_code_block("Stdout", stdout))
|
|
|
|
|
|
|
|
|
|
|
|
|
| 191 |
|
| 192 |
+
stderr = (result.get("stderr") or "").rstrip()
|
| 193 |
+
if stderr:
|
| 194 |
+
sections.append(_code_block("Stderr", stderr))
|
|
|
|
| 195 |
|
| 196 |
+
meta: List[str] = []
|
| 197 |
reward = data.get("reward")
|
| 198 |
+
if reward is not None:
|
| 199 |
+
meta.append(f"Reward `{reward}`")
|
| 200 |
done = data.get("done")
|
| 201 |
+
if done is not None:
|
| 202 |
+
meta.append(f"Done `{done}`")
|
| 203 |
+
iteration = observation.get("iteration")
|
| 204 |
+
max_iter = observation.get("max_iterations")
|
| 205 |
+
if iteration is not None and max_iter is not None:
|
| 206 |
+
meta.append(f"Step `{iteration}/{max_iter}`")
|
| 207 |
+
if elapsed_s is not None:
|
| 208 |
+
meta.append(f"Elapsed `{elapsed_s:.1f}s`")
|
| 209 |
+
if meta:
|
| 210 |
+
sections.append(" · ".join(meta))
|
| 211 |
|
| 212 |
+
if not sections:
|
| 213 |
+
return "_No output yet. Reset and Run._"
|
| 214 |
+
return "\n\n".join(sections)
|
| 215 |
|
| 216 |
|
| 217 |
def build_repl_gradio_app(
|
|
|
|
| 223 |
quick_start_md: str,
|
| 224 |
) -> gr.Blocks:
|
| 225 |
"""Build the REPL-specific Gradio tab."""
|
| 226 |
+
del action_fields, is_chat_env
|
| 227 |
+
|
| 228 |
+
# Skip `metadata.description`: the core default is a generic
|
| 229 |
+
# "<name> environment" placeholder that adds noise without content.
|
| 230 |
+
readme_content = (
|
| 231 |
+
metadata.readme_content if metadata and metadata.readme_content else ""
|
| 232 |
+
)
|
| 233 |
|
| 234 |
async def reset_repl(
|
| 235 |
+
context: Optional[str],
|
| 236 |
+
task_prompt: Optional[str],
|
| 237 |
+
expected_answer: Optional[str],
|
| 238 |
+
hf_token: Optional[str],
|
| 239 |
+
llm_model: Optional[str],
|
| 240 |
):
|
| 241 |
+
# Gradio can pass None for untouched password textboxes. Normalize here.
|
| 242 |
reset_kwargs: Dict[str, Any] = {}
|
| 243 |
+
if context and context.strip():
|
| 244 |
reset_kwargs["context"] = context
|
| 245 |
+
if task_prompt and task_prompt.strip():
|
| 246 |
reset_kwargs["task_prompt"] = task_prompt
|
| 247 |
+
if expected_answer and expected_answer.strip():
|
| 248 |
+
reset_kwargs["expected_answer"] = expected_answer.strip()
|
| 249 |
+
if hf_token and hf_token.strip():
|
| 250 |
+
reset_kwargs["hf_token"] = hf_token.strip()
|
| 251 |
+
if llm_model and llm_model.strip():
|
| 252 |
reset_kwargs["llm_model"] = llm_model
|
| 253 |
|
| 254 |
try:
|
| 255 |
+
start = time.monotonic()
|
| 256 |
data = await web_manager.reset_environment(reset_kwargs)
|
| 257 |
+
elapsed_s = time.monotonic() - start
|
| 258 |
state = web_manager.get_state()
|
| 259 |
return (
|
| 260 |
+
_format_repl_response(data, elapsed_s=elapsed_s),
|
| 261 |
json.dumps(data, indent=2, sort_keys=True),
|
| 262 |
json.dumps(state, indent=2, sort_keys=True),
|
| 263 |
+
f"Reset complete in {elapsed_s:.1f}s.",
|
| 264 |
)
|
| 265 |
except Exception as exc:
|
| 266 |
return ("", "", "", f"Error: {exc}")
|
| 267 |
|
| 268 |
+
async def run_code(code: Optional[str]):
|
| 269 |
+
if not code or not code.strip():
|
| 270 |
return ("", "", "", "Enter Python code to run.")
|
| 271 |
|
| 272 |
try:
|
| 273 |
+
start = time.monotonic()
|
| 274 |
data = await web_manager.step_environment({"code": code})
|
| 275 |
+
elapsed_s = time.monotonic() - start
|
| 276 |
state = web_manager.get_state()
|
| 277 |
return (
|
| 278 |
+
_format_repl_response(data, elapsed_s=elapsed_s),
|
| 279 |
json.dumps(data, indent=2, sort_keys=True),
|
| 280 |
json.dumps(state, indent=2, sort_keys=True),
|
| 281 |
+
f"Code executed in {elapsed_s:.1f}s.",
|
| 282 |
)
|
| 283 |
except Exception as exc:
|
| 284 |
return ("", "", "", f"Error: {exc}")
|
|
|
|
| 289 |
except Exception as exc:
|
| 290 |
return f"Error: {exc}"
|
| 291 |
|
| 292 |
+
def load_example(label: str):
|
| 293 |
+
if not label or label not in _EXAMPLES:
|
| 294 |
+
return (
|
| 295 |
+
gr.update(),
|
| 296 |
+
gr.update(),
|
| 297 |
+
gr.update(),
|
| 298 |
+
gr.update(),
|
| 299 |
+
gr.update(),
|
| 300 |
+
)
|
| 301 |
+
ctx, task, code, expected = _EXAMPLES[label]
|
| 302 |
+
# Auto-collapse the Context accordion once populated to keep the
|
| 303 |
+
# viewport focused on Task + Code + Output.
|
| 304 |
+
return ctx, task, code, expected, gr.update(open=False)
|
| 305 |
+
|
| 306 |
+
def load_uploaded_document(file_obj):
|
| 307 |
+
if file_obj is None:
|
| 308 |
+
return gr.update(), gr.update(), gr.update()
|
| 309 |
+
file_path = (
|
| 310 |
+
file_obj if isinstance(file_obj, str) else getattr(file_obj, "name", None)
|
| 311 |
+
)
|
| 312 |
+
if not file_path:
|
| 313 |
+
return gr.update(), "Upload error: could not read file path.", gr.update()
|
| 314 |
+
try:
|
| 315 |
+
text = _extract_text_from_upload(file_path)
|
| 316 |
+
except Exception as exc:
|
| 317 |
+
return gr.update(), f"Upload error: {exc}", gr.update()
|
| 318 |
+
return (
|
| 319 |
+
text,
|
| 320 |
+
f"Loaded {Path(file_path).name} ({len(text)} chars) into Context.",
|
| 321 |
+
gr.update(open=False),
|
| 322 |
+
)
|
| 323 |
+
|
| 324 |
+
with gr.Blocks(title=title) as blocks:
|
| 325 |
gr.Markdown(
|
| 326 |
+
"# REPL Control Panel\n"
|
| 327 |
+
"*Recursive Language Model REPL — run agentic Python with recursive LM calls.*"
|
|
|
|
| 328 |
)
|
| 329 |
+
|
| 330 |
with gr.Row():
|
| 331 |
with gr.Column(scale=2):
|
| 332 |
+
with gr.Row():
|
| 333 |
+
example_dropdown = gr.Dropdown(
|
| 334 |
+
label="💡 Load example",
|
| 335 |
+
choices=list(_EXAMPLES.keys()),
|
| 336 |
+
value=None,
|
| 337 |
+
)
|
| 338 |
+
upload = gr.File(
|
| 339 |
+
label="📎 Or drop a .txt / .pdf",
|
| 340 |
+
file_types=[".txt", ".pdf"],
|
| 341 |
+
type="filepath",
|
| 342 |
+
)
|
| 343 |
task_prompt = gr.Textbox(
|
| 344 |
label="Task Prompt",
|
| 345 |
placeholder="What should the agent solve?",
|
| 346 |
+
lines=2,
|
| 347 |
)
|
| 348 |
+
with gr.Accordion("Context", open=True) as context_accordion:
|
| 349 |
+
context = gr.Textbox(
|
| 350 |
+
label="",
|
| 351 |
+
placeholder="Problem context or source text (auto-fills from Load example / Drop)...",
|
| 352 |
+
lines=3,
|
| 353 |
+
show_label=False,
|
| 354 |
+
)
|
| 355 |
+
with gr.Accordion("Advanced options", open=False):
|
| 356 |
hf_token = gr.Textbox(
|
| 357 |
+
label="Hugging Face Token (required for llm_query / rlm_query)",
|
| 358 |
+
placeholder="hf_... — used only for this reset; not persisted",
|
| 359 |
type="password",
|
| 360 |
)
|
| 361 |
+
expected_answer = gr.Textbox(
|
| 362 |
+
label="Expected answer (activates reward scoring)",
|
| 363 |
+
placeholder="e.g. BANANA-747 — leave blank to skip",
|
| 364 |
+
)
|
| 365 |
llm_model = gr.Textbox(
|
| 366 |
label="LLM Model",
|
| 367 |
+
placeholder="e.g. Qwen/Qwen2.5-7B-Instruct",
|
| 368 |
+
info=(
|
| 369 |
+
"Blank = server default (Qwen/Qwen3.5-9B, tuned for the "
|
| 370 |
+
"`enable_thinking=False` chat template)."
|
| 371 |
+
),
|
| 372 |
)
|
| 373 |
+
with gr.Row():
|
| 374 |
+
reset_btn = gr.Button("🔁 Reset episode", variant="secondary")
|
| 375 |
+
status = gr.Textbox(
|
| 376 |
+
label="Status",
|
| 377 |
+
interactive=False,
|
| 378 |
+
lines=1,
|
| 379 |
+
scale=1,
|
| 380 |
+
)
|
| 381 |
+
with gr.Column(scale=3):
|
| 382 |
code = gr.Textbox(
|
| 383 |
label="Python Code",
|
| 384 |
placeholder="count = len(context.split())",
|
| 385 |
+
lines=8,
|
| 386 |
)
|
| 387 |
with gr.Row():
|
| 388 |
+
run_btn = gr.Button("▶ Run", variant="primary")
|
| 389 |
+
state_btn = gr.Button("📋 Get state", variant="secondary")
|
| 390 |
+
session_view = gr.Markdown(value="_Reset and Run to see output._")
|
| 391 |
+
with gr.Accordion("Raw JSON response", open=False):
|
| 392 |
+
raw_json = gr.Code(
|
| 393 |
+
label="",
|
| 394 |
+
language="json",
|
| 395 |
+
interactive=False,
|
| 396 |
+
)
|
| 397 |
+
with gr.Accordion("Session state", open=False):
|
| 398 |
+
state_json = gr.Code(
|
| 399 |
+
label="",
|
| 400 |
+
language="json",
|
| 401 |
+
interactive=False,
|
| 402 |
+
)
|
| 403 |
+
|
| 404 |
+
with gr.Accordion("REPL helpers reference & typical flow", open=False):
|
| 405 |
+
gr.Markdown(_HELPERS_MD)
|
| 406 |
+
|
| 407 |
+
if quick_start_md:
|
| 408 |
+
with gr.Accordion("API quick start (Python client snippet)", open=False):
|
| 409 |
+
gr.Markdown(quick_start_md)
|
| 410 |
+
|
| 411 |
+
if readme_content:
|
| 412 |
+
with gr.Accordion("Full README", open=False):
|
| 413 |
+
gr.Markdown(readme_content)
|
| 414 |
|
| 415 |
reset_btn.click(
|
| 416 |
fn=reset_repl,
|
| 417 |
+
inputs=[context, task_prompt, expected_answer, hf_token, llm_model],
|
| 418 |
outputs=[session_view, raw_json, state_json, status],
|
| 419 |
)
|
| 420 |
run_btn.click(
|
|
|
|
| 428 |
outputs=[session_view, raw_json, state_json, status],
|
| 429 |
)
|
| 430 |
state_btn.click(fn=get_state_sync, outputs=[state_json])
|
| 431 |
+
example_dropdown.change(
|
| 432 |
+
fn=load_example,
|
| 433 |
+
inputs=[example_dropdown],
|
| 434 |
+
outputs=[
|
| 435 |
+
context,
|
| 436 |
+
task_prompt,
|
| 437 |
+
code,
|
| 438 |
+
expected_answer,
|
| 439 |
+
context_accordion,
|
| 440 |
+
],
|
| 441 |
+
)
|
| 442 |
+
upload.change(
|
| 443 |
+
fn=load_uploaded_document,
|
| 444 |
+
inputs=[upload],
|
| 445 |
+
outputs=[context, status, context_accordion],
|
| 446 |
+
)
|
| 447 |
|
| 448 |
return blocks
|
envs/repl_env/server/repl_environment.py
CHANGED
|
@@ -33,7 +33,12 @@ try:
|
|
| 33 |
from ..models import CodeBlockResult, REPLAction, REPLObservation, REPLState
|
| 34 |
except ImportError:
|
| 35 |
try:
|
| 36 |
-
from repl_env.models import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
except ImportError:
|
| 38 |
from models import CodeBlockResult, REPLAction, REPLObservation, REPLState
|
| 39 |
|
|
@@ -47,10 +52,11 @@ except ImportError:
|
|
| 47 |
from repl_env.rubrics import REPLRubric
|
| 48 |
from .python_executor import PythonExecutor
|
| 49 |
except ImportError:
|
| 50 |
-
from .python_executor import PythonExecutor
|
| 51 |
from recursive_controller import create_server_recursive_controller
|
| 52 |
from rubrics import REPLRubric
|
| 53 |
|
|
|
|
|
|
|
| 54 |
|
| 55 |
class REPLEnvironment(Environment):
|
| 56 |
"""
|
|
@@ -138,6 +144,12 @@ class REPLEnvironment(Environment):
|
|
| 138 |
self._executor: Optional[PythonExecutor] = None
|
| 139 |
self._runtime_controller = None
|
| 140 |
self._runtime_controller_chat_fn: Optional[Callable[..., str]] = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 141 |
|
| 142 |
@staticmethod
|
| 143 |
def _build_hf_chat_fn(
|
|
@@ -149,7 +161,7 @@ class REPLEnvironment(Environment):
|
|
| 149 |
except ImportError:
|
| 150 |
raise RuntimeError("huggingface_hub is required for HF-backed recursion")
|
| 151 |
|
| 152 |
-
default_model =
|
| 153 |
client = InferenceClient(model=default_model, token=hf_token, timeout=300)
|
| 154 |
|
| 155 |
def chat_fn(messages: list[dict[str, str]], model: str | None = None) -> str:
|
|
@@ -255,13 +267,23 @@ class REPLEnvironment(Environment):
|
|
| 255 |
self.rlm_max_depth = runtime_rlm_max_depth
|
| 256 |
self.rlm_max_iterations = runtime_rlm_max_iterations
|
| 257 |
|
| 258 |
-
# Create or rebuild LLM functions when needed.
|
| 259 |
# Token resolution: explicit hf_token > HF_TOKEN env var > cached HF login.
|
| 260 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 261 |
effective_token = (
|
| 262 |
hf_token if hf_token is not None else os.environ.get("HF_TOKEN")
|
| 263 |
)
|
|
|
|
|
|
|
| 264 |
self._create_llm_functions(effective_token, llm_model)
|
|
|
|
| 265 |
elif depth_changed and self._runtime_controller is not None:
|
| 266 |
# Rebuild controller with new depth/iteration config but reuse
|
| 267 |
# the existing chat_fn — don't require re-providing credentials.
|
|
@@ -514,10 +536,18 @@ class REPLEnvironment(Environment):
|
|
| 514 |
Returns:
|
| 515 |
Final answer string or None if not found
|
| 516 |
"""
|
| 517 |
-
# Pattern 1: RLM-style FINAL(answer)
|
| 518 |
-
|
| 519 |
-
|
| 520 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 521 |
|
| 522 |
# Pattern 2: RLM-style FINAL_VAR(variable_name)
|
| 523 |
final_var_match = re.search(r"FINAL_VAR\((\w+)\)", stdout)
|
|
|
|
| 33 |
from ..models import CodeBlockResult, REPLAction, REPLObservation, REPLState
|
| 34 |
except ImportError:
|
| 35 |
try:
|
| 36 |
+
from repl_env.models import (
|
| 37 |
+
CodeBlockResult,
|
| 38 |
+
REPLAction,
|
| 39 |
+
REPLObservation,
|
| 40 |
+
REPLState,
|
| 41 |
+
)
|
| 42 |
except ImportError:
|
| 43 |
from models import CodeBlockResult, REPLAction, REPLObservation, REPLState
|
| 44 |
|
|
|
|
| 52 |
from repl_env.rubrics import REPLRubric
|
| 53 |
from .python_executor import PythonExecutor
|
| 54 |
except ImportError:
|
|
|
|
| 55 |
from recursive_controller import create_server_recursive_controller
|
| 56 |
from rubrics import REPLRubric
|
| 57 |
|
| 58 |
+
from .python_executor import PythonExecutor
|
| 59 |
+
|
| 60 |
|
| 61 |
class REPLEnvironment(Environment):
|
| 62 |
"""
|
|
|
|
| 144 |
self._executor: Optional[PythonExecutor] = None
|
| 145 |
self._runtime_controller = None
|
| 146 |
self._runtime_controller_chat_fn: Optional[Callable[..., str]] = None
|
| 147 |
+
self._current_llm_model: Optional[str] = None
|
| 148 |
+
|
| 149 |
+
@staticmethod
|
| 150 |
+
def _resolve_model(llm_model: Optional[str]) -> str:
|
| 151 |
+
"""Resolve the effective model name: explicit arg > env var > hard default."""
|
| 152 |
+
return llm_model or os.environ.get("LLM_MODEL", "Qwen/Qwen3.5-9B")
|
| 153 |
|
| 154 |
@staticmethod
|
| 155 |
def _build_hf_chat_fn(
|
|
|
|
| 161 |
except ImportError:
|
| 162 |
raise RuntimeError("huggingface_hub is required for HF-backed recursion")
|
| 163 |
|
| 164 |
+
default_model = REPLEnvironment._resolve_model(llm_model)
|
| 165 |
client = InferenceClient(model=default_model, token=hf_token, timeout=300)
|
| 166 |
|
| 167 |
def chat_fn(messages: list[dict[str, str]], model: str | None = None) -> str:
|
|
|
|
| 267 |
self.rlm_max_depth = runtime_rlm_max_depth
|
| 268 |
self.rlm_max_iterations = runtime_rlm_max_iterations
|
| 269 |
|
|
|
|
| 270 |
# Token resolution: explicit hf_token > HF_TOKEN env var > cached HF login.
|
| 271 |
+
# Compare resolved model names so reset(llm_model=<default>) and
|
| 272 |
+
# reset() are treated as equal and don't trigger a redundant rebuild.
|
| 273 |
+
resolved_model = self._resolve_model(llm_model)
|
| 274 |
+
has_runtime_llm = self._runtime_controller is not None
|
| 275 |
+
model_changed = (
|
| 276 |
+
has_runtime_llm and resolved_model != self._current_llm_model
|
| 277 |
+
)
|
| 278 |
+
token_provided = hf_token is not None
|
| 279 |
+
if not self.llm_query_fn or model_changed or token_provided:
|
| 280 |
effective_token = (
|
| 281 |
hf_token if hf_token is not None else os.environ.get("HF_TOKEN")
|
| 282 |
)
|
| 283 |
+
# Pass raw llm_model; _build_hf_chat_fn resolves it via
|
| 284 |
+
# _resolve_model so the client matches the cache key below.
|
| 285 |
self._create_llm_functions(effective_token, llm_model)
|
| 286 |
+
self._current_llm_model = resolved_model
|
| 287 |
elif depth_changed and self._runtime_controller is not None:
|
| 288 |
# Rebuild controller with new depth/iteration config but reuse
|
| 289 |
# the existing chat_fn — don't require re-providing credentials.
|
|
|
|
| 536 |
Returns:
|
| 537 |
Final answer string or None if not found
|
| 538 |
"""
|
| 539 |
+
# Pattern 1: RLM-style FINAL(answer). Paren-counting handles nested
|
| 540 |
+
# parens (e.g. FINAL(f(x))) and multi-line values without regex flag trade-offs.
|
| 541 |
+
idx = stdout.find("FINAL(")
|
| 542 |
+
if idx != -1:
|
| 543 |
+
depth, start = 0, idx + len("FINAL")
|
| 544 |
+
for i, ch in enumerate(stdout[idx + len("FINAL"):], start=idx + len("FINAL")):
|
| 545 |
+
if ch == "(":
|
| 546 |
+
depth += 1
|
| 547 |
+
elif ch == ")":
|
| 548 |
+
depth -= 1
|
| 549 |
+
if depth == 0:
|
| 550 |
+
return stdout[start + 1 : i].strip()
|
| 551 |
|
| 552 |
# Pattern 2: RLM-style FINAL_VAR(variable_name)
|
| 553 |
final_var_match = re.search(r"FINAL_VAR\((\w+)\)", stdout)
|
pyproject.toml
CHANGED
|
@@ -15,7 +15,7 @@ description = "Recursive Language Model REPL Environment for OpenEnv"
|
|
| 15 |
requires-python = ">=3.10"
|
| 16 |
dependencies = [
|
| 17 |
# Core OpenEnv dependencies (required for server functionality)
|
| 18 |
-
"openenv
|
| 19 |
"fastapi>=0.115.0",
|
| 20 |
"pydantic>=2.0.0",
|
| 21 |
"uvicorn>=0.24.0",
|
|
@@ -26,11 +26,13 @@ dependencies = [
|
|
| 26 |
"huggingface_hub>=0.20.0",
|
| 27 |
# REPL custom Gradio tab on /web
|
| 28 |
"gradio>=4.0.0",
|
|
|
|
|
|
|
| 29 |
]
|
| 30 |
|
| 31 |
[project.optional-dependencies]
|
| 32 |
dev = [
|
| 33 |
-
"pytest>=
|
| 34 |
"pytest-cov>=4.0.0",
|
| 35 |
]
|
| 36 |
|
|
|
|
| 15 |
requires-python = ">=3.10"
|
| 16 |
dependencies = [
|
| 17 |
# Core OpenEnv dependencies (required for server functionality)
|
| 18 |
+
"openenv @ git+https://github.com/huggingface/OpenEnv.git@v0.10.0",
|
| 19 |
"fastapi>=0.115.0",
|
| 20 |
"pydantic>=2.0.0",
|
| 21 |
"uvicorn>=0.24.0",
|
|
|
|
| 26 |
"huggingface_hub>=0.20.0",
|
| 27 |
# REPL custom Gradio tab on /web
|
| 28 |
"gradio>=4.0.0",
|
| 29 |
+
# Document upload support in the Gradio tab (server-side only, not in the sandbox)
|
| 30 |
+
"pypdf>=6.16.1",
|
| 31 |
]
|
| 32 |
|
| 33 |
[project.optional-dependencies]
|
| 34 |
dev = [
|
| 35 |
+
"pytest>=9.0.3",
|
| 36 |
"pytest-cov>=4.0.0",
|
| 37 |
]
|
| 38 |
|
runner.py
CHANGED
|
@@ -243,10 +243,18 @@ class LocalRLMRunner:
|
|
| 243 |
]
|
| 244 |
try:
|
| 245 |
response = self._chat(final_prompt, model)
|
| 246 |
-
# Try to extract FINAL(...) from the response
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 250 |
# If no FINAL pattern, return the raw response as best-effort
|
| 251 |
return response.strip() if response.strip() else None
|
| 252 |
except Exception:
|
|
|
|
| 243 |
]
|
| 244 |
try:
|
| 245 |
response = self._chat(final_prompt, model)
|
| 246 |
+
# Try to extract FINAL(...) from the response. Paren-counting handles
|
| 247 |
+
# nested parens and mid-sentence FINAL without regex flag trade-offs.
|
| 248 |
+
idx = response.find("FINAL(")
|
| 249 |
+
if idx != -1:
|
| 250 |
+
depth, start = 0, idx + len("FINAL")
|
| 251 |
+
for i, ch in enumerate(response[idx + len("FINAL"):], start=idx + len("FINAL")):
|
| 252 |
+
if ch == "(":
|
| 253 |
+
depth += 1
|
| 254 |
+
elif ch == ")":
|
| 255 |
+
depth -= 1
|
| 256 |
+
if depth == 0:
|
| 257 |
+
return response[start + 1 : i].strip()
|
| 258 |
# If no FINAL pattern, return the raw response as best-effort
|
| 259 |
return response.strip() if response.strip() else None
|
| 260 |
except Exception:
|
server/Dockerfile
CHANGED
|
@@ -10,7 +10,7 @@
|
|
| 10 |
# - Standalone environments (with openenv from pip)
|
| 11 |
# The build script (openenv build) handles context detection and sets appropriate build args.
|
| 12 |
|
| 13 |
-
ARG BASE_IMAGE=ghcr.io/
|
| 14 |
FROM ${BASE_IMAGE} AS builder
|
| 15 |
|
| 16 |
WORKDIR /app
|
|
@@ -22,8 +22,8 @@ ARG ENV_NAME=repl_env
|
|
| 22 |
# Copy environment code (always at root of build context)
|
| 23 |
COPY . /app/env
|
| 24 |
|
| 25 |
-
# For in-repo builds, openenv
|
| 26 |
-
# For standalone builds, openenv
|
| 27 |
WORKDIR /app/env
|
| 28 |
|
| 29 |
# Ensure uv is available (for local builds where base image lacks it)
|
|
|
|
| 10 |
# - Standalone environments (with openenv from pip)
|
| 11 |
# The build script (openenv build) handles context detection and sets appropriate build args.
|
| 12 |
|
| 13 |
+
ARG BASE_IMAGE=ghcr.io/huggingface/openenv-base:latest
|
| 14 |
FROM ${BASE_IMAGE} AS builder
|
| 15 |
|
| 16 |
WORKDIR /app
|
|
|
|
| 22 |
# Copy environment code (always at root of build context)
|
| 23 |
COPY . /app/env
|
| 24 |
|
| 25 |
+
# For in-repo builds, openenv is already in the pyproject.toml dependencies
|
| 26 |
+
# For standalone builds, openenv will be installed from pip via pyproject.toml
|
| 27 |
WORKDIR /app/env
|
| 28 |
|
| 29 |
# Ensure uv is available (for local builds where base image lacks it)
|
server/app.py
CHANGED
|
@@ -33,6 +33,7 @@ Usage:
|
|
| 33 |
Environment Variables:
|
| 34 |
HF_TOKEN: Fallback HuggingFace API token (client token takes priority)
|
| 35 |
LLM_MODEL: Model to use for llm_query/llm_query_batched (default: Qwen/Qwen3.5-9B)
|
|
|
|
| 36 |
"""
|
| 37 |
|
| 38 |
import inspect
|
|
@@ -42,11 +43,11 @@ import os
|
|
| 42 |
try:
|
| 43 |
from openenv.core.env_server.http_server import create_app
|
| 44 |
|
| 45 |
-
from ..models import REPLAction, REPLObservation
|
| 46 |
from .gradio_ui import build_repl_gradio_app
|
| 47 |
from .repl_environment import REPLEnvironment
|
| 48 |
except ImportError:
|
| 49 |
-
from models import REPLAction, REPLObservation
|
| 50 |
from openenv.core.env_server.http_server import create_app
|
| 51 |
from server.gradio_ui import build_repl_gradio_app
|
| 52 |
from server.repl_environment import REPLEnvironment
|
|
@@ -60,6 +61,7 @@ REPL_MAX_OUTPUT_LENGTH = int(os.environ.get("REPL_MAX_OUTPUT_LENGTH", "8192"))
|
|
| 60 |
REPL_CONTEXT_PREVIEW_LENGTH = int(os.environ.get("REPL_CONTEXT_PREVIEW_LENGTH", "500"))
|
| 61 |
REPL_RLM_MAX_DEPTH = int(os.environ.get("REPL_RLM_MAX_DEPTH", "2"))
|
| 62 |
REPL_RLM_MAX_ITERATIONS = int(os.environ.get("REPL_RLM_MAX_ITERATIONS", "30"))
|
|
|
|
| 63 |
# ==========================================
|
| 64 |
|
| 65 |
_logger = logging.getLogger(__name__)
|
|
@@ -94,17 +96,34 @@ def create_repl_environment() -> REPLEnvironment:
|
|
| 94 |
# Create the app with web interface and README integration.
|
| 95 |
_sig = inspect.signature(create_app)
|
| 96 |
if "gradio_builder" in _sig.parameters:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
app = create_app(
|
| 98 |
create_repl_environment,
|
| 99 |
REPLAction,
|
| 100 |
REPLObservation,
|
| 101 |
-
|
| 102 |
-
max_concurrent_envs=8,
|
| 103 |
-
gradio_builder=build_repl_gradio_app,
|
| 104 |
)
|
| 105 |
else:
|
| 106 |
_logger.warning(
|
| 107 |
-
"Installed openenv
|
| 108 |
"custom REPL Gradio tab will not be available."
|
| 109 |
)
|
| 110 |
app = create_app(
|
|
@@ -112,7 +131,7 @@ else:
|
|
| 112 |
REPLAction,
|
| 113 |
REPLObservation,
|
| 114 |
env_name="repl_env",
|
| 115 |
-
max_concurrent_envs=
|
| 116 |
)
|
| 117 |
|
| 118 |
|
|
|
|
| 33 |
Environment Variables:
|
| 34 |
HF_TOKEN: Fallback HuggingFace API token (client token takes priority)
|
| 35 |
LLM_MODEL: Model to use for llm_query/llm_query_batched (default: Qwen/Qwen3.5-9B)
|
| 36 |
+
MAX_CONCURRENT_ENVS: Maximum concurrent WebSocket sessions (default: 8)
|
| 37 |
"""
|
| 38 |
|
| 39 |
import inspect
|
|
|
|
| 43 |
try:
|
| 44 |
from openenv.core.env_server.http_server import create_app
|
| 45 |
|
| 46 |
+
from ..models import REPLAction, REPLObservation, REPLState
|
| 47 |
from .gradio_ui import build_repl_gradio_app
|
| 48 |
from .repl_environment import REPLEnvironment
|
| 49 |
except ImportError:
|
| 50 |
+
from models import REPLAction, REPLObservation, REPLState
|
| 51 |
from openenv.core.env_server.http_server import create_app
|
| 52 |
from server.gradio_ui import build_repl_gradio_app
|
| 53 |
from server.repl_environment import REPLEnvironment
|
|
|
|
| 61 |
REPL_CONTEXT_PREVIEW_LENGTH = int(os.environ.get("REPL_CONTEXT_PREVIEW_LENGTH", "500"))
|
| 62 |
REPL_RLM_MAX_DEPTH = int(os.environ.get("REPL_RLM_MAX_DEPTH", "2"))
|
| 63 |
REPL_RLM_MAX_ITERATIONS = int(os.environ.get("REPL_RLM_MAX_ITERATIONS", "30"))
|
| 64 |
+
MAX_CONCURRENT_ENVS = int(os.environ.get("MAX_CONCURRENT_ENVS", "8"))
|
| 65 |
# ==========================================
|
| 66 |
|
| 67 |
_logger = logging.getLogger(__name__)
|
|
|
|
| 96 |
# Create the app with web interface and README integration.
|
| 97 |
_sig = inspect.signature(create_app)
|
| 98 |
if "gradio_builder" in _sig.parameters:
|
| 99 |
+
# Each kwarg is guarded by inspect.signature so older openenv
|
| 100 |
+
# releases that predate the param still boot this env.
|
| 101 |
+
create_app_kwargs: dict = {
|
| 102 |
+
"env_name": "repl_env",
|
| 103 |
+
"max_concurrent_envs": MAX_CONCURRENT_ENVS,
|
| 104 |
+
"gradio_builder": build_repl_gradio_app,
|
| 105 |
+
}
|
| 106 |
+
if "custom_tab_name" in _sig.parameters:
|
| 107 |
+
create_app_kwargs["custom_tab_name"] = "REPL"
|
| 108 |
+
if "custom_tab_primary" in _sig.parameters:
|
| 109 |
+
create_app_kwargs["custom_tab_primary"] = True
|
| 110 |
+
if "show_default_tab" in _sig.parameters:
|
| 111 |
+
create_app_kwargs["show_default_tab"] = False
|
| 112 |
+
if "title_override" in _sig.parameters:
|
| 113 |
+
create_app_kwargs["title_override"] = (
|
| 114 |
+
"OpenEnv REPL — Recursive Language Model playground"
|
| 115 |
+
)
|
| 116 |
+
if "state_cls" in _sig.parameters:
|
| 117 |
+
create_app_kwargs["state_cls"] = REPLState
|
| 118 |
app = create_app(
|
| 119 |
create_repl_environment,
|
| 120 |
REPLAction,
|
| 121 |
REPLObservation,
|
| 122 |
+
**create_app_kwargs,
|
|
|
|
|
|
|
| 123 |
)
|
| 124 |
else:
|
| 125 |
_logger.warning(
|
| 126 |
+
"Installed openenv does not support gradio_builder; "
|
| 127 |
"custom REPL Gradio tab will not be available."
|
| 128 |
)
|
| 129 |
app = create_app(
|
|
|
|
| 131 |
REPLAction,
|
| 132 |
REPLObservation,
|
| 133 |
env_name="repl_env",
|
| 134 |
+
max_concurrent_envs=MAX_CONCURRENT_ENVS,
|
| 135 |
)
|
| 136 |
|
| 137 |
|
server/gradio_ui.py
CHANGED
|
@@ -8,57 +8,210 @@
|
|
| 8 |
|
| 9 |
from __future__ import annotations
|
| 10 |
|
|
|
|
| 11 |
import json
|
|
|
|
|
|
|
|
|
|
| 12 |
from typing import Any, Dict, List, Optional
|
| 13 |
|
| 14 |
import gradio as gr
|
| 15 |
from openenv.core.env_server.types import EnvironmentMetadata
|
| 16 |
|
| 17 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
def _code_block(title: str, content: str) -> str:
|
| 19 |
if not content:
|
| 20 |
return ""
|
| 21 |
return f"**{title}:**\n```text\n{content}\n```"
|
| 22 |
|
| 23 |
|
| 24 |
-
def _format_repl_response(
|
| 25 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
observation = data.get("observation", {})
|
| 27 |
result = observation.get("result", {})
|
| 28 |
-
sections: List[str] = ["# REPL Session"]
|
| 29 |
|
| 30 |
-
|
| 31 |
-
if context_preview:
|
| 32 |
-
sections.append(_code_block("Context Preview", context_preview))
|
| 33 |
|
| 34 |
-
|
| 35 |
-
if
|
| 36 |
-
|
|
|
|
| 37 |
|
| 38 |
-
|
| 39 |
-
if
|
| 40 |
-
sections.append(
|
| 41 |
-
"**Available Variables:** " + ", ".join(f"`{name}`" for name in available_variables)
|
| 42 |
-
)
|
| 43 |
|
| 44 |
-
if
|
| 45 |
-
sections.append(
|
| 46 |
-
"**Locals Snapshot:**\n```json\n"
|
| 47 |
-
+ json.dumps(result["locals_snapshot"], indent=2, sort_keys=True)
|
| 48 |
-
+ "\n```"
|
| 49 |
-
)
|
| 50 |
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
sections.append(_code_block("Stderr", stderr))
|
| 55 |
|
|
|
|
| 56 |
reward = data.get("reward")
|
|
|
|
|
|
|
| 57 |
done = data.get("done")
|
| 58 |
-
|
| 59 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
|
| 61 |
-
|
|
|
|
|
|
|
| 62 |
|
| 63 |
|
| 64 |
def build_repl_gradio_app(
|
|
@@ -70,48 +223,62 @@ def build_repl_gradio_app(
|
|
| 70 |
quick_start_md: str,
|
| 71 |
) -> gr.Blocks:
|
| 72 |
"""Build the REPL-specific Gradio tab."""
|
| 73 |
-
del action_fields, is_chat_env
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
|
| 75 |
async def reset_repl(
|
| 76 |
-
context: str,
|
| 77 |
-
task_prompt: str,
|
| 78 |
-
|
| 79 |
-
|
|
|
|
| 80 |
):
|
|
|
|
| 81 |
reset_kwargs: Dict[str, Any] = {}
|
| 82 |
-
if context.strip():
|
| 83 |
reset_kwargs["context"] = context
|
| 84 |
-
if task_prompt.strip():
|
| 85 |
reset_kwargs["task_prompt"] = task_prompt
|
| 86 |
-
if
|
| 87 |
-
reset_kwargs["
|
| 88 |
-
if
|
|
|
|
|
|
|
| 89 |
reset_kwargs["llm_model"] = llm_model
|
| 90 |
|
| 91 |
try:
|
|
|
|
| 92 |
data = await web_manager.reset_environment(reset_kwargs)
|
|
|
|
| 93 |
state = web_manager.get_state()
|
| 94 |
return (
|
| 95 |
-
_format_repl_response(data),
|
| 96 |
json.dumps(data, indent=2, sort_keys=True),
|
| 97 |
json.dumps(state, indent=2, sort_keys=True),
|
| 98 |
-
"
|
| 99 |
)
|
| 100 |
except Exception as exc:
|
| 101 |
return ("", "", "", f"Error: {exc}")
|
| 102 |
|
| 103 |
-
async def run_code(code: str):
|
| 104 |
-
if not code.strip():
|
| 105 |
return ("", "", "", "Enter Python code to run.")
|
| 106 |
|
| 107 |
try:
|
|
|
|
| 108 |
data = await web_manager.step_environment({"code": code})
|
|
|
|
| 109 |
state = web_manager.get_state()
|
| 110 |
return (
|
| 111 |
-
_format_repl_response(data),
|
| 112 |
json.dumps(data, indent=2, sort_keys=True),
|
| 113 |
json.dumps(state, indent=2, sort_keys=True),
|
| 114 |
-
"Code executed.",
|
| 115 |
)
|
| 116 |
except Exception as exc:
|
| 117 |
return ("", "", "", f"Error: {exc}")
|
|
@@ -122,62 +289,132 @@ def build_repl_gradio_app(
|
|
| 122 |
except Exception as exc:
|
| 123 |
return f"Error: {exc}"
|
| 124 |
|
| 125 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
gr.Markdown(
|
| 127 |
-
"# REPL Control Panel\n
|
| 128 |
-
"
|
| 129 |
-
"leaving the Space."
|
| 130 |
)
|
|
|
|
| 131 |
with gr.Row():
|
| 132 |
with gr.Column(scale=2):
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
task_prompt = gr.Textbox(
|
| 139 |
label="Task Prompt",
|
| 140 |
placeholder="What should the agent solve?",
|
| 141 |
-
lines=
|
| 142 |
)
|
| 143 |
-
with gr.Accordion("
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 144 |
hf_token = gr.Textbox(
|
| 145 |
-
label="Hugging Face Token",
|
| 146 |
-
placeholder="
|
| 147 |
type="password",
|
| 148 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 149 |
llm_model = gr.Textbox(
|
| 150 |
label="LLM Model",
|
| 151 |
-
placeholder="
|
|
|
|
|
|
|
|
|
|
|
|
|
| 152 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 153 |
code = gr.Textbox(
|
| 154 |
label="Python Code",
|
| 155 |
placeholder="count = len(context.split())",
|
| 156 |
-
lines=
|
| 157 |
)
|
| 158 |
with gr.Row():
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 177 |
|
| 178 |
reset_btn.click(
|
| 179 |
fn=reset_repl,
|
| 180 |
-
inputs=[context, task_prompt, hf_token, llm_model],
|
| 181 |
outputs=[session_view, raw_json, state_json, status],
|
| 182 |
)
|
| 183 |
run_btn.click(
|
|
@@ -191,5 +428,21 @@ def build_repl_gradio_app(
|
|
| 191 |
outputs=[session_view, raw_json, state_json, status],
|
| 192 |
)
|
| 193 |
state_btn.click(fn=get_state_sync, outputs=[state_json])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 194 |
|
| 195 |
return blocks
|
|
|
|
| 8 |
|
| 9 |
from __future__ import annotations
|
| 10 |
|
| 11 |
+
import io
|
| 12 |
import json
|
| 13 |
+
import re
|
| 14 |
+
import time
|
| 15 |
+
from pathlib import Path
|
| 16 |
from typing import Any, Dict, List, Optional
|
| 17 |
|
| 18 |
import gradio as gr
|
| 19 |
from openenv.core.env_server.types import EnvironmentMetadata
|
| 20 |
|
| 21 |
|
| 22 |
+
_FINAL_RE = re.compile(r"FINAL\(([^\n]+?)\)\s*$", re.MULTILINE)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def _extract_final(stdout: str) -> Optional[str]:
|
| 26 |
+
"""Pull the final answer from the last `FINAL(...)` line of stdout."""
|
| 27 |
+
matches = _FINAL_RE.findall(stdout or "")
|
| 28 |
+
return matches[-1].strip() if matches else None
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
try:
|
| 32 |
+
import pypdf
|
| 33 |
+
except ImportError: # pragma: no cover - optional dep
|
| 34 |
+
pypdf = None # type: ignore[assignment]
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
_EX1_NEEDLE_CONTEXT = """ACME Robotics — Q1 internal changelog, build notes, and incident log.
|
| 38 |
+
|
| 39 |
+
Week 1. Fleet rollout reached 412 units across warehouses A and B. Latency regression on the manipulator firmware was traced back to the new servo driver; hotfix 1.4.2 shipped Wednesday. No customer impact.
|
| 40 |
+
|
| 41 |
+
Week 2. Vision team rotated the training dataset to include low-light scenes. Offline eval improved 3.1% on the occluded-grasping benchmark but regressed 0.8% on the clean-table benchmark. Product signed off on the trade-off.
|
| 42 |
+
|
| 43 |
+
Week 3. Security audit flagged credential handling in the pairing flow. The legacy MQTT broker was retired. Any robot still on firmware < 1.3.0 will refuse to pair after May 1 — document this in the migration note. The activation code is BANANA-747. Do not share externally. Re-keying ceremony for the HSM happens next sprint.
|
| 44 |
+
|
| 45 |
+
Week 4. Onboarding four new SREs. Runbook for the arm-joint recalibration was rewritten from scratch after the Tuesday incident. New runbook lives under ops/arm/joint-recal/v2.md. The Tuesday incident: a collision with a pallet rack during autonomous pickup. Root cause: stale depth-map cache. Mitigation: cache TTL dropped from 5 minutes to 20 seconds.
|
| 46 |
+
|
| 47 |
+
Week 5. Starting the unification work on the control plane. Two clusters are being collapsed into one, with staged traffic shifting. Rollback criteria are defined in the RFC. Early signal looks good: no increase in p99 pick latency. Next milestone: full cutover.
|
| 48 |
+
|
| 49 |
+
Week 6. Offsite in Porto. Platform team proposed a new interface between the planner and the low-level controller. Discussion deferred to the RFC process. Interesting prior art from the MIT paper on whole-body MPC. Follow-up reading list shared in the team channel."""
|
| 50 |
+
|
| 51 |
+
_EX1_TASK = "Find the activation code hidden somewhere in the changelog."
|
| 52 |
+
|
| 53 |
+
_EX1_CODE = """# Needle in a haystack: fan out direct LM calls over chunks in parallel.
|
| 54 |
+
chunks = [context[i:i+300] for i in range(0, len(context), 300)]
|
| 55 |
+
prompts = [
|
| 56 |
+
"In the text below, find any hyphenated identifier of the form LETTERS-DIGITS "
|
| 57 |
+
"(for example: ABC-123, FOO-42). Reply with just that identifier. "
|
| 58 |
+
f"If there is none, reply with the single word NONE.\\n\\n---\\n{c}"
|
| 59 |
+
for c in chunks
|
| 60 |
+
]
|
| 61 |
+
answers = llm_query_batched(prompts)
|
| 62 |
+
hit = next((a.strip() for a in answers if "NONE" not in a.upper()), "not found")
|
| 63 |
+
print(f"spawned {len(chunks)} children")
|
| 64 |
+
print(FINAL(hit))"""
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
_EX2_CONTEXT = ""
|
| 68 |
+
_EX2_TASK = "Show the recursive primitive: the child runs its own REPL agent."
|
| 69 |
+
_EX2_CODE = """# rlm_query spawns a full recursive REPL agent as a child.
|
| 70 |
+
# The child can reason, run code, and finalize with FINAL(...).
|
| 71 |
+
answer = rlm_query("What is 15 * 23? Work it out step by step and respond with only the final number.")
|
| 72 |
+
print(f"Child returned: {answer!r}")
|
| 73 |
+
print(FINAL(answer.strip()))"""
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
_EXAMPLES: Dict[str, tuple[str, str, str, str]] = {
|
| 77 |
+
# 4th element is `expected_answer`: RFC 004 ground truth, matched
|
| 78 |
+
# against FINAL(...) for reward scoring.
|
| 79 |
+
"Needle in a haystack (llm_query_batched)": (
|
| 80 |
+
_EX1_NEEDLE_CONTEXT,
|
| 81 |
+
_EX1_TASK,
|
| 82 |
+
_EX1_CODE,
|
| 83 |
+
"BANANA-747",
|
| 84 |
+
),
|
| 85 |
+
"Recursive child agent (rlm_query)": (
|
| 86 |
+
_EX2_CONTEXT,
|
| 87 |
+
_EX2_TASK,
|
| 88 |
+
_EX2_CODE,
|
| 89 |
+
"345",
|
| 90 |
+
),
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
_HELPERS_MD = """
|
| 95 |
+
### REPL helpers
|
| 96 |
+
|
| 97 |
+
These names are injected into the Python namespace once you Reset:
|
| 98 |
+
|
| 99 |
+
- `context` — the string you passed in the *Context* field, available as a Python variable.
|
| 100 |
+
- `llm_query(prompt, model=None)` — single direct call to the configured LLM.
|
| 101 |
+
- `llm_query_batched(prompts, model=None)` — fan out N direct LLM calls in parallel.
|
| 102 |
+
- `rlm_query(prompt)` / `rlm_query_batched(prompts)` — each child runs a full recursive REPL loop (deeper RLM pattern).
|
| 103 |
+
- `FINAL(value)` — finalize the episode with `value` as the answer.
|
| 104 |
+
- `FINAL_VAR("name")` — finalize with the value of the named variable.
|
| 105 |
+
- `answer = {"content": ..., "ready": True}` — dict-based finalization.
|
| 106 |
+
|
| 107 |
+
### Rewards (RFC 004)
|
| 108 |
+
|
| 109 |
+
Pass **Expected answer** at Reset to enable rubric-based scoring: the observation's `reward` field becomes `1.0` when `FINAL(...)` matches (exact match, case-insensitive strip) and `0.0` otherwise. Leave blank to skip scoring.
|
| 110 |
+
|
| 111 |
+
### Typical flow
|
| 112 |
+
|
| 113 |
+
1. Paste your **Hugging Face Token** — required for any example that calls `llm_query` / `rlm_query`.
|
| 114 |
+
2. Write Context + Task Prompt (or pick a demo from **💡 Load example**).
|
| 115 |
+
3. Click **🔁 Reset episode** — loads your context and wires up the helpers above.
|
| 116 |
+
4. Write Python in *Python Code* that uses those helpers and ends with `FINAL(...)`.
|
| 117 |
+
5. Click **▶ Run**. Inspect *Stdout* and *Raw JSON response* (the latter contains child-call metadata for recursive runs).
|
| 118 |
+
"""
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
_MAX_UPLOAD_BYTES = 20 * 1024 * 1024 # 20 MB — comfortably fits any arXiv PDF
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def _extract_text_from_upload(file_path: str) -> str:
|
| 125 |
+
"""Read a .txt or .pdf upload from disk and return its text content.
|
| 126 |
+
|
| 127 |
+
Runs in the Gradio server process (outside the smolagents sandbox),
|
| 128 |
+
so it can use `pypdf` and the filesystem without triggering the
|
| 129 |
+
REPL's import restrictions.
|
| 130 |
+
"""
|
| 131 |
+
path = Path(file_path)
|
| 132 |
+
size = path.stat().st_size
|
| 133 |
+
if size > _MAX_UPLOAD_BYTES:
|
| 134 |
+
return (
|
| 135 |
+
f"[File too large ({size // (1024 * 1024)} MB). "
|
| 136 |
+
f"Cap is {_MAX_UPLOAD_BYTES // (1024 * 1024)} MB to protect "
|
| 137 |
+
"the shared Space process from OOM.]"
|
| 138 |
+
)
|
| 139 |
+
suffix = path.suffix.lower()
|
| 140 |
+
if suffix == ".txt":
|
| 141 |
+
return path.read_text(encoding="utf-8", errors="replace")
|
| 142 |
+
if suffix == ".pdf":
|
| 143 |
+
if pypdf is None:
|
| 144 |
+
return "[pypdf is not installed in this deployment; cannot parse PDFs.]"
|
| 145 |
+
with path.open("rb") as fh:
|
| 146 |
+
reader = pypdf.PdfReader(io.BytesIO(fh.read()))
|
| 147 |
+
pages = []
|
| 148 |
+
for page in reader.pages:
|
| 149 |
+
try:
|
| 150 |
+
pages.append(page.extract_text() or "")
|
| 151 |
+
except Exception as exc: # pypdf can raise on malformed PDFs
|
| 152 |
+
pages.append(f"[page extraction failed: {exc}]")
|
| 153 |
+
text = "\n\n".join(pages).strip()
|
| 154 |
+
return text or "[No extractable text — PDF may be image-only.]"
|
| 155 |
+
return f"[Unsupported file type: {suffix}. Use .txt or .pdf.]"
|
| 156 |
+
|
| 157 |
+
|
| 158 |
def _code_block(title: str, content: str) -> str:
|
| 159 |
if not content:
|
| 160 |
return ""
|
| 161 |
return f"**{title}:**\n```text\n{content}\n```"
|
| 162 |
|
| 163 |
|
| 164 |
+
def _format_repl_response(
|
| 165 |
+
data: Dict[str, Any],
|
| 166 |
+
elapsed_s: Optional[float] = None,
|
| 167 |
+
) -> str:
|
| 168 |
+
"""Render REPL observations as a compact, screenshot-friendly view.
|
| 169 |
+
|
| 170 |
+
The FINAL answer is surfaced at the top as a quoted block, followed by
|
| 171 |
+
the task prompt echo, then stdout / stderr, and a one-line metadata
|
| 172 |
+
strip. Context preview / locals snapshot / available variables are
|
| 173 |
+
still in the raw JSON pane for debugging.
|
| 174 |
+
"""
|
| 175 |
observation = data.get("observation", {})
|
| 176 |
result = observation.get("result", {})
|
|
|
|
| 177 |
|
| 178 |
+
sections: List[str] = []
|
|
|
|
|
|
|
| 179 |
|
| 180 |
+
stdout = (result.get("stdout") or "").rstrip()
|
| 181 |
+
final_answer = _extract_final(stdout) if stdout else None
|
| 182 |
+
if final_answer:
|
| 183 |
+
sections.append(f"### 🎯 Answer\n\n> **{final_answer}**")
|
| 184 |
|
| 185 |
+
task_prompt = observation.get("metadata", {}).get("task_prompt")
|
| 186 |
+
if task_prompt:
|
| 187 |
+
sections.append(f"**Task:** {task_prompt}")
|
|
|
|
|
|
|
| 188 |
|
| 189 |
+
if stdout:
|
| 190 |
+
sections.append(_code_block("Stdout", stdout))
|
|
|
|
|
|
|
|
|
|
|
|
|
| 191 |
|
| 192 |
+
stderr = (result.get("stderr") or "").rstrip()
|
| 193 |
+
if stderr:
|
| 194 |
+
sections.append(_code_block("Stderr", stderr))
|
|
|
|
| 195 |
|
| 196 |
+
meta: List[str] = []
|
| 197 |
reward = data.get("reward")
|
| 198 |
+
if reward is not None:
|
| 199 |
+
meta.append(f"Reward `{reward}`")
|
| 200 |
done = data.get("done")
|
| 201 |
+
if done is not None:
|
| 202 |
+
meta.append(f"Done `{done}`")
|
| 203 |
+
iteration = observation.get("iteration")
|
| 204 |
+
max_iter = observation.get("max_iterations")
|
| 205 |
+
if iteration is not None and max_iter is not None:
|
| 206 |
+
meta.append(f"Step `{iteration}/{max_iter}`")
|
| 207 |
+
if elapsed_s is not None:
|
| 208 |
+
meta.append(f"Elapsed `{elapsed_s:.1f}s`")
|
| 209 |
+
if meta:
|
| 210 |
+
sections.append(" · ".join(meta))
|
| 211 |
|
| 212 |
+
if not sections:
|
| 213 |
+
return "_No output yet. Reset and Run._"
|
| 214 |
+
return "\n\n".join(sections)
|
| 215 |
|
| 216 |
|
| 217 |
def build_repl_gradio_app(
|
|
|
|
| 223 |
quick_start_md: str,
|
| 224 |
) -> gr.Blocks:
|
| 225 |
"""Build the REPL-specific Gradio tab."""
|
| 226 |
+
del action_fields, is_chat_env
|
| 227 |
+
|
| 228 |
+
# Skip `metadata.description`: the core default is a generic
|
| 229 |
+
# "<name> environment" placeholder that adds noise without content.
|
| 230 |
+
readme_content = (
|
| 231 |
+
metadata.readme_content if metadata and metadata.readme_content else ""
|
| 232 |
+
)
|
| 233 |
|
| 234 |
async def reset_repl(
|
| 235 |
+
context: Optional[str],
|
| 236 |
+
task_prompt: Optional[str],
|
| 237 |
+
expected_answer: Optional[str],
|
| 238 |
+
hf_token: Optional[str],
|
| 239 |
+
llm_model: Optional[str],
|
| 240 |
):
|
| 241 |
+
# Gradio can pass None for untouched password textboxes. Normalize here.
|
| 242 |
reset_kwargs: Dict[str, Any] = {}
|
| 243 |
+
if context and context.strip():
|
| 244 |
reset_kwargs["context"] = context
|
| 245 |
+
if task_prompt and task_prompt.strip():
|
| 246 |
reset_kwargs["task_prompt"] = task_prompt
|
| 247 |
+
if expected_answer and expected_answer.strip():
|
| 248 |
+
reset_kwargs["expected_answer"] = expected_answer.strip()
|
| 249 |
+
if hf_token and hf_token.strip():
|
| 250 |
+
reset_kwargs["hf_token"] = hf_token.strip()
|
| 251 |
+
if llm_model and llm_model.strip():
|
| 252 |
reset_kwargs["llm_model"] = llm_model
|
| 253 |
|
| 254 |
try:
|
| 255 |
+
start = time.monotonic()
|
| 256 |
data = await web_manager.reset_environment(reset_kwargs)
|
| 257 |
+
elapsed_s = time.monotonic() - start
|
| 258 |
state = web_manager.get_state()
|
| 259 |
return (
|
| 260 |
+
_format_repl_response(data, elapsed_s=elapsed_s),
|
| 261 |
json.dumps(data, indent=2, sort_keys=True),
|
| 262 |
json.dumps(state, indent=2, sort_keys=True),
|
| 263 |
+
f"Reset complete in {elapsed_s:.1f}s.",
|
| 264 |
)
|
| 265 |
except Exception as exc:
|
| 266 |
return ("", "", "", f"Error: {exc}")
|
| 267 |
|
| 268 |
+
async def run_code(code: Optional[str]):
|
| 269 |
+
if not code or not code.strip():
|
| 270 |
return ("", "", "", "Enter Python code to run.")
|
| 271 |
|
| 272 |
try:
|
| 273 |
+
start = time.monotonic()
|
| 274 |
data = await web_manager.step_environment({"code": code})
|
| 275 |
+
elapsed_s = time.monotonic() - start
|
| 276 |
state = web_manager.get_state()
|
| 277 |
return (
|
| 278 |
+
_format_repl_response(data, elapsed_s=elapsed_s),
|
| 279 |
json.dumps(data, indent=2, sort_keys=True),
|
| 280 |
json.dumps(state, indent=2, sort_keys=True),
|
| 281 |
+
f"Code executed in {elapsed_s:.1f}s.",
|
| 282 |
)
|
| 283 |
except Exception as exc:
|
| 284 |
return ("", "", "", f"Error: {exc}")
|
|
|
|
| 289 |
except Exception as exc:
|
| 290 |
return f"Error: {exc}"
|
| 291 |
|
| 292 |
+
def load_example(label: str):
|
| 293 |
+
if not label or label not in _EXAMPLES:
|
| 294 |
+
return (
|
| 295 |
+
gr.update(),
|
| 296 |
+
gr.update(),
|
| 297 |
+
gr.update(),
|
| 298 |
+
gr.update(),
|
| 299 |
+
gr.update(),
|
| 300 |
+
)
|
| 301 |
+
ctx, task, code, expected = _EXAMPLES[label]
|
| 302 |
+
# Auto-collapse the Context accordion once populated to keep the
|
| 303 |
+
# viewport focused on Task + Code + Output.
|
| 304 |
+
return ctx, task, code, expected, gr.update(open=False)
|
| 305 |
+
|
| 306 |
+
def load_uploaded_document(file_obj):
|
| 307 |
+
if file_obj is None:
|
| 308 |
+
return gr.update(), gr.update(), gr.update()
|
| 309 |
+
file_path = (
|
| 310 |
+
file_obj if isinstance(file_obj, str) else getattr(file_obj, "name", None)
|
| 311 |
+
)
|
| 312 |
+
if not file_path:
|
| 313 |
+
return gr.update(), "Upload error: could not read file path.", gr.update()
|
| 314 |
+
try:
|
| 315 |
+
text = _extract_text_from_upload(file_path)
|
| 316 |
+
except Exception as exc:
|
| 317 |
+
return gr.update(), f"Upload error: {exc}", gr.update()
|
| 318 |
+
return (
|
| 319 |
+
text,
|
| 320 |
+
f"Loaded {Path(file_path).name} ({len(text)} chars) into Context.",
|
| 321 |
+
gr.update(open=False),
|
| 322 |
+
)
|
| 323 |
+
|
| 324 |
+
with gr.Blocks(title=title) as blocks:
|
| 325 |
gr.Markdown(
|
| 326 |
+
"# REPL Control Panel\n"
|
| 327 |
+
"*Recursive Language Model REPL — run agentic Python with recursive LM calls.*"
|
|
|
|
| 328 |
)
|
| 329 |
+
|
| 330 |
with gr.Row():
|
| 331 |
with gr.Column(scale=2):
|
| 332 |
+
with gr.Row():
|
| 333 |
+
example_dropdown = gr.Dropdown(
|
| 334 |
+
label="💡 Load example",
|
| 335 |
+
choices=list(_EXAMPLES.keys()),
|
| 336 |
+
value=None,
|
| 337 |
+
)
|
| 338 |
+
upload = gr.File(
|
| 339 |
+
label="📎 Or drop a .txt / .pdf",
|
| 340 |
+
file_types=[".txt", ".pdf"],
|
| 341 |
+
type="filepath",
|
| 342 |
+
)
|
| 343 |
task_prompt = gr.Textbox(
|
| 344 |
label="Task Prompt",
|
| 345 |
placeholder="What should the agent solve?",
|
| 346 |
+
lines=2,
|
| 347 |
)
|
| 348 |
+
with gr.Accordion("Context", open=True) as context_accordion:
|
| 349 |
+
context = gr.Textbox(
|
| 350 |
+
label="",
|
| 351 |
+
placeholder="Problem context or source text (auto-fills from Load example / Drop)...",
|
| 352 |
+
lines=3,
|
| 353 |
+
show_label=False,
|
| 354 |
+
)
|
| 355 |
+
with gr.Accordion("Advanced options", open=False):
|
| 356 |
hf_token = gr.Textbox(
|
| 357 |
+
label="Hugging Face Token (required for llm_query / rlm_query)",
|
| 358 |
+
placeholder="hf_... — used only for this reset; not persisted",
|
| 359 |
type="password",
|
| 360 |
)
|
| 361 |
+
expected_answer = gr.Textbox(
|
| 362 |
+
label="Expected answer (activates reward scoring)",
|
| 363 |
+
placeholder="e.g. BANANA-747 — leave blank to skip",
|
| 364 |
+
)
|
| 365 |
llm_model = gr.Textbox(
|
| 366 |
label="LLM Model",
|
| 367 |
+
placeholder="e.g. Qwen/Qwen2.5-7B-Instruct",
|
| 368 |
+
info=(
|
| 369 |
+
"Blank = server default (Qwen/Qwen3.5-9B, tuned for the "
|
| 370 |
+
"`enable_thinking=False` chat template)."
|
| 371 |
+
),
|
| 372 |
)
|
| 373 |
+
with gr.Row():
|
| 374 |
+
reset_btn = gr.Button("🔁 Reset episode", variant="secondary")
|
| 375 |
+
status = gr.Textbox(
|
| 376 |
+
label="Status",
|
| 377 |
+
interactive=False,
|
| 378 |
+
lines=1,
|
| 379 |
+
scale=1,
|
| 380 |
+
)
|
| 381 |
+
with gr.Column(scale=3):
|
| 382 |
code = gr.Textbox(
|
| 383 |
label="Python Code",
|
| 384 |
placeholder="count = len(context.split())",
|
| 385 |
+
lines=8,
|
| 386 |
)
|
| 387 |
with gr.Row():
|
| 388 |
+
run_btn = gr.Button("▶ Run", variant="primary")
|
| 389 |
+
state_btn = gr.Button("📋 Get state", variant="secondary")
|
| 390 |
+
session_view = gr.Markdown(value="_Reset and Run to see output._")
|
| 391 |
+
with gr.Accordion("Raw JSON response", open=False):
|
| 392 |
+
raw_json = gr.Code(
|
| 393 |
+
label="",
|
| 394 |
+
language="json",
|
| 395 |
+
interactive=False,
|
| 396 |
+
)
|
| 397 |
+
with gr.Accordion("Session state", open=False):
|
| 398 |
+
state_json = gr.Code(
|
| 399 |
+
label="",
|
| 400 |
+
language="json",
|
| 401 |
+
interactive=False,
|
| 402 |
+
)
|
| 403 |
+
|
| 404 |
+
with gr.Accordion("REPL helpers reference & typical flow", open=False):
|
| 405 |
+
gr.Markdown(_HELPERS_MD)
|
| 406 |
+
|
| 407 |
+
if quick_start_md:
|
| 408 |
+
with gr.Accordion("API quick start (Python client snippet)", open=False):
|
| 409 |
+
gr.Markdown(quick_start_md)
|
| 410 |
+
|
| 411 |
+
if readme_content:
|
| 412 |
+
with gr.Accordion("Full README", open=False):
|
| 413 |
+
gr.Markdown(readme_content)
|
| 414 |
|
| 415 |
reset_btn.click(
|
| 416 |
fn=reset_repl,
|
| 417 |
+
inputs=[context, task_prompt, expected_answer, hf_token, llm_model],
|
| 418 |
outputs=[session_view, raw_json, state_json, status],
|
| 419 |
)
|
| 420 |
run_btn.click(
|
|
|
|
| 428 |
outputs=[session_view, raw_json, state_json, status],
|
| 429 |
)
|
| 430 |
state_btn.click(fn=get_state_sync, outputs=[state_json])
|
| 431 |
+
example_dropdown.change(
|
| 432 |
+
fn=load_example,
|
| 433 |
+
inputs=[example_dropdown],
|
| 434 |
+
outputs=[
|
| 435 |
+
context,
|
| 436 |
+
task_prompt,
|
| 437 |
+
code,
|
| 438 |
+
expected_answer,
|
| 439 |
+
context_accordion,
|
| 440 |
+
],
|
| 441 |
+
)
|
| 442 |
+
upload.change(
|
| 443 |
+
fn=load_uploaded_document,
|
| 444 |
+
inputs=[upload],
|
| 445 |
+
outputs=[context, status, context_accordion],
|
| 446 |
+
)
|
| 447 |
|
| 448 |
return blocks
|
server/repl_environment.py
CHANGED
|
@@ -33,7 +33,12 @@ try:
|
|
| 33 |
from ..models import CodeBlockResult, REPLAction, REPLObservation, REPLState
|
| 34 |
except ImportError:
|
| 35 |
try:
|
| 36 |
-
from repl_env.models import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
except ImportError:
|
| 38 |
from models import CodeBlockResult, REPLAction, REPLObservation, REPLState
|
| 39 |
|
|
@@ -47,10 +52,11 @@ except ImportError:
|
|
| 47 |
from repl_env.rubrics import REPLRubric
|
| 48 |
from .python_executor import PythonExecutor
|
| 49 |
except ImportError:
|
| 50 |
-
from .python_executor import PythonExecutor
|
| 51 |
from recursive_controller import create_server_recursive_controller
|
| 52 |
from rubrics import REPLRubric
|
| 53 |
|
|
|
|
|
|
|
| 54 |
|
| 55 |
class REPLEnvironment(Environment):
|
| 56 |
"""
|
|
@@ -138,6 +144,12 @@ class REPLEnvironment(Environment):
|
|
| 138 |
self._executor: Optional[PythonExecutor] = None
|
| 139 |
self._runtime_controller = None
|
| 140 |
self._runtime_controller_chat_fn: Optional[Callable[..., str]] = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 141 |
|
| 142 |
@staticmethod
|
| 143 |
def _build_hf_chat_fn(
|
|
@@ -149,7 +161,7 @@ class REPLEnvironment(Environment):
|
|
| 149 |
except ImportError:
|
| 150 |
raise RuntimeError("huggingface_hub is required for HF-backed recursion")
|
| 151 |
|
| 152 |
-
default_model =
|
| 153 |
client = InferenceClient(model=default_model, token=hf_token, timeout=300)
|
| 154 |
|
| 155 |
def chat_fn(messages: list[dict[str, str]], model: str | None = None) -> str:
|
|
@@ -255,13 +267,23 @@ class REPLEnvironment(Environment):
|
|
| 255 |
self.rlm_max_depth = runtime_rlm_max_depth
|
| 256 |
self.rlm_max_iterations = runtime_rlm_max_iterations
|
| 257 |
|
| 258 |
-
# Create or rebuild LLM functions when needed.
|
| 259 |
# Token resolution: explicit hf_token > HF_TOKEN env var > cached HF login.
|
| 260 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 261 |
effective_token = (
|
| 262 |
hf_token if hf_token is not None else os.environ.get("HF_TOKEN")
|
| 263 |
)
|
|
|
|
|
|
|
| 264 |
self._create_llm_functions(effective_token, llm_model)
|
|
|
|
| 265 |
elif depth_changed and self._runtime_controller is not None:
|
| 266 |
# Rebuild controller with new depth/iteration config but reuse
|
| 267 |
# the existing chat_fn — don't require re-providing credentials.
|
|
@@ -514,10 +536,18 @@ class REPLEnvironment(Environment):
|
|
| 514 |
Returns:
|
| 515 |
Final answer string or None if not found
|
| 516 |
"""
|
| 517 |
-
# Pattern 1: RLM-style FINAL(answer)
|
| 518 |
-
|
| 519 |
-
|
| 520 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 521 |
|
| 522 |
# Pattern 2: RLM-style FINAL_VAR(variable_name)
|
| 523 |
final_var_match = re.search(r"FINAL_VAR\((\w+)\)", stdout)
|
|
|
|
| 33 |
from ..models import CodeBlockResult, REPLAction, REPLObservation, REPLState
|
| 34 |
except ImportError:
|
| 35 |
try:
|
| 36 |
+
from repl_env.models import (
|
| 37 |
+
CodeBlockResult,
|
| 38 |
+
REPLAction,
|
| 39 |
+
REPLObservation,
|
| 40 |
+
REPLState,
|
| 41 |
+
)
|
| 42 |
except ImportError:
|
| 43 |
from models import CodeBlockResult, REPLAction, REPLObservation, REPLState
|
| 44 |
|
|
|
|
| 52 |
from repl_env.rubrics import REPLRubric
|
| 53 |
from .python_executor import PythonExecutor
|
| 54 |
except ImportError:
|
|
|
|
| 55 |
from recursive_controller import create_server_recursive_controller
|
| 56 |
from rubrics import REPLRubric
|
| 57 |
|
| 58 |
+
from .python_executor import PythonExecutor
|
| 59 |
+
|
| 60 |
|
| 61 |
class REPLEnvironment(Environment):
|
| 62 |
"""
|
|
|
|
| 144 |
self._executor: Optional[PythonExecutor] = None
|
| 145 |
self._runtime_controller = None
|
| 146 |
self._runtime_controller_chat_fn: Optional[Callable[..., str]] = None
|
| 147 |
+
self._current_llm_model: Optional[str] = None
|
| 148 |
+
|
| 149 |
+
@staticmethod
|
| 150 |
+
def _resolve_model(llm_model: Optional[str]) -> str:
|
| 151 |
+
"""Resolve the effective model name: explicit arg > env var > hard default."""
|
| 152 |
+
return llm_model or os.environ.get("LLM_MODEL", "Qwen/Qwen3.5-9B")
|
| 153 |
|
| 154 |
@staticmethod
|
| 155 |
def _build_hf_chat_fn(
|
|
|
|
| 161 |
except ImportError:
|
| 162 |
raise RuntimeError("huggingface_hub is required for HF-backed recursion")
|
| 163 |
|
| 164 |
+
default_model = REPLEnvironment._resolve_model(llm_model)
|
| 165 |
client = InferenceClient(model=default_model, token=hf_token, timeout=300)
|
| 166 |
|
| 167 |
def chat_fn(messages: list[dict[str, str]], model: str | None = None) -> str:
|
|
|
|
| 267 |
self.rlm_max_depth = runtime_rlm_max_depth
|
| 268 |
self.rlm_max_iterations = runtime_rlm_max_iterations
|
| 269 |
|
|
|
|
| 270 |
# Token resolution: explicit hf_token > HF_TOKEN env var > cached HF login.
|
| 271 |
+
# Compare resolved model names so reset(llm_model=<default>) and
|
| 272 |
+
# reset() are treated as equal and don't trigger a redundant rebuild.
|
| 273 |
+
resolved_model = self._resolve_model(llm_model)
|
| 274 |
+
has_runtime_llm = self._runtime_controller is not None
|
| 275 |
+
model_changed = (
|
| 276 |
+
has_runtime_llm and resolved_model != self._current_llm_model
|
| 277 |
+
)
|
| 278 |
+
token_provided = hf_token is not None
|
| 279 |
+
if not self.llm_query_fn or model_changed or token_provided:
|
| 280 |
effective_token = (
|
| 281 |
hf_token if hf_token is not None else os.environ.get("HF_TOKEN")
|
| 282 |
)
|
| 283 |
+
# Pass raw llm_model; _build_hf_chat_fn resolves it via
|
| 284 |
+
# _resolve_model so the client matches the cache key below.
|
| 285 |
self._create_llm_functions(effective_token, llm_model)
|
| 286 |
+
self._current_llm_model = resolved_model
|
| 287 |
elif depth_changed and self._runtime_controller is not None:
|
| 288 |
# Rebuild controller with new depth/iteration config but reuse
|
| 289 |
# the existing chat_fn — don't require re-providing credentials.
|
|
|
|
| 536 |
Returns:
|
| 537 |
Final answer string or None if not found
|
| 538 |
"""
|
| 539 |
+
# Pattern 1: RLM-style FINAL(answer). Paren-counting handles nested
|
| 540 |
+
# parens (e.g. FINAL(f(x))) and multi-line values without regex flag trade-offs.
|
| 541 |
+
idx = stdout.find("FINAL(")
|
| 542 |
+
if idx != -1:
|
| 543 |
+
depth, start = 0, idx + len("FINAL")
|
| 544 |
+
for i, ch in enumerate(stdout[idx + len("FINAL"):], start=idx + len("FINAL")):
|
| 545 |
+
if ch == "(":
|
| 546 |
+
depth += 1
|
| 547 |
+
elif ch == ")":
|
| 548 |
+
depth -= 1
|
| 549 |
+
if depth == 0:
|
| 550 |
+
return stdout[start + 1 : i].strip()
|
| 551 |
|
| 552 |
# Pattern 2: RLM-style FINAL_VAR(variable_name)
|
| 553 |
final_var_match = re.search(r"FINAL_VAR\((\w+)\)", stdout)
|
src/__init__.py
CHANGED
|
@@ -1,7 +1,3 @@
|
|
| 1 |
-
#
|
| 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 |
"""EnvTorch: Standardized agentic execution environments."""
|
|
|
|
| 1 |
+
# SPDX-License-Identifier: BSD-3-Clause
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
"""EnvTorch: Standardized agentic execution environments."""
|
src/core/README.md
CHANGED
|
@@ -1,21 +1,15 @@
|
|
| 1 |
-
#
|
| 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.
|
|
@@ -28,16 +22,42 @@ Core components for OpenEnv - a framework for building HTTP-based agentic enviro
|
|
| 28 |
- **Container Providers**: Pluggable architecture for running containers (Docker, Kubernetes, etc.)
|
| 29 |
- **Type System**: Strongly-typed Action/Observation/State interfaces
|
| 30 |
- **Web Interface**: Optional web UI for interacting with environments
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
|
| 32 |
## Installation
|
| 33 |
|
| 34 |
```bash
|
| 35 |
-
pip install "openenv
|
| 36 |
```
|
| 37 |
|
| 38 |
For development:
|
| 39 |
```bash
|
| 40 |
-
pip install "openenv
|
| 41 |
```
|
| 42 |
|
| 43 |
## Quick Start
|
|
@@ -52,14 +72,17 @@ from openenv.core import EnvClient, StepResult
|
|
| 52 |
from dataclasses import dataclass
|
| 53 |
from typing import Any
|
| 54 |
|
|
|
|
| 55 |
@dataclass
|
| 56 |
class MyAction:
|
| 57 |
text: str
|
| 58 |
|
|
|
|
| 59 |
@dataclass
|
| 60 |
class MyObservation:
|
| 61 |
response: str
|
| 62 |
|
|
|
|
| 63 |
class MyEnvClient(EnvClient[MyAction, MyObservation, Any]):
|
| 64 |
def _step_payload(self, action: MyAction) -> dict:
|
| 65 |
return {"text": action.text}
|
|
@@ -69,12 +92,13 @@ class MyEnvClient(EnvClient[MyAction, MyObservation, Any]):
|
|
| 69 |
return StepResult(
|
| 70 |
observation=MyObservation(**obs_data),
|
| 71 |
reward=payload.get("reward"),
|
| 72 |
-
done=payload.get("done", False)
|
| 73 |
)
|
| 74 |
|
| 75 |
def _parse_state(self, payload: dict) -> Any:
|
| 76 |
return payload
|
| 77 |
|
|
|
|
| 78 |
# Async usage (recommended)
|
| 79 |
async def main():
|
| 80 |
client = await MyEnvClient.from_docker_image("my-env:latest")
|
|
@@ -82,6 +106,7 @@ async def main():
|
|
| 82 |
result = await client.reset()
|
| 83 |
step_result = await client.step(MyAction(text="hello"))
|
| 84 |
|
|
|
|
| 85 |
asyncio.run(main())
|
| 86 |
|
| 87 |
# Sync usage (via .sync() wrapper)
|
|
@@ -96,26 +121,26 @@ with MyEnvClient(base_url="http://localhost:8000").sync() as client:
|
|
| 96 |
from openenv.core.env_server import Environment, HTTPEnvServer, create_app
|
| 97 |
from dataclasses import dataclass
|
| 98 |
|
|
|
|
| 99 |
@dataclass
|
| 100 |
class MyAction:
|
| 101 |
text: str
|
| 102 |
|
|
|
|
| 103 |
@dataclass
|
| 104 |
class MyObservation:
|
| 105 |
response: str
|
| 106 |
reward: float = 0.0
|
| 107 |
done: bool = False
|
| 108 |
|
|
|
|
| 109 |
class MyEnvironment(Environment):
|
| 110 |
def reset(self) -> MyObservation:
|
| 111 |
return MyObservation(response="Ready")
|
| 112 |
|
| 113 |
def step(self, action: MyAction) -> MyObservation:
|
| 114 |
-
return MyObservation(
|
| 115 |
-
|
| 116 |
-
reward=1.0,
|
| 117 |
-
done=False
|
| 118 |
-
)
|
| 119 |
|
| 120 |
# Create FastAPI app
|
| 121 |
env = MyEnvironment()
|
|
@@ -199,7 +224,7 @@ Base interface for environment implementations:
|
|
| 199 |
|
| 200 |
## License
|
| 201 |
|
| 202 |
-
This project is licensed under the BSD
|
| 203 |
|
| 204 |
## Contributing
|
| 205 |
|
|
@@ -207,6 +232,6 @@ Contributions are welcome! Please see the main OpenEnv repository for contributi
|
|
| 207 |
|
| 208 |
## Links
|
| 209 |
|
| 210 |
-
- **Homepage**: https://github.com/
|
| 211 |
-
- **Documentation**: https://github.com/
|
| 212 |
-
- **Bug Tracker**: https://github.com/
|
|
|
|
| 1 |
+
# 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 |
+
> [!NOTE]
|
| 8 |
+
> OpenEnv is in early development, see the note in the [project README](../../../README.md) before contributing. The [README](../../../README.md#community-support--acknowledgments) lists the technical committee that governs the project, and the [charter](../../../GOVERNANCE.md) explains how it works.
|
| 9 |
|
| 10 |
## Overview
|
| 11 |
`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.
|
| 12 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
# OpenEnv Core
|
| 14 |
|
| 15 |
Core components for OpenEnv - a framework for building HTTP-based agentic environments.
|
|
|
|
| 22 |
- **Container Providers**: Pluggable architecture for running containers (Docker, Kubernetes, etc.)
|
| 23 |
- **Type System**: Strongly-typed Action/Observation/State interfaces
|
| 24 |
- **Web Interface**: Optional web UI for interacting with environments
|
| 25 |
+
- **Experimental Harness Helpers**: `openenv.core.harness` provides
|
| 26 |
+
`ResourceSessionFactory`, `StepEnvSessionAdapter`, and `HarnessAdapter` for
|
| 27 |
+
MCP-first training/evaluation harnesses
|
| 28 |
+
|
| 29 |
+
## Experimental Harness Helpers
|
| 30 |
+
|
| 31 |
+
OpenEnv now includes an additive harness-facing layer for cases where a rollout
|
| 32 |
+
driver should interact with environment resources through tools instead of
|
| 33 |
+
calling `reset()` / `step()` directly. These helpers are currently
|
| 34 |
+
experimental while RFC 005 remains in review, so import them from
|
| 35 |
+
`openenv.core.harness`.
|
| 36 |
+
|
| 37 |
+
Core pieces:
|
| 38 |
+
|
| 39 |
+
- `ResourceSessionFactory.create(task, seed, episode_id)` creates an isolated
|
| 40 |
+
per-rollout session
|
| 41 |
+
- `StepEnvSessionAdapter` wraps an existing client and exposes session tools
|
| 42 |
+
- `SessionMCPBridge` exposes a session through MCP-style JSON-RPC
|
| 43 |
+
- `MCPHarnessAdapter` drives white-box rollouts where the trainer still owns
|
| 44 |
+
model sampling, token IDs, and logprobs
|
| 45 |
+
- `CLIHarnessAdapter` supports opaque evaluation harnesses over the same session
|
| 46 |
+
surface
|
| 47 |
+
- `build_harness_rollout_func(...)` builds a TRL-compatible `rollout_func`
|
| 48 |
+
|
| 49 |
+
This layer is additive. Existing environment clients and MCP environments keep
|
| 50 |
+
their current control-plane APIs unchanged.
|
| 51 |
|
| 52 |
## Installation
|
| 53 |
|
| 54 |
```bash
|
| 55 |
+
pip install "openenv"
|
| 56 |
```
|
| 57 |
|
| 58 |
For development:
|
| 59 |
```bash
|
| 60 |
+
pip install "openenv"
|
| 61 |
```
|
| 62 |
|
| 63 |
## Quick Start
|
|
|
|
| 72 |
from dataclasses import dataclass
|
| 73 |
from typing import Any
|
| 74 |
|
| 75 |
+
|
| 76 |
@dataclass
|
| 77 |
class MyAction:
|
| 78 |
text: str
|
| 79 |
|
| 80 |
+
|
| 81 |
@dataclass
|
| 82 |
class MyObservation:
|
| 83 |
response: str
|
| 84 |
|
| 85 |
+
|
| 86 |
class MyEnvClient(EnvClient[MyAction, MyObservation, Any]):
|
| 87 |
def _step_payload(self, action: MyAction) -> dict:
|
| 88 |
return {"text": action.text}
|
|
|
|
| 92 |
return StepResult(
|
| 93 |
observation=MyObservation(**obs_data),
|
| 94 |
reward=payload.get("reward"),
|
| 95 |
+
done=payload.get("done", False),
|
| 96 |
)
|
| 97 |
|
| 98 |
def _parse_state(self, payload: dict) -> Any:
|
| 99 |
return payload
|
| 100 |
|
| 101 |
+
|
| 102 |
# Async usage (recommended)
|
| 103 |
async def main():
|
| 104 |
client = await MyEnvClient.from_docker_image("my-env:latest")
|
|
|
|
| 106 |
result = await client.reset()
|
| 107 |
step_result = await client.step(MyAction(text="hello"))
|
| 108 |
|
| 109 |
+
|
| 110 |
asyncio.run(main())
|
| 111 |
|
| 112 |
# Sync usage (via .sync() wrapper)
|
|
|
|
| 121 |
from openenv.core.env_server import Environment, HTTPEnvServer, create_app
|
| 122 |
from dataclasses import dataclass
|
| 123 |
|
| 124 |
+
|
| 125 |
@dataclass
|
| 126 |
class MyAction:
|
| 127 |
text: str
|
| 128 |
|
| 129 |
+
|
| 130 |
@dataclass
|
| 131 |
class MyObservation:
|
| 132 |
response: str
|
| 133 |
reward: float = 0.0
|
| 134 |
done: bool = False
|
| 135 |
|
| 136 |
+
|
| 137 |
class MyEnvironment(Environment):
|
| 138 |
def reset(self) -> MyObservation:
|
| 139 |
return MyObservation(response="Ready")
|
| 140 |
|
| 141 |
def step(self, action: MyAction) -> MyObservation:
|
| 142 |
+
return MyObservation(response=f"Echo: {action.text}", reward=1.0, done=False)
|
| 143 |
+
|
|
|
|
|
|
|
|
|
|
| 144 |
|
| 145 |
# Create FastAPI app
|
| 146 |
env = MyEnvironment()
|
|
|
|
| 224 |
|
| 225 |
## License
|
| 226 |
|
| 227 |
+
This project is licensed under the BSD 3-Clause License - see the LICENSE file for details.
|
| 228 |
|
| 229 |
## Contributing
|
| 230 |
|
|
|
|
| 232 |
|
| 233 |
## Links
|
| 234 |
|
| 235 |
+
- **Homepage**: https://github.com/huggingface/OpenEnv
|
| 236 |
+
- **Documentation**: https://github.com/huggingface/OpenEnv/blob/main/README.md
|
| 237 |
+
- **Bug Tracker**: https://github.com/huggingface/OpenEnv/issues
|
src/core/__init__.py
CHANGED
|
@@ -1,8 +1,4 @@
|
|
| 1 |
-
#
|
| 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 |
|
|
|
|
| 1 |
+
# SPDX-License-Identifier: BSD-3-Clause
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
"""Core components for agentic environments."""
|
| 4 |
|
src/core/client_types.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
# Type definitions for EnvTorch
|
| 2 |
from dataclasses import dataclass
|
| 3 |
-
from typing import Generic, Optional, TypeVar
|
| 4 |
|
| 5 |
# Generic type for observations
|
| 6 |
ObsT = TypeVar("ObsT")
|
|
@@ -13,11 +13,17 @@ class StepResult(Generic[ObsT]):
|
|
| 13 |
Represents the result of one environment step.
|
| 14 |
|
| 15 |
Attributes:
|
| 16 |
-
observation:
|
| 17 |
-
|
| 18 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
"""
|
| 20 |
|
| 21 |
observation: ObsT
|
| 22 |
reward: Optional[float] = None
|
| 23 |
done: bool = False
|
|
|
|
|
|
| 1 |
# Type definitions for EnvTorch
|
| 2 |
from dataclasses import dataclass
|
| 3 |
+
from typing import Any, Dict, Generic, Optional, TypeVar
|
| 4 |
|
| 5 |
# Generic type for observations
|
| 6 |
ObsT = TypeVar("ObsT")
|
|
|
|
| 13 |
Represents the result of one environment step.
|
| 14 |
|
| 15 |
Attributes:
|
| 16 |
+
observation:
|
| 17 |
+
The environment's observation after the action.
|
| 18 |
+
reward (`float`, *optional*):
|
| 19 |
+
Scalar reward for this step.
|
| 20 |
+
done (`bool`, *optional*, defaults to `False`):
|
| 21 |
+
Whether the episode is finished.
|
| 22 |
+
metadata (`dict`, *optional*):
|
| 23 |
+
Additional metadata returned alongside the observation.
|
| 24 |
"""
|
| 25 |
|
| 26 |
observation: ObsT
|
| 27 |
reward: Optional[float] = None
|
| 28 |
done: bool = False
|
| 29 |
+
metadata: Optional[Dict[str, Any]] = None
|
src/core/containers/__init__.py
CHANGED
|
@@ -1,7 +1,3 @@
|
|
| 1 |
-
#
|
| 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."""
|
|
|
|
| 1 |
+
# SPDX-License-Identifier: BSD-3-Clause
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
"""Container management for environment servers."""
|
src/core/containers/images/Dockerfile
CHANGED
|
@@ -1,8 +1,4 @@
|
|
| 1 |
-
#
|
| 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
|
|
|
|
| 1 |
+
# SPDX-License-Identifier: BSD-3-Clause
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
#
|
| 4 |
# OpenEnv Base Image
|
src/core/containers/runtime/__init__.py
CHANGED
|
@@ -1,8 +1,4 @@
|
|
| 1 |
-
#
|
| 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 |
|
|
@@ -15,6 +11,11 @@ from .providers import (
|
|
| 15 |
)
|
| 16 |
from .uv_provider import UVProvider
|
| 17 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
__all__ = [
|
| 19 |
"ContainerProvider",
|
| 20 |
"DockerSwarmProvider",
|
|
|
|
| 1 |
+
# SPDX-License-Identifier: BSD-3-Clause
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
"""Container runtime providers."""
|
| 4 |
|
|
|
|
| 11 |
)
|
| 12 |
from .uv_provider import UVProvider
|
| 13 |
|
| 14 |
+
# Note: optional cloud providers that require extra SDKs (e.g.
|
| 15 |
+
# `ACASandboxProvider`, `DaytonaProvider`) are intentionally NOT re-exported
|
| 16 |
+
# here. Import them from their module, e.g.
|
| 17 |
+
# `from openenv.core.containers.runtime.aca_provider import ACASandboxProvider`.
|
| 18 |
+
|
| 19 |
__all__ = [
|
| 20 |
"ContainerProvider",
|
| 21 |
"DockerSwarmProvider",
|
src/core/containers/runtime/_server_config.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-License-Identifier: BSD-3-Clause
|
| 2 |
+
|
| 3 |
+
"""Shared helpers for reading environment server configuration."""
|
| 4 |
+
|
| 5 |
+
from __future__ import annotations
|
| 6 |
+
|
| 7 |
+
import json
|
| 8 |
+
import re
|
| 9 |
+
|
| 10 |
+
import yaml
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def parse_openenv_app_field(yaml_content: str) -> str | None:
|
| 14 |
+
"""Extract the top-level ``app`` value from raw ``openenv.yaml`` content."""
|
| 15 |
+
try:
|
| 16 |
+
data = yaml.safe_load(yaml_content) or {}
|
| 17 |
+
except Exception:
|
| 18 |
+
return None
|
| 19 |
+
|
| 20 |
+
if not isinstance(data, dict):
|
| 21 |
+
return None
|
| 22 |
+
|
| 23 |
+
value = data.get("app")
|
| 24 |
+
if isinstance(value, str):
|
| 25 |
+
value = value.strip()
|
| 26 |
+
return value if value else None
|
| 27 |
+
return None
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def parse_dockerfile_cmd(dockerfile_content: str) -> str | None:
|
| 31 |
+
"""Extract the server command from the last Dockerfile ``CMD``."""
|
| 32 |
+
last_cmd: str | None = None
|
| 33 |
+
for line in dockerfile_content.splitlines():
|
| 34 |
+
stripped = line.strip()
|
| 35 |
+
if stripped.startswith("#"):
|
| 36 |
+
continue
|
| 37 |
+
match = re.match(r"CMD\s+(.+)", stripped, flags=re.IGNORECASE)
|
| 38 |
+
if match:
|
| 39 |
+
last_cmd = match.group(1).strip()
|
| 40 |
+
|
| 41 |
+
if last_cmd is None:
|
| 42 |
+
return None
|
| 43 |
+
|
| 44 |
+
if last_cmd.startswith("["):
|
| 45 |
+
try:
|
| 46 |
+
parts = json.loads(last_cmd)
|
| 47 |
+
if isinstance(parts, list) and all(isinstance(p, str) for p in parts):
|
| 48 |
+
return " ".join(parts)
|
| 49 |
+
except (json.JSONDecodeError, TypeError):
|
| 50 |
+
# Invalid exec-form JSON falls back to the raw CMD below.
|
| 51 |
+
pass
|
| 52 |
+
|
| 53 |
+
return last_cmd if last_cmd else None
|
src/core/containers/runtime/aca_provider.py
ADDED
|
@@ -0,0 +1,690 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-License-Identifier: BSD-3-Clause
|
| 2 |
+
|
| 3 |
+
"""Azure Container Apps Sandbox provider for OpenEnv environments.
|
| 4 |
+
|
| 5 |
+
Note: the ``RFC 002 security invariant S<n>`` references in this module point to
|
| 6 |
+
the **proposed** "Cloud Sandbox Providers" amendment to RFC 002, which is pending
|
| 7 |
+
review/sign-off by the RFC authors. They describe the security properties this
|
| 8 |
+
provider enforces; treat them as proposed (not yet ratified) until that sign-off.
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import os
|
| 14 |
+
import shlex
|
| 15 |
+
import time
|
| 16 |
+
import warnings
|
| 17 |
+
from typing import Any, Dict, Literal, Optional
|
| 18 |
+
|
| 19 |
+
from .providers import ContainerProvider
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
_DEFAULT_ACA_PORT = 8000
|
| 23 |
+
# 15 minutes matches the conservative auto-stop default used by other cloud
|
| 24 |
+
# sandbox providers (e.g. Daytona). A live RL rollout holds one WebSocket for
|
| 25 |
+
# the whole episode, so an aggressive auto-suspend can silently drop the
|
| 26 |
+
# session between slow steps. See RFC 002 "Cloud Sandbox Providers".
|
| 27 |
+
_DEFAULT_AUTO_SUSPEND_SECONDS = 900
|
| 28 |
+
_VALID_SUSPEND_MODES = {"Memory", "Disk"}
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _require_secure_url(url: str) -> str:
|
| 32 |
+
"""Enforce https/wss transport (RFC 002 security invariant S1).
|
| 33 |
+
|
| 34 |
+
`EnvClient` derives its WebSocket URL from this base URL, so a plaintext
|
| 35 |
+
URL would become a cleartext `ws://` connection. The offending URL is
|
| 36 |
+
deliberately omitted from the error because an anonymous ACA port URL is a
|
| 37 |
+
bearer capability (S2) that must not leak into logs.
|
| 38 |
+
"""
|
| 39 |
+
if not isinstance(url, str) or not url.lower().startswith("https://"):
|
| 40 |
+
raise RuntimeError(
|
| 41 |
+
"ACA sandbox returned a non-HTTPS port URL. OpenEnv requires an "
|
| 42 |
+
"https/wss base_url so EnvClient traffic is encrypted. Refusing to "
|
| 43 |
+
"connect over plaintext."
|
| 44 |
+
)
|
| 45 |
+
return url
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def _validate_anonymous_port(anonymous_port: Optional[bool]) -> bool:
|
| 49 |
+
"""Require explicit, public-only ingress opt-in (RFC 002 invariant S2).
|
| 50 |
+
|
| 51 |
+
Public exposure is never implicit (`None` is rejected), and authenticated
|
| 52 |
+
ACA ports are rejected because `EnvClient` has no WebSocket auth mechanism
|
| 53 |
+
yet.
|
| 54 |
+
"""
|
| 55 |
+
if anonymous_port is None:
|
| 56 |
+
raise ValueError(
|
| 57 |
+
"ACASandboxProvider requires explicit anonymous_port=True. "
|
| 58 |
+
"ACA anonymous ports are public ingress; authenticated ACA "
|
| 59 |
+
"ports are not supported by EnvClient until a client-auth RFC "
|
| 60 |
+
"defines WebSocket credentials."
|
| 61 |
+
)
|
| 62 |
+
if not anonymous_port:
|
| 63 |
+
raise ValueError(
|
| 64 |
+
"authenticated ACA sandbox ports are not supported by EnvClient "
|
| 65 |
+
"yet. Use anonymous_port=True only for intentionally exposed "
|
| 66 |
+
"sandbox URLs, or wait for a client-auth RFC."
|
| 67 |
+
)
|
| 68 |
+
return True
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def _resolve_required(value: str | None, env_name: str, parameter: str) -> str:
|
| 72 |
+
resolved = value or os.environ.get(env_name)
|
| 73 |
+
if not resolved:
|
| 74 |
+
raise ValueError(
|
| 75 |
+
f"{parameter} is required. Pass it to ACASandboxProvider or set "
|
| 76 |
+
f"the {env_name} environment variable."
|
| 77 |
+
)
|
| 78 |
+
return resolved
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def _raise_install_error(exc: ImportError) -> None:
|
| 82 |
+
raise RuntimeError(
|
| 83 |
+
"Azure Container Apps sandbox support requires optional dependencies. "
|
| 84 |
+
"Install them with `pip install openenv[aca]`."
|
| 85 |
+
) from exc
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
class _DefaultACASandboxAdapter:
|
| 89 |
+
"""Thin adapter over the preview `azure-containerapps-sandbox` SDK.
|
| 90 |
+
|
| 91 |
+
The provider talks to this private adapter instead of spreading SDK details
|
| 92 |
+
through its own logic; tests inject a duck-typed fake in its place.
|
| 93 |
+
"""
|
| 94 |
+
|
| 95 |
+
def __init__(
|
| 96 |
+
self,
|
| 97 |
+
*,
|
| 98 |
+
subscription_id: str,
|
| 99 |
+
resource_group: str,
|
| 100 |
+
sandbox_group: str,
|
| 101 |
+
region: str | None,
|
| 102 |
+
endpoint: str | None,
|
| 103 |
+
credential: Any,
|
| 104 |
+
sdk_kwargs: dict[str, Any],
|
| 105 |
+
):
|
| 106 |
+
try:
|
| 107 |
+
from azure.containerapps.sandbox import (
|
| 108 |
+
endpoint_for_region,
|
| 109 |
+
SandboxGroupClient,
|
| 110 |
+
)
|
| 111 |
+
except ImportError as exc: # pragma: no cover - exercised via provider tests
|
| 112 |
+
_raise_install_error(exc)
|
| 113 |
+
|
| 114 |
+
if credential is None:
|
| 115 |
+
try:
|
| 116 |
+
from azure.identity import DefaultAzureCredential
|
| 117 |
+
except (
|
| 118 |
+
ImportError
|
| 119 |
+
) as exc: # pragma: no cover - exercised via provider tests
|
| 120 |
+
_raise_install_error(exc)
|
| 121 |
+
|
| 122 |
+
credential = DefaultAzureCredential()
|
| 123 |
+
|
| 124 |
+
if endpoint is None:
|
| 125 |
+
if region is None:
|
| 126 |
+
raise ValueError(
|
| 127 |
+
"region is required when endpoint is not provided. Pass it to "
|
| 128 |
+
"ACASandboxProvider or set AZURE_REGION."
|
| 129 |
+
)
|
| 130 |
+
endpoint = endpoint_for_region(region)
|
| 131 |
+
|
| 132 |
+
self._client = SandboxGroupClient(
|
| 133 |
+
endpoint,
|
| 134 |
+
credential,
|
| 135 |
+
subscription_id=subscription_id,
|
| 136 |
+
resource_group=resource_group,
|
| 137 |
+
sandbox_group=sandbox_group,
|
| 138 |
+
**sdk_kwargs,
|
| 139 |
+
)
|
| 140 |
+
|
| 141 |
+
def create_sandbox(
|
| 142 |
+
self,
|
| 143 |
+
*,
|
| 144 |
+
disk: str | None,
|
| 145 |
+
disk_id: str | None,
|
| 146 |
+
env_vars: dict[str, str] | None,
|
| 147 |
+
labels: dict[str, str] | None,
|
| 148 |
+
egress_policy: Any,
|
| 149 |
+
cpu: str,
|
| 150 |
+
memory: str,
|
| 151 |
+
disk_size: str | None,
|
| 152 |
+
auto_suspend_seconds: int,
|
| 153 |
+
auto_suspend_mode: str,
|
| 154 |
+
create_timeout: int,
|
| 155 |
+
polling_interval: int,
|
| 156 |
+
) -> Any:
|
| 157 |
+
kwargs: dict[str, Any] = {
|
| 158 |
+
"disk": disk,
|
| 159 |
+
"disk_id": disk_id,
|
| 160 |
+
"cpu": cpu,
|
| 161 |
+
"memory": memory,
|
| 162 |
+
"disk_size": disk_size,
|
| 163 |
+
"auto_suspend_seconds": auto_suspend_seconds,
|
| 164 |
+
"auto_suspend_mode": auto_suspend_mode,
|
| 165 |
+
"labels": labels,
|
| 166 |
+
"environment": env_vars,
|
| 167 |
+
"egress_policy": egress_policy,
|
| 168 |
+
"polling_timeout": create_timeout,
|
| 169 |
+
"polling_interval": polling_interval,
|
| 170 |
+
}
|
| 171 |
+
kwargs = {key: value for key, value in kwargs.items() if value is not None}
|
| 172 |
+
# `begin_create_sandbox(...).result()` blocks until the sandbox is
|
| 173 |
+
# *Running* and returns a sandbox-scoped `SandboxClient` (not a plain
|
| 174 |
+
# data model). That client exposes exec / add_port / begin_delete
|
| 175 |
+
# directly, which is the handle the rest of this adapter operates on.
|
| 176 |
+
return self._client.begin_create_sandbox(**kwargs).result()
|
| 177 |
+
|
| 178 |
+
def add_port(self, sandbox: Any, *, port: int, anonymous: bool) -> str:
|
| 179 |
+
sandbox_port = sandbox.add_port(port, anonymous=anonymous)
|
| 180 |
+
url = getattr(sandbox_port, "url", None)
|
| 181 |
+
if not url and isinstance(sandbox_port, dict):
|
| 182 |
+
url = sandbox_port.get("url")
|
| 183 |
+
if not url:
|
| 184 |
+
raise RuntimeError(
|
| 185 |
+
"ACA sandbox did not return a URL for the exposed port. "
|
| 186 |
+
"OpenEnv requires a base_url that EnvClient can connect to directly."
|
| 187 |
+
)
|
| 188 |
+
return str(url)
|
| 189 |
+
|
| 190 |
+
def exec(
|
| 191 |
+
self,
|
| 192 |
+
sandbox: Any,
|
| 193 |
+
command: str,
|
| 194 |
+
*,
|
| 195 |
+
working_directory: str | None = None,
|
| 196 |
+
) -> Any:
|
| 197 |
+
return sandbox.exec(command, working_directory=working_directory)
|
| 198 |
+
|
| 199 |
+
def delete_sandbox(self, sandbox: Any) -> None:
|
| 200 |
+
if hasattr(sandbox, "begin_delete"):
|
| 201 |
+
sandbox.begin_delete().result()
|
| 202 |
+
else:
|
| 203 |
+
sandbox.delete()
|
| 204 |
+
|
| 205 |
+
def close(self) -> None:
|
| 206 |
+
self._client.close()
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
def _source_from_image(image: str) -> tuple[str | None, str | None]:
|
| 210 |
+
if not image or not image.strip():
|
| 211 |
+
raise ValueError("image must be a non-empty ACA disk or disk-id reference")
|
| 212 |
+
image = image.strip()
|
| 213 |
+
|
| 214 |
+
if image.startswith("disk:"):
|
| 215 |
+
disk = image[len("disk:") :].strip()
|
| 216 |
+
if not disk:
|
| 217 |
+
raise ValueError("disk: source must include a disk image name")
|
| 218 |
+
return disk, None
|
| 219 |
+
if image.startswith("disk-id:"):
|
| 220 |
+
disk_id = image[len("disk-id:") :].strip()
|
| 221 |
+
if not disk_id:
|
| 222 |
+
raise ValueError("disk-id: source must include a disk image id")
|
| 223 |
+
return None, disk_id
|
| 224 |
+
|
| 225 |
+
# A bare string is treated as a public disk-image name. Guard the common
|
| 226 |
+
# migration foot-gun: an ACA sandbox source is NOT a Docker/OCI registry
|
| 227 |
+
# image, so a value that looks like one (a registry path with "/", or an
|
| 228 |
+
# "image:tag") is almost certainly a mistake and would otherwise surface as
|
| 229 |
+
# an opaque ACA SDK error. Fail fast with guidance instead.
|
| 230 |
+
if "/" in image or ":" in image:
|
| 231 |
+
raise ValueError(
|
| 232 |
+
f"{image!r} looks like a container/OCI image reference, but "
|
| 233 |
+
"ACASandboxProvider sources are Azure Container Apps sandbox disks, "
|
| 234 |
+
"not Docker registry images. Use a bare public disk name, "
|
| 235 |
+
"'disk:<name>' for a public disk image, or 'disk-id:<id>' for a "
|
| 236 |
+
"private one (building a disk image from a container image is a "
|
| 237 |
+
"separate, out-of-band ACA step)."
|
| 238 |
+
)
|
| 239 |
+
|
| 240 |
+
return image, None
|
| 241 |
+
|
| 242 |
+
|
| 243 |
+
def _exec_stdout(result: Any) -> str:
|
| 244 |
+
for attr in ("stdout", "result", "output"):
|
| 245 |
+
value = getattr(result, attr, None)
|
| 246 |
+
if value is not None:
|
| 247 |
+
return str(value)
|
| 248 |
+
if isinstance(result, dict):
|
| 249 |
+
for key in ("stdout", "result", "output"):
|
| 250 |
+
value = result.get(key)
|
| 251 |
+
if value is not None:
|
| 252 |
+
return str(value)
|
| 253 |
+
return str(result)
|
| 254 |
+
|
| 255 |
+
|
| 256 |
+
class ACASandboxProvider(ContainerProvider):
|
| 257 |
+
"""Container provider backed by Azure Container Apps Sandboxes.
|
| 258 |
+
|
| 259 |
+
`start_container`'s `image` is an ACA sandbox source (`disk:<name>` for a
|
| 260 |
+
public disk image or `disk-id:<id>` for a private one), not a Docker
|
| 261 |
+
registry image. The provider boots an OpenEnv server inside the sandbox,
|
| 262 |
+
exposes it on an anonymous ACA port, and returns an `https://` URL that
|
| 263 |
+
`EnvClient` connects to over `wss://`.
|
| 264 |
+
|
| 265 |
+
The environment runs untrusted code, so the provider is secure by default
|
| 266 |
+
(proposed RFC 002 security invariants): it requires explicit `anonymous_port=True`
|
| 267 |
+
ingress (S2), enforces https/wss transport (S1), offers
|
| 268 |
+
`deny_all_egress()` to block the cloud metadata/IMDS endpoint (S3), and
|
| 269 |
+
never surfaces raw sandbox output unless `surface_server_logs=True` (S4).
|
| 270 |
+
|
| 271 |
+
Validated end to end against a live ACA sandbox group; the underlying
|
| 272 |
+
`azure-containerapps-sandbox` SDK is preview, so pin it and re-validate
|
| 273 |
+
after upgrades (see `tests/test_core/test_aca_provider_integration.py`).
|
| 274 |
+
|
| 275 |
+
`close()` stops the active sandbox *and* releases the underlying SDK client,
|
| 276 |
+
so prefer using the provider as a context manager (or call `close()`
|
| 277 |
+
explicitly) for deterministic cleanup. `close()`/`__enter__`/`__exit__` are
|
| 278 |
+
defined on `ContainerProvider` (default no-op), so a caller holding a bare
|
| 279 |
+
`ContainerProvider` reference can release the client polymorphically.
|
| 280 |
+
|
| 281 |
+
Only one sandbox is active per provider: calling `start_container` again
|
| 282 |
+
before `stop_container()`/`close()` raises `RuntimeError` rather than
|
| 283 |
+
orphaning the running sandbox.
|
| 284 |
+
|
| 285 |
+
```python
|
| 286 |
+
with ACASandboxProvider(
|
| 287 |
+
image="disk:my-env",
|
| 288 |
+
anonymous_port=True,
|
| 289 |
+
...,
|
| 290 |
+
) as provider:
|
| 291 |
+
base_url = provider.start_container(cmd=...)
|
| 292 |
+
...
|
| 293 |
+
# sandbox deleted and SDK client closed on exit
|
| 294 |
+
```
|
| 295 |
+
"""
|
| 296 |
+
|
| 297 |
+
def __init__(
|
| 298 |
+
self,
|
| 299 |
+
*,
|
| 300 |
+
subscription_id: Optional[str] = None,
|
| 301 |
+
resource_group: Optional[str] = None,
|
| 302 |
+
sandbox_group: Optional[str] = None,
|
| 303 |
+
region: Optional[str] = None,
|
| 304 |
+
endpoint: Optional[str] = None,
|
| 305 |
+
credential: Any = None,
|
| 306 |
+
cpu: str = "1000m",
|
| 307 |
+
memory: str = "2048Mi",
|
| 308 |
+
disk_size: Optional[str] = None,
|
| 309 |
+
auto_suspend_seconds: int = _DEFAULT_AUTO_SUSPEND_SECONDS,
|
| 310 |
+
auto_suspend_mode: Literal["Memory", "Disk"] = "Memory",
|
| 311 |
+
labels: Optional[Dict[str, str]] = None,
|
| 312 |
+
egress_policy: Any = None,
|
| 313 |
+
anonymous_port: Literal[True] | None = None,
|
| 314 |
+
image: Optional[str] = None,
|
| 315 |
+
env_vars: Optional[Dict[str, str]] = None,
|
| 316 |
+
cmd: Optional[str] = None,
|
| 317 |
+
working_directory: Optional[str] = None,
|
| 318 |
+
surface_server_logs: bool = False,
|
| 319 |
+
create_timeout: int = 300,
|
| 320 |
+
polling_interval: int = 3,
|
| 321 |
+
sdk_kwargs: Optional[dict[str, Any]] = None,
|
| 322 |
+
_adapter: Any = None,
|
| 323 |
+
):
|
| 324 |
+
if auto_suspend_mode not in _VALID_SUSPEND_MODES:
|
| 325 |
+
raise ValueError("auto_suspend_mode must be 'Memory' or 'Disk'")
|
| 326 |
+
_validate_anonymous_port(anonymous_port)
|
| 327 |
+
|
| 328 |
+
self.subscription_id = subscription_id or os.environ.get(
|
| 329 |
+
"AZURE_SUBSCRIPTION_ID"
|
| 330 |
+
)
|
| 331 |
+
self.resource_group = resource_group or os.environ.get("AZURE_RESOURCE_GROUP")
|
| 332 |
+
self.sandbox_group = sandbox_group or os.environ.get("AZURE_SANDBOX_GROUP")
|
| 333 |
+
self.region = region or os.environ.get("AZURE_REGION")
|
| 334 |
+
self.endpoint = endpoint
|
| 335 |
+
|
| 336 |
+
self.cpu = cpu
|
| 337 |
+
self.memory = memory
|
| 338 |
+
self.disk_size = disk_size
|
| 339 |
+
self.auto_suspend_seconds = auto_suspend_seconds
|
| 340 |
+
self.auto_suspend_mode = auto_suspend_mode
|
| 341 |
+
self.labels = dict(labels or {})
|
| 342 |
+
self.egress_policy = egress_policy
|
| 343 |
+
self.anonymous_port = anonymous_port
|
| 344 |
+
self._image = image
|
| 345 |
+
self._env_vars = dict(env_vars or {}) if env_vars is not None else None
|
| 346 |
+
self.cmd = cmd
|
| 347 |
+
self.working_directory = working_directory
|
| 348 |
+
self.surface_server_logs = surface_server_logs
|
| 349 |
+
self.create_timeout = create_timeout
|
| 350 |
+
self.polling_interval = polling_interval
|
| 351 |
+
|
| 352 |
+
if _adapter is None:
|
| 353 |
+
self._adapter = _DefaultACASandboxAdapter(
|
| 354 |
+
subscription_id=_resolve_required(
|
| 355 |
+
self.subscription_id, "AZURE_SUBSCRIPTION_ID", "subscription_id"
|
| 356 |
+
),
|
| 357 |
+
resource_group=_resolve_required(
|
| 358 |
+
self.resource_group, "AZURE_RESOURCE_GROUP", "resource_group"
|
| 359 |
+
),
|
| 360 |
+
sandbox_group=_resolve_required(
|
| 361 |
+
self.sandbox_group, "AZURE_SANDBOX_GROUP", "sandbox_group"
|
| 362 |
+
),
|
| 363 |
+
region=self.region,
|
| 364 |
+
endpoint=self.endpoint,
|
| 365 |
+
credential=credential,
|
| 366 |
+
sdk_kwargs=dict(sdk_kwargs or {}),
|
| 367 |
+
)
|
| 368 |
+
self._owns_adapter = True
|
| 369 |
+
else:
|
| 370 |
+
self._adapter = _adapter
|
| 371 |
+
self._owns_adapter = False
|
| 372 |
+
|
| 373 |
+
self._sandbox: Any = None
|
| 374 |
+
self._base_url: Optional[str] = None
|
| 375 |
+
self._port = _DEFAULT_ACA_PORT
|
| 376 |
+
self._started_server = False
|
| 377 |
+
# Injected env-var values, used to scrub captured server output before
|
| 378 |
+
# it is ever surfaced in an error (RFC 002 security invariant S4).
|
| 379 |
+
self._redact_values: set[str] = set()
|
| 380 |
+
|
| 381 |
+
@staticmethod
|
| 382 |
+
def deny_all_egress(allow: Optional[list[str]] = None) -> Any:
|
| 383 |
+
"""Build a default-deny ACA `EgressPolicy` (RFC 002 security invariant S3).
|
| 384 |
+
|
| 385 |
+
Untrusted RL/agent code should not be able to exfiltrate data or reach
|
| 386 |
+
the cloud metadata/IMDS endpoint. This returns an `EgressPolicy` whose
|
| 387 |
+
default action is `Deny`, with an optional host allowlist (for example a
|
| 388 |
+
model endpoint or package registry the environment legitimately needs).
|
| 389 |
+
|
| 390 |
+
```python
|
| 391 |
+
provider = ACASandboxProvider(
|
| 392 |
+
anonymous_port=True,
|
| 393 |
+
egress_policy=ACASandboxProvider.deny_all_egress(
|
| 394 |
+
allow=["my-model.openai.azure.com"]
|
| 395 |
+
),
|
| 396 |
+
)
|
| 397 |
+
```
|
| 398 |
+
"""
|
| 399 |
+
try:
|
| 400 |
+
from azure.containerapps.sandbox import EgressHostRule, EgressPolicy
|
| 401 |
+
except ImportError as exc: # pragma: no cover - exercised via provider tests
|
| 402 |
+
_raise_install_error(exc)
|
| 403 |
+
|
| 404 |
+
host_rules = [
|
| 405 |
+
EgressHostRule(pattern=pattern, action="Allow") for pattern in (allow or [])
|
| 406 |
+
]
|
| 407 |
+
return EgressPolicy(default_action="Deny", host_rules=host_rules)
|
| 408 |
+
|
| 409 |
+
def start_container(
|
| 410 |
+
self,
|
| 411 |
+
image: Optional[str] = None,
|
| 412 |
+
port: Optional[int] = None,
|
| 413 |
+
env_vars: Optional[Dict[str, str]] = None,
|
| 414 |
+
**kwargs: Any,
|
| 415 |
+
) -> str:
|
| 416 |
+
"""Start an OpenEnv server in an ACA sandbox and return its base URL.
|
| 417 |
+
|
| 418 |
+
`image` is an ACA sandbox *source*, not a Docker/OCI registry image: a
|
| 419 |
+
bare string or `disk:<name>` for a public disk image, or `disk-id:<id>`
|
| 420 |
+
for a private one. It may be omitted when supplied to the constructor.
|
| 421 |
+
A value that looks like a container image (a registry path or
|
| 422 |
+
`name:tag`) is rejected with guidance. Only port 8000 is supported.
|
| 423 |
+
`**kwargs` accepts per-start overrides (`cmd`, `labels`,
|
| 424 |
+
`egress_policy`, `anonymous_port`); unknown options raise `ValueError`
|
| 425 |
+
so typos cannot silently change sandbox behavior.
|
| 426 |
+
"""
|
| 427 |
+
if self._sandbox is not None:
|
| 428 |
+
raise RuntimeError(
|
| 429 |
+
"ACASandboxProvider already has an active sandbox. Call "
|
| 430 |
+
"stop_container() (or close()) before starting another — a "
|
| 431 |
+
"second start would orphan the running sandbox."
|
| 432 |
+
)
|
| 433 |
+
|
| 434 |
+
bind_port = port or _DEFAULT_ACA_PORT
|
| 435 |
+
if bind_port != _DEFAULT_ACA_PORT:
|
| 436 |
+
raise ValueError(
|
| 437 |
+
f"ACASandboxProvider only supports port {_DEFAULT_ACA_PORT} "
|
| 438 |
+
f"(got {bind_port})."
|
| 439 |
+
)
|
| 440 |
+
|
| 441 |
+
effective_image = image if image is not None else self._image
|
| 442 |
+
if effective_image is None:
|
| 443 |
+
raise ValueError(
|
| 444 |
+
"ACASandboxProvider requires an image. Pass it to the constructor "
|
| 445 |
+
"or start_container()."
|
| 446 |
+
)
|
| 447 |
+
effective_env_vars = self._env_vars if env_vars is None else env_vars
|
| 448 |
+
|
| 449 |
+
disk, disk_id = _source_from_image(effective_image)
|
| 450 |
+
labels = dict(self.labels)
|
| 451 |
+
labels.update(kwargs.pop("labels", {}) or {})
|
| 452 |
+
cmd = kwargs.pop("cmd", None) or self.cmd
|
| 453 |
+
egress_policy = kwargs.pop("egress_policy", self.egress_policy)
|
| 454 |
+
anonymous_port = kwargs.pop("anonymous_port", self.anonymous_port)
|
| 455 |
+
# Re-validate in case anonymous_port was overridden at start time: public
|
| 456 |
+
# exposure must stay an explicit, opt-in decision (S2).
|
| 457 |
+
_validate_anonymous_port(anonymous_port)
|
| 458 |
+
|
| 459 |
+
if kwargs:
|
| 460 |
+
unknown = ", ".join(sorted(kwargs))
|
| 461 |
+
raise ValueError(f"Unsupported ACASandboxProvider start options: {unknown}")
|
| 462 |
+
|
| 463 |
+
# Security (RFC 002 S3): the env server runs untrusted code and ACA's
|
| 464 |
+
# EgressPolicy defaults to default_action="Allow", so an unset policy
|
| 465 |
+
# means unrestricted egress — the workload could exfiltrate data or
|
| 466 |
+
# steal the sandbox managed-identity token from the cloud metadata
|
| 467 |
+
# endpoint. Warn loudly; do not silently allow it.
|
| 468 |
+
if egress_policy is None:
|
| 469 |
+
warnings.warn(
|
| 470 |
+
"ACASandboxProvider is starting a sandbox with unrestricted "
|
| 471 |
+
"egress (no egress_policy). The environment runs untrusted code; "
|
| 472 |
+
"prefer a default-deny policy via "
|
| 473 |
+
"ACASandboxProvider.deny_all_egress(allow=[...]) and ensure the "
|
| 474 |
+
"cloud metadata/IMDS endpoint is blocked.",
|
| 475 |
+
stacklevel=2,
|
| 476 |
+
)
|
| 477 |
+
|
| 478 |
+
# Record injected secret values so captured server output can be scrubbed
|
| 479 |
+
# before it is ever surfaced in an error (security invariant S4).
|
| 480 |
+
self._redact_values = {
|
| 481 |
+
value for value in (effective_env_vars or {}).values() if value
|
| 482 |
+
}
|
| 483 |
+
|
| 484 |
+
# Two-stage cleanup. A create failure created nothing, so just drop the
|
| 485 |
+
# recorded secrets and re-raise (notably it must NOT delete a sandbox
|
| 486 |
+
# this call did not create — e.g. one left over on the provider). Only a
|
| 487 |
+
# failure AFTER the sandbox exists runs stop_container() to delete it.
|
| 488 |
+
try:
|
| 489 |
+
self._sandbox = self._adapter.create_sandbox(
|
| 490 |
+
disk=disk,
|
| 491 |
+
disk_id=disk_id,
|
| 492 |
+
env_vars=dict(effective_env_vars or {}) if effective_env_vars else None,
|
| 493 |
+
labels=labels or None,
|
| 494 |
+
egress_policy=egress_policy,
|
| 495 |
+
cpu=self.cpu,
|
| 496 |
+
memory=self.memory,
|
| 497 |
+
disk_size=self.disk_size,
|
| 498 |
+
auto_suspend_seconds=self.auto_suspend_seconds,
|
| 499 |
+
auto_suspend_mode=self.auto_suspend_mode,
|
| 500 |
+
create_timeout=self.create_timeout,
|
| 501 |
+
polling_interval=self.polling_interval,
|
| 502 |
+
)
|
| 503 |
+
except Exception:
|
| 504 |
+
self._redact_values = set()
|
| 505 |
+
raise
|
| 506 |
+
|
| 507 |
+
try:
|
| 508 |
+
if cmd:
|
| 509 |
+
self._start_server(cmd)
|
| 510 |
+
self._base_url = _require_secure_url(
|
| 511 |
+
self._adapter.add_port(
|
| 512 |
+
self._sandbox,
|
| 513 |
+
port=bind_port,
|
| 514 |
+
anonymous=anonymous_port,
|
| 515 |
+
)
|
| 516 |
+
)
|
| 517 |
+
self._port = bind_port
|
| 518 |
+
except Exception:
|
| 519 |
+
# A cleanup failure here must not mask the original error: swallow
|
| 520 |
+
# any exception from stop_container() so the root cause propagates.
|
| 521 |
+
try:
|
| 522 |
+
self.stop_container()
|
| 523 |
+
except Exception:
|
| 524 |
+
pass
|
| 525 |
+
raise
|
| 526 |
+
|
| 527 |
+
return self._base_url
|
| 528 |
+
|
| 529 |
+
def _start_server(self, cmd: str) -> None:
|
| 530 |
+
# `cmd` is trusted orchestrator configuration, not agent/environment
|
| 531 |
+
# input — callers must not pass agent-controlled text as `cmd`, and any
|
| 532 |
+
# dynamic value interpolated into it must be quoted by the caller. Given
|
| 533 |
+
# that, running it through the subshell below is safe (RFC 002 amendment
|
| 534 |
+
# S5 — no command injection from untrusted sources). `working_directory`
|
| 535 |
+
# is still shlex-quoted defensively.
|
| 536 |
+
if self.working_directory:
|
| 537 |
+
command = f"cd {shlex.quote(self.working_directory)} && {cmd}"
|
| 538 |
+
else:
|
| 539 |
+
command = cmd
|
| 540 |
+
escaped = shlex.quote(command)
|
| 541 |
+
# The adapter's `exec` maps to the ACA SDK's `executeShellCommand`
|
| 542 |
+
# endpoint, which runs its argument through a POSIX shell in the sandbox
|
| 543 |
+
# (the SDK docstring says "Execute a shell command" and tells callers to
|
| 544 |
+
# `shlex.quote()` interpolated input). That shell is what makes the
|
| 545 |
+
# backgrounding (`&`), output redirection, and `$!` PID capture below
|
| 546 |
+
# work; if a future preview SDK changes `exec` to bypass the shell, this
|
| 547 |
+
# start sequence must be revisited (re-validate via the integration test).
|
| 548 |
+
self._adapter.exec(
|
| 549 |
+
self._sandbox,
|
| 550 |
+
f"nohup bash -c {escaped} > /tmp/openenv-server.log 2>&1 & "
|
| 551 |
+
"echo $! > /tmp/openenv-server.pid",
|
| 552 |
+
)
|
| 553 |
+
self._started_server = True
|
| 554 |
+
|
| 555 |
+
def _redact(self, text: str, *, max_chars: int = 2000) -> str:
|
| 556 |
+
"""Scrub injected secret values and bound length before surfacing output.
|
| 557 |
+
|
| 558 |
+
Replaces any injected env-var value with `***` and keeps only the tail.
|
| 559 |
+
This is best-effort (exact-match only), which is why server output is
|
| 560 |
+
withheld entirely unless `surface_server_logs=True` (RFC 002 S4).
|
| 561 |
+
"""
|
| 562 |
+
redacted = text or ""
|
| 563 |
+
for value in self._redact_values:
|
| 564 |
+
redacted = redacted.replace(value, "***")
|
| 565 |
+
if len(redacted) > max_chars:
|
| 566 |
+
redacted = "...(truncated)...\n" + redacted[-max_chars:]
|
| 567 |
+
return redacted
|
| 568 |
+
|
| 569 |
+
def _server_died_message(self) -> str:
|
| 570 |
+
"""Build the startup-crash error, secure by default (RFC 002 S4).
|
| 571 |
+
|
| 572 |
+
Untrusted code can print secrets then force a crash to exfiltrate them
|
| 573 |
+
through the exception (which lands in orchestrator/CI logs), so sandbox
|
| 574 |
+
output is excluded unless `surface_server_logs=True`, in which case a
|
| 575 |
+
best-effort redacted, bounded excerpt is included.
|
| 576 |
+
"""
|
| 577 |
+
base = (
|
| 578 |
+
"ACA sandbox server process died during startup. Server output is "
|
| 579 |
+
"not surfaced to avoid leaking secrets injected into the sandbox; "
|
| 580 |
+
"retrieve /tmp/openenv-server.log from the sandbox out of band, or "
|
| 581 |
+
"construct the provider with surface_server_logs=True to include a "
|
| 582 |
+
"redacted excerpt."
|
| 583 |
+
)
|
| 584 |
+
if not self.surface_server_logs:
|
| 585 |
+
return base
|
| 586 |
+
if self._sandbox is None:
|
| 587 |
+
return base
|
| 588 |
+
log = self._redact(
|
| 589 |
+
_exec_stdout(
|
| 590 |
+
self._adapter.exec(
|
| 591 |
+
self._sandbox,
|
| 592 |
+
"cat /tmp/openenv-server.log 2>/dev/null",
|
| 593 |
+
)
|
| 594 |
+
)
|
| 595 |
+
)
|
| 596 |
+
return (
|
| 597 |
+
"ACA sandbox server process died during startup. The excerpt below "
|
| 598 |
+
"is the sandbox server output with injected secret values redacted "
|
| 599 |
+
"(best-effort); it may still contain secrets the workload printed "
|
| 600 |
+
f"by other means.\nLog (redacted):\n{log}"
|
| 601 |
+
)
|
| 602 |
+
|
| 603 |
+
def wait_for_ready(self, base_url: str, timeout_s: float = 120.0) -> None:
|
| 604 |
+
"""Wait for the ACA-hosted OpenEnv server to answer on `/health`.
|
| 605 |
+
|
| 606 |
+
A `200` on `/health` proves HTTP reachability but not that the exposed
|
| 607 |
+
ACA port proxies the `/ws` WebSocket upgrade `EnvClient` needs; the
|
| 608 |
+
integration test covers the full `wss://` round-trip. If the captured
|
| 609 |
+
server process dies during startup this raises `RuntimeError` (sandbox
|
| 610 |
+
output is withheld unless `surface_server_logs=True`; see RFC 002 S4).
|
| 611 |
+
"""
|
| 612 |
+
# Imported lazily inside the method that needs it, matching the other
|
| 613 |
+
# providers in this package (e.g. DaytonaProvider).
|
| 614 |
+
import requests
|
| 615 |
+
|
| 616 |
+
deadline = time.time() + timeout_s
|
| 617 |
+
health_url = f"{base_url}/health"
|
| 618 |
+
|
| 619 |
+
while time.time() < deadline:
|
| 620 |
+
try:
|
| 621 |
+
response = requests.get(health_url, timeout=5.0)
|
| 622 |
+
if response.status_code == 200:
|
| 623 |
+
return
|
| 624 |
+
except requests.RequestException:
|
| 625 |
+
pass
|
| 626 |
+
|
| 627 |
+
if self._started_server and self._sandbox is not None:
|
| 628 |
+
status = _exec_stdout(
|
| 629 |
+
self._adapter.exec(
|
| 630 |
+
self._sandbox,
|
| 631 |
+
"kill -0 $(cat /tmp/openenv-server.pid) 2>/dev/null "
|
| 632 |
+
"&& echo RUNNING || echo DEAD",
|
| 633 |
+
)
|
| 634 |
+
)
|
| 635 |
+
if "DEAD" in status:
|
| 636 |
+
raise RuntimeError(self._server_died_message())
|
| 637 |
+
|
| 638 |
+
time.sleep(1.0)
|
| 639 |
+
|
| 640 |
+
raise TimeoutError(
|
| 641 |
+
f"ACA sandbox did not become ready within {timeout_s}s. "
|
| 642 |
+
"If the sandbox image starts the server itself, pass cmd= to let "
|
| 643 |
+
"ACASandboxProvider capture /tmp/openenv-server.log for early crash "
|
| 644 |
+
"diagnostics."
|
| 645 |
+
)
|
| 646 |
+
|
| 647 |
+
def stop_container(self) -> None:
|
| 648 |
+
"""Delete the active ACA sandbox."""
|
| 649 |
+
if self._sandbox is None:
|
| 650 |
+
# Still drop any injected secret values recorded by a failed start
|
| 651 |
+
# (e.g. create_sandbox raised before a sandbox handle existed).
|
| 652 |
+
self._redact_values = set()
|
| 653 |
+
return
|
| 654 |
+
|
| 655 |
+
try:
|
| 656 |
+
self._adapter.delete_sandbox(self._sandbox)
|
| 657 |
+
finally:
|
| 658 |
+
self._sandbox = None
|
| 659 |
+
self._base_url = None
|
| 660 |
+
self._started_server = False
|
| 661 |
+
# Drop the previous episode's injected secret values; a new start
|
| 662 |
+
# repopulates them (avoids carrying stale values across sandboxes).
|
| 663 |
+
self._redact_values = set()
|
| 664 |
+
|
| 665 |
+
@property
|
| 666 |
+
def base_url(self) -> str:
|
| 667 |
+
"""URL returned by the last `start_container`."""
|
| 668 |
+
if self._base_url is None:
|
| 669 |
+
raise RuntimeError(
|
| 670 |
+
"ACASandboxProvider has no active base_url. Start the provider "
|
| 671 |
+
"before reading base_url."
|
| 672 |
+
)
|
| 673 |
+
return self._base_url
|
| 674 |
+
|
| 675 |
+
def close(self) -> None:
|
| 676 |
+
"""Stop the active sandbox and close the underlying SDK client.
|
| 677 |
+
|
| 678 |
+
Overrides the base no-op so a caller can release the preview SDK client
|
| 679 |
+
deterministically (also invoked on context-manager exit). The base
|
| 680 |
+
`ContainerProvider` defines `close()`/`__enter__`/`__exit__`, so callers
|
| 681 |
+
holding a bare `ContainerProvider` can release resources polymorphically.
|
| 682 |
+
"""
|
| 683 |
+
self.stop_container()
|
| 684 |
+
if self._owns_adapter:
|
| 685 |
+
self._adapter.close()
|
| 686 |
+
# don't close a second time on a subsequent close()/context exit
|
| 687 |
+
self._owns_adapter = False
|
| 688 |
+
|
| 689 |
+
|
| 690 |
+
__all__ = ["ACASandboxProvider"]
|
src/core/containers/runtime/daytona_provider.py
CHANGED
|
@@ -1,8 +1,4 @@
|
|
| 1 |
-
#
|
| 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 |
Daytona container provider for running OpenEnv environments in Daytona cloud sandboxes.
|
|
@@ -12,14 +8,12 @@ Requires the ``daytona`` SDK: ``pip install daytona>=0.10``
|
|
| 12 |
|
| 13 |
from __future__ import annotations
|
| 14 |
|
| 15 |
-
import json
|
| 16 |
import os
|
| 17 |
import shlex
|
| 18 |
import time
|
| 19 |
from typing import Any, Callable, Dict, Optional
|
| 20 |
|
| 21 |
-
import
|
| 22 |
-
|
| 23 |
from .providers import ContainerProvider
|
| 24 |
|
| 25 |
|
|
@@ -40,6 +34,8 @@ class DaytonaProvider(ContainerProvider):
|
|
| 40 |
def __init__(
|
| 41 |
self,
|
| 42 |
*,
|
|
|
|
|
|
|
| 43 |
api_key: Optional[str] = None,
|
| 44 |
public: bool = False,
|
| 45 |
resources: Optional[Any] = None,
|
|
@@ -51,6 +47,11 @@ class DaytonaProvider(ContainerProvider):
|
|
| 51 |
):
|
| 52 |
"""
|
| 53 |
Args:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
api_key: Daytona API key. Falls back to ``DAYTONA_API_KEY`` env var.
|
| 55 |
public: If True, the sandbox preview is publicly accessible.
|
| 56 |
resources: Optional ``daytona.Resources`` instance for CPU/memory.
|
|
@@ -71,6 +72,8 @@ class DaytonaProvider(ContainerProvider):
|
|
| 71 |
config_kwargs["target"] = target
|
| 72 |
|
| 73 |
self._daytona = Daytona(DaytonaConfig(**config_kwargs))
|
|
|
|
|
|
|
| 74 |
self._public = public
|
| 75 |
self._resources = resources
|
| 76 |
self._auto_stop_interval = auto_stop_interval
|
|
@@ -144,19 +147,7 @@ class DaytonaProvider(ContainerProvider):
|
|
| 144 |
|
| 145 |
Uses PyYAML to handle comments, quotes, and nested keys correctly.
|
| 146 |
"""
|
| 147 |
-
|
| 148 |
-
data = yaml.safe_load(yaml_content) or {}
|
| 149 |
-
except Exception:
|
| 150 |
-
return None
|
| 151 |
-
|
| 152 |
-
if not isinstance(data, dict):
|
| 153 |
-
return None
|
| 154 |
-
|
| 155 |
-
value = data.get("app")
|
| 156 |
-
if isinstance(value, str):
|
| 157 |
-
value = value.strip()
|
| 158 |
-
return value if value else None
|
| 159 |
-
return None
|
| 160 |
|
| 161 |
@staticmethod
|
| 162 |
def _parse_dockerfile_cmd(dockerfile_content: str) -> Optional[str]:
|
|
@@ -171,32 +162,7 @@ class DaytonaProvider(ContainerProvider):
|
|
| 171 |
Returns:
|
| 172 |
The command as a single string, or ``None`` if no ``CMD`` found.
|
| 173 |
"""
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
last_cmd: Optional[str] = None
|
| 177 |
-
for line in dockerfile_content.splitlines():
|
| 178 |
-
stripped = line.strip()
|
| 179 |
-
# Skip comments
|
| 180 |
-
if stripped.startswith("#"):
|
| 181 |
-
continue
|
| 182 |
-
match = re.match(r"CMD\s+(.+)", stripped, flags=re.IGNORECASE)
|
| 183 |
-
if match:
|
| 184 |
-
last_cmd = match.group(1).strip()
|
| 185 |
-
|
| 186 |
-
if last_cmd is None:
|
| 187 |
-
return None
|
| 188 |
-
|
| 189 |
-
# Exec form: CMD ["executable", "param1", ...]
|
| 190 |
-
if last_cmd.startswith("["):
|
| 191 |
-
try:
|
| 192 |
-
parts = json.loads(last_cmd)
|
| 193 |
-
if isinstance(parts, list) and all(isinstance(p, str) for p in parts):
|
| 194 |
-
return " ".join(parts)
|
| 195 |
-
except (json.JSONDecodeError, TypeError):
|
| 196 |
-
pass
|
| 197 |
-
|
| 198 |
-
# Shell form: CMD executable param1 ...
|
| 199 |
-
return last_cmd if last_cmd else None
|
| 200 |
|
| 201 |
@staticmethod
|
| 202 |
def strip_buildkit_syntax(dockerfile_content: str) -> str:
|
|
@@ -349,7 +315,7 @@ class DaytonaProvider(ContainerProvider):
|
|
| 349 |
|
| 350 |
def start_container(
|
| 351 |
self,
|
| 352 |
-
image: str,
|
| 353 |
port: Optional[int] = None,
|
| 354 |
env_vars: Optional[Dict[str, str]] = None,
|
| 355 |
**kwargs: Any,
|
|
@@ -370,7 +336,8 @@ class DaytonaProvider(ContainerProvider):
|
|
| 370 |
image: Docker image name (e.g. ``"echo-env:latest"``),
|
| 371 |
``"snapshot:<name>"`` to create from a pre-built snapshot,
|
| 372 |
or ``"dockerfile:<path>"`` returned by
|
| 373 |
-
:meth:`image_from_dockerfile`.
|
|
|
|
| 374 |
port: Must be ``None`` or ``8000``. Daytona exposes port 8000
|
| 375 |
via its preview proxy; other ports raise ``ValueError``.
|
| 376 |
env_vars: Environment variables forwarded to the sandbox.
|
|
@@ -386,6 +353,14 @@ class DaytonaProvider(ContainerProvider):
|
|
| 386 |
"The Daytona preview proxy routes to port 8000 inside the sandbox."
|
| 387 |
)
|
| 388 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 389 |
# Resolve the server command (may be None; discovery happens after
|
| 390 |
# sandbox creation when we can inspect the filesystem).
|
| 391 |
cmd = kwargs.pop("cmd", None) or self._cmd
|
|
@@ -395,24 +370,24 @@ class DaytonaProvider(ContainerProvider):
|
|
| 395 |
|
| 396 |
# Build creation params
|
| 397 |
create_kwargs: Dict[str, Any] = {}
|
| 398 |
-
if
|
| 399 |
-
create_kwargs["env_vars"] =
|
| 400 |
if self._public:
|
| 401 |
create_kwargs["public"] = True
|
| 402 |
if self._auto_stop_interval != 15:
|
| 403 |
create_kwargs["auto_stop_interval"] = self._auto_stop_interval
|
| 404 |
|
| 405 |
-
if
|
| 406 |
from daytona import CreateSandboxFromSnapshotParams
|
| 407 |
|
| 408 |
-
snapshot_name =
|
| 409 |
params = CreateSandboxFromSnapshotParams(
|
| 410 |
snapshot=snapshot_name, **create_kwargs
|
| 411 |
)
|
| 412 |
-
elif
|
| 413 |
from daytona import CreateSandboxFromImageParams, Image
|
| 414 |
|
| 415 |
-
dockerfile_path =
|
| 416 |
meta = self._dockerfile_registry.get(dockerfile_path)
|
| 417 |
if meta is None:
|
| 418 |
raise ValueError(
|
|
@@ -445,7 +420,7 @@ class DaytonaProvider(ContainerProvider):
|
|
| 445 |
else:
|
| 446 |
from daytona import CreateSandboxFromImageParams
|
| 447 |
|
| 448 |
-
img_kwargs = {"image":
|
| 449 |
if self._resources is not None:
|
| 450 |
img_kwargs["resources"] = self._resources
|
| 451 |
params = CreateSandboxFromImageParams(**img_kwargs)
|
|
|
|
| 1 |
+
# SPDX-License-Identifier: BSD-3-Clause
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
"""
|
| 4 |
Daytona container provider for running OpenEnv environments in Daytona cloud sandboxes.
|
|
|
|
| 8 |
|
| 9 |
from __future__ import annotations
|
| 10 |
|
|
|
|
| 11 |
import os
|
| 12 |
import shlex
|
| 13 |
import time
|
| 14 |
from typing import Any, Callable, Dict, Optional
|
| 15 |
|
| 16 |
+
from ._server_config import parse_dockerfile_cmd, parse_openenv_app_field
|
|
|
|
| 17 |
from .providers import ContainerProvider
|
| 18 |
|
| 19 |
|
|
|
|
| 34 |
def __init__(
|
| 35 |
self,
|
| 36 |
*,
|
| 37 |
+
image: Optional[str] = None,
|
| 38 |
+
env_vars: Optional[Dict[str, str]] = None,
|
| 39 |
api_key: Optional[str] = None,
|
| 40 |
public: bool = False,
|
| 41 |
resources: Optional[Any] = None,
|
|
|
|
| 47 |
):
|
| 48 |
"""
|
| 49 |
Args:
|
| 50 |
+
image: Docker image, snapshot reference, or ``"dockerfile:<path>"``
|
| 51 |
+
source to use when ``start_container()`` is called without an
|
| 52 |
+
image.
|
| 53 |
+
env_vars: Environment variables to use when ``start_container()`` is
|
| 54 |
+
called without explicit ``env_vars``.
|
| 55 |
api_key: Daytona API key. Falls back to ``DAYTONA_API_KEY`` env var.
|
| 56 |
public: If True, the sandbox preview is publicly accessible.
|
| 57 |
resources: Optional ``daytona.Resources`` instance for CPU/memory.
|
|
|
|
| 72 |
config_kwargs["target"] = target
|
| 73 |
|
| 74 |
self._daytona = Daytona(DaytonaConfig(**config_kwargs))
|
| 75 |
+
self._image = image
|
| 76 |
+
self._env_vars = env_vars
|
| 77 |
self._public = public
|
| 78 |
self._resources = resources
|
| 79 |
self._auto_stop_interval = auto_stop_interval
|
|
|
|
| 147 |
|
| 148 |
Uses PyYAML to handle comments, quotes, and nested keys correctly.
|
| 149 |
"""
|
| 150 |
+
return parse_openenv_app_field(yaml_content)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 151 |
|
| 152 |
@staticmethod
|
| 153 |
def _parse_dockerfile_cmd(dockerfile_content: str) -> Optional[str]:
|
|
|
|
| 162 |
Returns:
|
| 163 |
The command as a single string, or ``None`` if no ``CMD`` found.
|
| 164 |
"""
|
| 165 |
+
return parse_dockerfile_cmd(dockerfile_content)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 166 |
|
| 167 |
@staticmethod
|
| 168 |
def strip_buildkit_syntax(dockerfile_content: str) -> str:
|
|
|
|
| 315 |
|
| 316 |
def start_container(
|
| 317 |
self,
|
| 318 |
+
image: Optional[str] = None,
|
| 319 |
port: Optional[int] = None,
|
| 320 |
env_vars: Optional[Dict[str, str]] = None,
|
| 321 |
**kwargs: Any,
|
|
|
|
| 336 |
image: Docker image name (e.g. ``"echo-env:latest"``),
|
| 337 |
``"snapshot:<name>"`` to create from a pre-built snapshot,
|
| 338 |
or ``"dockerfile:<path>"`` returned by
|
| 339 |
+
:meth:`image_from_dockerfile`. May be omitted when supplied
|
| 340 |
+
to the constructor.
|
| 341 |
port: Must be ``None`` or ``8000``. Daytona exposes port 8000
|
| 342 |
via its preview proxy; other ports raise ``ValueError``.
|
| 343 |
env_vars: Environment variables forwarded to the sandbox.
|
|
|
|
| 353 |
"The Daytona preview proxy routes to port 8000 inside the sandbox."
|
| 354 |
)
|
| 355 |
|
| 356 |
+
effective_image = image if image is not None else self._image
|
| 357 |
+
if effective_image is None:
|
| 358 |
+
raise ValueError(
|
| 359 |
+
"DaytonaProvider requires an image. Pass it to the constructor "
|
| 360 |
+
"or start_container()."
|
| 361 |
+
)
|
| 362 |
+
effective_env_vars = self._env_vars if env_vars is None else env_vars
|
| 363 |
+
|
| 364 |
# Resolve the server command (may be None; discovery happens after
|
| 365 |
# sandbox creation when we can inspect the filesystem).
|
| 366 |
cmd = kwargs.pop("cmd", None) or self._cmd
|
|
|
|
| 370 |
|
| 371 |
# Build creation params
|
| 372 |
create_kwargs: Dict[str, Any] = {}
|
| 373 |
+
if effective_env_vars:
|
| 374 |
+
create_kwargs["env_vars"] = effective_env_vars
|
| 375 |
if self._public:
|
| 376 |
create_kwargs["public"] = True
|
| 377 |
if self._auto_stop_interval != 15:
|
| 378 |
create_kwargs["auto_stop_interval"] = self._auto_stop_interval
|
| 379 |
|
| 380 |
+
if effective_image.startswith("snapshot:"):
|
| 381 |
from daytona import CreateSandboxFromSnapshotParams
|
| 382 |
|
| 383 |
+
snapshot_name = effective_image[len("snapshot:") :]
|
| 384 |
params = CreateSandboxFromSnapshotParams(
|
| 385 |
snapshot=snapshot_name, **create_kwargs
|
| 386 |
)
|
| 387 |
+
elif effective_image.startswith("dockerfile:"):
|
| 388 |
from daytona import CreateSandboxFromImageParams, Image
|
| 389 |
|
| 390 |
+
dockerfile_path = effective_image[len("dockerfile:") :]
|
| 391 |
meta = self._dockerfile_registry.get(dockerfile_path)
|
| 392 |
if meta is None:
|
| 393 |
raise ValueError(
|
|
|
|
| 420 |
else:
|
| 421 |
from daytona import CreateSandboxFromImageParams
|
| 422 |
|
| 423 |
+
img_kwargs = {"image": effective_image, **create_kwargs}
|
| 424 |
if self._resources is not None:
|
| 425 |
img_kwargs["resources"] = self._resources
|
| 426 |
params = CreateSandboxFromImageParams(**img_kwargs)
|
src/core/containers/runtime/hf_sandbox_provider.py
ADDED
|
@@ -0,0 +1,342 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
"""Hugging Face-backed provider for OpenEnv environment servers."""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import asyncio
|
| 12 |
+
import hashlib
|
| 13 |
+
import socket
|
| 14 |
+
import threading
|
| 15 |
+
import time
|
| 16 |
+
from contextlib import suppress
|
| 17 |
+
from typing import Any
|
| 18 |
+
|
| 19 |
+
import requests
|
| 20 |
+
from fastapi import FastAPI, Request, Response, WebSocket, WebSocketDisconnect
|
| 21 |
+
from websockets.asyncio.client import connect as ws_connect
|
| 22 |
+
from websockets.exceptions import ConnectionClosed
|
| 23 |
+
|
| 24 |
+
from .providers import ContainerProvider
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
_DEFAULT_PORT = 8000
|
| 28 |
+
_SERVER_COMMAND = (
|
| 29 |
+
'export SBX_PROXY_DIR="${SBX_PROXY_DIR:-$HOME/.sbx/proxy}"; '
|
| 30 |
+
'mkdir -p "$SBX_PROXY_DIR"; '
|
| 31 |
+
'nohup server >"$HOME/openenv-server.log" 2>&1 &'
|
| 32 |
+
)
|
| 33 |
+
_MAX_WS_MESSAGE_SIZE = 100 * 1024 * 1024
|
| 34 |
+
_HOP_BY_HOP_HEADERS = {
|
| 35 |
+
"connection",
|
| 36 |
+
"content-encoding",
|
| 37 |
+
"content-length",
|
| 38 |
+
"host",
|
| 39 |
+
"keep-alive",
|
| 40 |
+
"proxy-authenticate",
|
| 41 |
+
"proxy-authorization",
|
| 42 |
+
"te",
|
| 43 |
+
"trailer",
|
| 44 |
+
"transfer-encoding",
|
| 45 |
+
"upgrade",
|
| 46 |
+
}
|
| 47 |
+
_POOLS: dict[tuple[str, str], Any] = {}
|
| 48 |
+
_POOL_LOCK = threading.Lock()
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def _find_available_port() -> int:
|
| 52 |
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
| 53 |
+
sock.bind(("127.0.0.1", 0))
|
| 54 |
+
sock.listen(1)
|
| 55 |
+
return sock.getsockname()[1]
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def _to_ws_url(url: str) -> str:
|
| 59 |
+
if url.startswith("https://"):
|
| 60 |
+
return "wss://" + url[len("https://") :]
|
| 61 |
+
if url.startswith("http://"):
|
| 62 |
+
return "ws://" + url[len("http://") :]
|
| 63 |
+
return url
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def _pool_name(image: str, flavor: str) -> str:
|
| 67 |
+
digest = hashlib.sha1(f"{image}\0{flavor}".encode("utf-8")).hexdigest()[:12]
|
| 68 |
+
return f"openenv-{digest}"
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def _get_sandbox_pool_cls() -> Any:
|
| 72 |
+
try:
|
| 73 |
+
from huggingface_hub import SandboxPool
|
| 74 |
+
except ImportError as exc:
|
| 75 |
+
raise RuntimeError(
|
| 76 |
+
"HFSandboxProvider requires a huggingface_hub version with "
|
| 77 |
+
"SandboxPool.create and Sandbox.proxy_url_for support."
|
| 78 |
+
) from exc
|
| 79 |
+
return SandboxPool
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def _get_pool(image: str, flavor: str) -> Any:
|
| 83 |
+
key = (image, flavor)
|
| 84 |
+
with _POOL_LOCK:
|
| 85 |
+
pool = _POOLS.get(key)
|
| 86 |
+
if pool is None:
|
| 87 |
+
SandboxPool = _get_sandbox_pool_cls()
|
| 88 |
+
pool = SandboxPool(
|
| 89 |
+
image=image,
|
| 90 |
+
flavor=flavor,
|
| 91 |
+
name=_pool_name(image, flavor),
|
| 92 |
+
)
|
| 93 |
+
_POOLS[key] = pool
|
| 94 |
+
return pool
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
class _LocalAuthProxy:
|
| 98 |
+
def __init__(self, *, target_url: str, headers: dict[str, str]):
|
| 99 |
+
self.target_url = target_url.rstrip("/")
|
| 100 |
+
self.headers = headers
|
| 101 |
+
self.port = _find_available_port()
|
| 102 |
+
self._server: Any = None
|
| 103 |
+
self._thread: threading.Thread | None = None
|
| 104 |
+
|
| 105 |
+
@property
|
| 106 |
+
def base_url(self) -> str:
|
| 107 |
+
return f"http://127.0.0.1:{self.port}"
|
| 108 |
+
|
| 109 |
+
def start(self) -> str:
|
| 110 |
+
app = FastAPI()
|
| 111 |
+
|
| 112 |
+
@app.api_route(
|
| 113 |
+
"/{path:path}",
|
| 114 |
+
methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
| 115 |
+
)
|
| 116 |
+
async def proxy_http(path: str, request: Request) -> Response:
|
| 117 |
+
query = request.url.query
|
| 118 |
+
target = f"{self.target_url}/{path}"
|
| 119 |
+
if query:
|
| 120 |
+
target = f"{target}?{query}"
|
| 121 |
+
headers = dict(self.headers)
|
| 122 |
+
content_type = request.headers.get("content-type")
|
| 123 |
+
if content_type is not None:
|
| 124 |
+
headers["Content-Type"] = content_type
|
| 125 |
+
body = await request.body()
|
| 126 |
+
try:
|
| 127 |
+
upstream = await asyncio.to_thread(
|
| 128 |
+
requests.request,
|
| 129 |
+
request.method,
|
| 130 |
+
target,
|
| 131 |
+
data=body,
|
| 132 |
+
headers=headers,
|
| 133 |
+
timeout=60.0,
|
| 134 |
+
allow_redirects=True,
|
| 135 |
+
)
|
| 136 |
+
except requests.RequestException:
|
| 137 |
+
return Response(
|
| 138 |
+
content=b"upstream HF job unreachable",
|
| 139 |
+
status_code=502,
|
| 140 |
+
)
|
| 141 |
+
response_headers = {
|
| 142 |
+
key: value
|
| 143 |
+
for key, value in upstream.headers.items()
|
| 144 |
+
if key.lower() not in _HOP_BY_HOP_HEADERS
|
| 145 |
+
}
|
| 146 |
+
return Response(
|
| 147 |
+
content=upstream.content,
|
| 148 |
+
status_code=upstream.status_code,
|
| 149 |
+
headers=response_headers,
|
| 150 |
+
)
|
| 151 |
+
|
| 152 |
+
@app.websocket("/{path:path}")
|
| 153 |
+
async def proxy_websocket(path: str, websocket: WebSocket) -> None:
|
| 154 |
+
query = websocket.url.query
|
| 155 |
+
target = f"{_to_ws_url(self.target_url)}/{path}"
|
| 156 |
+
if query:
|
| 157 |
+
target = f"{target}?{query}"
|
| 158 |
+
headers = {**self.headers, "accept": "*/*"}
|
| 159 |
+
upstream = await self._connect_upstream_websocket(target, headers)
|
| 160 |
+
await websocket.accept()
|
| 161 |
+
try:
|
| 162 |
+
to_upstream = asyncio.create_task(
|
| 163 |
+
self._client_to_upstream(websocket, upstream)
|
| 164 |
+
)
|
| 165 |
+
to_client = asyncio.create_task(
|
| 166 |
+
self._upstream_to_client(websocket, upstream)
|
| 167 |
+
)
|
| 168 |
+
done, pending = await asyncio.wait(
|
| 169 |
+
{to_upstream, to_client},
|
| 170 |
+
return_when=asyncio.FIRST_COMPLETED,
|
| 171 |
+
)
|
| 172 |
+
for task in pending:
|
| 173 |
+
task.cancel()
|
| 174 |
+
for task in done:
|
| 175 |
+
with suppress(ConnectionClosed, WebSocketDisconnect):
|
| 176 |
+
task.result()
|
| 177 |
+
finally:
|
| 178 |
+
with suppress(Exception):
|
| 179 |
+
await upstream.close()
|
| 180 |
+
|
| 181 |
+
import uvicorn
|
| 182 |
+
|
| 183 |
+
config = uvicorn.Config(
|
| 184 |
+
app,
|
| 185 |
+
host="127.0.0.1",
|
| 186 |
+
port=self.port,
|
| 187 |
+
log_level="warning",
|
| 188 |
+
access_log=False,
|
| 189 |
+
)
|
| 190 |
+
self._server = uvicorn.Server(config)
|
| 191 |
+
self._thread = threading.Thread(target=self._server.run, daemon=True)
|
| 192 |
+
self._thread.start()
|
| 193 |
+
while not self._server.started:
|
| 194 |
+
if not self._thread.is_alive():
|
| 195 |
+
raise RuntimeError("HF sandbox auth proxy failed to start")
|
| 196 |
+
time.sleep(0.05)
|
| 197 |
+
return self.base_url
|
| 198 |
+
|
| 199 |
+
async def _connect_upstream_websocket(
|
| 200 |
+
self, target: str, headers: dict[str, str]
|
| 201 |
+
) -> Any:
|
| 202 |
+
last_error: Exception | None = None
|
| 203 |
+
for _ in range(5):
|
| 204 |
+
try:
|
| 205 |
+
return await ws_connect(
|
| 206 |
+
target,
|
| 207 |
+
additional_headers=headers,
|
| 208 |
+
max_size=_MAX_WS_MESSAGE_SIZE,
|
| 209 |
+
compression=None,
|
| 210 |
+
)
|
| 211 |
+
except Exception as exc:
|
| 212 |
+
last_error = exc
|
| 213 |
+
await asyncio.sleep(0.5)
|
| 214 |
+
assert last_error is not None
|
| 215 |
+
raise last_error
|
| 216 |
+
|
| 217 |
+
async def _client_to_upstream(self, websocket: WebSocket, upstream: Any) -> None:
|
| 218 |
+
# EnvClient sends JSON text frames; binary frames are only relayed downstream.
|
| 219 |
+
async for message in websocket.iter_text():
|
| 220 |
+
await upstream.send(message)
|
| 221 |
+
|
| 222 |
+
async def _upstream_to_client(self, websocket: WebSocket, upstream: Any) -> None:
|
| 223 |
+
async for message in upstream:
|
| 224 |
+
if isinstance(message, bytes):
|
| 225 |
+
await websocket.send_bytes(message)
|
| 226 |
+
else:
|
| 227 |
+
await websocket.send_text(message)
|
| 228 |
+
|
| 229 |
+
def stop(self) -> None:
|
| 230 |
+
if self._server is None or self._thread is None:
|
| 231 |
+
return
|
| 232 |
+
self._server.should_exit = True
|
| 233 |
+
self._thread.join(timeout=5.0)
|
| 234 |
+
self._server = None
|
| 235 |
+
self._thread = None
|
| 236 |
+
|
| 237 |
+
|
| 238 |
+
class HFSandboxProvider(ContainerProvider):
|
| 239 |
+
"""Run an OpenEnv server on Hugging Face infrastructure.
|
| 240 |
+
|
| 241 |
+
The server runs in a `huggingface_hub` sandbox pool, which bills its hosts as
|
| 242 |
+
Hugging Face Jobs. `start_container()` returns a local proxy URL that adds the
|
| 243 |
+
Hugging Face auth headers, so any `EnvClient` can connect to it unchanged.
|
| 244 |
+
|
| 245 |
+
Args:
|
| 246 |
+
image (`str`):
|
| 247 |
+
Image to run, for example `hf.co/spaces/openenv/coding_env`. It must
|
| 248 |
+
provide a `server` command that serves OpenEnv on port 8000.
|
| 249 |
+
env_vars (`dict[str, str]`, *optional*):
|
| 250 |
+
Environment variables for the server.
|
| 251 |
+
flavor (`str`, *optional*, defaults to `"cpu-basic"`):
|
| 252 |
+
Hugging Face hardware flavor of the sandbox hosts.
|
| 253 |
+
"""
|
| 254 |
+
|
| 255 |
+
def __init__(
|
| 256 |
+
self,
|
| 257 |
+
*,
|
| 258 |
+
image: str,
|
| 259 |
+
env_vars: dict[str, str] | None = None,
|
| 260 |
+
flavor: str = "cpu-basic",
|
| 261 |
+
):
|
| 262 |
+
self.image = image
|
| 263 |
+
self.env_vars = env_vars
|
| 264 |
+
self.flavor = flavor
|
| 265 |
+
self._sandbox: Any = None
|
| 266 |
+
self._proxy: _LocalAuthProxy | None = None
|
| 267 |
+
|
| 268 |
+
def start_container(
|
| 269 |
+
self,
|
| 270 |
+
image: str | None = None,
|
| 271 |
+
port: int | None = None,
|
| 272 |
+
env_vars: dict[str, str] | None = None,
|
| 273 |
+
**kwargs: Any,
|
| 274 |
+
) -> str:
|
| 275 |
+
if self._sandbox is not None:
|
| 276 |
+
raise RuntimeError("HFSandboxProvider already has an active service")
|
| 277 |
+
if kwargs:
|
| 278 |
+
unsupported = ", ".join(sorted(kwargs))
|
| 279 |
+
raise ValueError(
|
| 280 |
+
f"HFSandboxProvider does not support start_container kwargs: {unsupported}"
|
| 281 |
+
)
|
| 282 |
+
|
| 283 |
+
if port not in (None, _DEFAULT_PORT):
|
| 284 |
+
raise ValueError(
|
| 285 |
+
f"HFSandboxProvider only supports port {_DEFAULT_PORT} (got {port})."
|
| 286 |
+
)
|
| 287 |
+
|
| 288 |
+
effective_image = self.image if image is None else image
|
| 289 |
+
effective_env = self.env_vars if env_vars is None else env_vars
|
| 290 |
+
pool = _get_pool(effective_image, self.flavor)
|
| 291 |
+
self._sandbox = pool.create(env=effective_env)
|
| 292 |
+
try:
|
| 293 |
+
if not hasattr(self._sandbox, "proxy_url_for") or not hasattr(
|
| 294 |
+
self._sandbox, "proxy_headers"
|
| 295 |
+
):
|
| 296 |
+
raise RuntimeError(
|
| 297 |
+
"HFSandboxProvider requires a huggingface_hub version with "
|
| 298 |
+
"Sandbox.proxy_url_for and Sandbox.proxy_headers support."
|
| 299 |
+
)
|
| 300 |
+
self._sandbox.run(
|
| 301 |
+
_SERVER_COMMAND,
|
| 302 |
+
shell=True,
|
| 303 |
+
)
|
| 304 |
+
self._proxy = _LocalAuthProxy(
|
| 305 |
+
target_url=self._sandbox.proxy_url_for(_DEFAULT_PORT, "/"),
|
| 306 |
+
headers=self._sandbox.proxy_headers,
|
| 307 |
+
)
|
| 308 |
+
return self._proxy.start()
|
| 309 |
+
except Exception:
|
| 310 |
+
self.stop_container()
|
| 311 |
+
raise
|
| 312 |
+
|
| 313 |
+
def stop_container(self) -> None:
|
| 314 |
+
if self._proxy is not None:
|
| 315 |
+
self._proxy.stop()
|
| 316 |
+
self._proxy = None
|
| 317 |
+
if self._sandbox is not None:
|
| 318 |
+
sandbox = self._sandbox
|
| 319 |
+
self._sandbox = None
|
| 320 |
+
with suppress(Exception):
|
| 321 |
+
sandbox.kill()
|
| 322 |
+
|
| 323 |
+
def wait_for_ready(self, base_url: str, timeout_s: float = 120.0) -> None:
|
| 324 |
+
deadline = time.time() + timeout_s
|
| 325 |
+
health_url = f"{base_url}/health"
|
| 326 |
+
while time.time() < deadline:
|
| 327 |
+
try:
|
| 328 |
+
response = requests.get(health_url, timeout=5.0)
|
| 329 |
+
if response.status_code == 200:
|
| 330 |
+
return
|
| 331 |
+
except requests.exceptions.RequestException:
|
| 332 |
+
pass
|
| 333 |
+
time.sleep(1.0)
|
| 334 |
+
raise TimeoutError(
|
| 335 |
+
f"HF sandbox job at {base_url} did not become ready within {timeout_s}s"
|
| 336 |
+
)
|
| 337 |
+
|
| 338 |
+
def close(self) -> None:
|
| 339 |
+
self.stop_container()
|
| 340 |
+
|
| 341 |
+
|
| 342 |
+
__all__ = ["HFSandboxProvider"]
|
src/core/containers/runtime/modal_provider.py
ADDED
|
@@ -0,0 +1,697 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-License-Identifier: BSD-3-Clause
|
| 2 |
+
|
| 3 |
+
"""
|
| 4 |
+
Modal container provider for running OpenEnv environments in Modal sandboxes.
|
| 5 |
+
|
| 6 |
+
Requires the ``modal`` SDK: ``pip install modal>=1.4``
|
| 7 |
+
|
| 8 |
+
The provider boots an OpenEnv server inside a Modal sandbox, exposes it on an
|
| 9 |
+
encrypted tunnel, and returns an ``https://`` URL that ``EnvClient`` connects to
|
| 10 |
+
over ``wss://``.
|
| 11 |
+
|
| 12 |
+
Supports both the stable Sandbox API (``modal.Sandbox.create``) and the beta
|
| 13 |
+
Sandbox v2 API (``modal.Sandbox._experimental_create``). Sandbox v2 is opt-in
|
| 14 |
+
via ``use_sandbox_v2=True`` because it is an experimental, private SDK feature;
|
| 15 |
+
see https://modal.com/docs/guide/sandbox-v2 .
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
from __future__ import annotations
|
| 19 |
+
|
| 20 |
+
import logging
|
| 21 |
+
import shlex
|
| 22 |
+
import time
|
| 23 |
+
from typing import Any, Dict, Optional
|
| 24 |
+
|
| 25 |
+
from ._server_config import parse_dockerfile_cmd, parse_openenv_app_field
|
| 26 |
+
from .providers import ContainerProvider
|
| 27 |
+
|
| 28 |
+
logger = logging.getLogger(__name__)
|
| 29 |
+
|
| 30 |
+
_DEFAULT_MODAL_PORT = 8000
|
| 31 |
+
|
| 32 |
+
# Modal's default per-container resource requests. We pass configured cpu/memory
|
| 33 |
+
# as the *limit* half of Modal's ``(request, limit)`` tuple so they cap usage
|
| 34 |
+
# rather than reserving (and billing for) the full amount. See
|
| 35 |
+
# https://modal.com/docs/guide/resources .
|
| 36 |
+
_DEFAULT_CPU_REQUEST = 0.125
|
| 37 |
+
_DEFAULT_MEMORY_REQUEST = 128
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def _require_secure_url(url: str) -> str:
|
| 41 |
+
"""Enforce https/wss transport (RFC 002 security invariant S1).
|
| 42 |
+
|
| 43 |
+
``EnvClient`` derives its WebSocket URL from this base URL, so a plaintext
|
| 44 |
+
URL would become a cleartext ``ws://`` connection. The offending URL is
|
| 45 |
+
deliberately omitted from the error because a Modal tunnel URL is a bearer
|
| 46 |
+
capability that must not leak into logs.
|
| 47 |
+
"""
|
| 48 |
+
if not isinstance(url, str) or not url.lower().startswith("https://"):
|
| 49 |
+
raise RuntimeError(
|
| 50 |
+
"Modal sandbox returned a non-HTTPS tunnel URL. OpenEnv requires an "
|
| 51 |
+
"https/wss base_url so EnvClient traffic is encrypted. Refusing to "
|
| 52 |
+
"connect over plaintext."
|
| 53 |
+
)
|
| 54 |
+
return url
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
class _DefaultModalAdapter:
|
| 58 |
+
"""Thin adapter over the ``modal`` SDK.
|
| 59 |
+
|
| 60 |
+
The provider talks to this private adapter instead of spreading SDK details
|
| 61 |
+
through its own logic; tests inject a duck-typed fake in its place. Keeping
|
| 62 |
+
the SDK surface here also localizes the feature check for the private
|
| 63 |
+
Sandbox v2 (``_experimental_create``) API.
|
| 64 |
+
"""
|
| 65 |
+
|
| 66 |
+
def __init__(self, *, app_name: str, use_sandbox_v2: bool):
|
| 67 |
+
import modal
|
| 68 |
+
|
| 69 |
+
self._modal = modal
|
| 70 |
+
self._app_name = app_name
|
| 71 |
+
self._use_sandbox_v2 = use_sandbox_v2
|
| 72 |
+
|
| 73 |
+
# Sandbox v2 rides on a private SDK entry point that is not present in
|
| 74 |
+
# every modal release. Fail fast at construction with clear guidance
|
| 75 |
+
# rather than at start_container time with an AttributeError.
|
| 76 |
+
if use_sandbox_v2 and not hasattr(modal.Sandbox, "_experimental_create"):
|
| 77 |
+
raise RuntimeError(
|
| 78 |
+
"use_sandbox_v2=True requires modal.Sandbox._experimental_create, "
|
| 79 |
+
"which is not available in the installed modal SDK. Upgrade modal, "
|
| 80 |
+
"or use the stable Sandbox API (use_sandbox_v2=False). Sandbox v2 "
|
| 81 |
+
"is an experimental feature; contact support@modal.com for access."
|
| 82 |
+
)
|
| 83 |
+
|
| 84 |
+
def image_from_registry(self, tag: str) -> Any:
|
| 85 |
+
return self._modal.Image.from_registry(tag)
|
| 86 |
+
|
| 87 |
+
def image_from_dockerfile(self, dockerfile_path: str, context_dir: str) -> Any:
|
| 88 |
+
return self._modal.Image.from_dockerfile(
|
| 89 |
+
dockerfile_path, context_dir=context_dir
|
| 90 |
+
)
|
| 91 |
+
|
| 92 |
+
def create_sandbox(
|
| 93 |
+
self,
|
| 94 |
+
*,
|
| 95 |
+
image: Any,
|
| 96 |
+
encrypted_ports: list[int],
|
| 97 |
+
timeout: int,
|
| 98 |
+
env: Optional[dict[str, str]],
|
| 99 |
+
extra: dict[str, Any],
|
| 100 |
+
) -> Any:
|
| 101 |
+
app = self._modal.App.lookup(self._app_name, create_if_missing=True)
|
| 102 |
+
kwargs: Dict[str, Any] = {
|
| 103 |
+
"app": app,
|
| 104 |
+
"image": image,
|
| 105 |
+
"encrypted_ports": encrypted_ports,
|
| 106 |
+
"timeout": timeout,
|
| 107 |
+
**extra,
|
| 108 |
+
}
|
| 109 |
+
if env:
|
| 110 |
+
kwargs["env"] = dict(env)
|
| 111 |
+
|
| 112 |
+
# The sandbox is started with a keep-alive entrypoint; the server
|
| 113 |
+
# command is launched via exec afterwards (see ModalProvider).
|
| 114 |
+
if self._use_sandbox_v2:
|
| 115 |
+
return self._modal.Sandbox._experimental_create(
|
| 116 |
+
"sleep", "infinity", **kwargs
|
| 117 |
+
)
|
| 118 |
+
return self._modal.Sandbox.create("sleep", "infinity", **kwargs)
|
| 119 |
+
|
| 120 |
+
def exec(self, sandbox: Any, command: str, *, timeout: int = 10) -> str:
|
| 121 |
+
"""Run *command* through a shell inside *sandbox* and return its stdout."""
|
| 122 |
+
proc = sandbox.exec("bash", "-c", command, timeout=timeout)
|
| 123 |
+
try:
|
| 124 |
+
out = proc.stdout.read()
|
| 125 |
+
except Exception:
|
| 126 |
+
out = ""
|
| 127 |
+
proc.wait()
|
| 128 |
+
return out or ""
|
| 129 |
+
|
| 130 |
+
def tunnel_url(self, sandbox: Any, port: int) -> str:
|
| 131 |
+
return sandbox.tunnels()[port].url
|
| 132 |
+
|
| 133 |
+
def terminate(self, sandbox: Any) -> None:
|
| 134 |
+
sandbox.terminate()
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
class ModalProvider(ContainerProvider):
|
| 138 |
+
"""
|
| 139 |
+
Container provider that runs environments in Modal sandboxes.
|
| 140 |
+
|
| 141 |
+
``start_container``'s ``image`` is either a registry tag
|
| 142 |
+
(``"echo-env:latest"``) or a ``"dockerfile:<path>"`` reference returned by
|
| 143 |
+
:meth:`image_from_dockerfile`. The server is exposed on an encrypted Modal
|
| 144 |
+
tunnel and the returned ``https://`` URL is what ``EnvClient`` connects to
|
| 145 |
+
over ``wss://``.
|
| 146 |
+
|
| 147 |
+
The environment runs untrusted code, so the provider is secure by default:
|
| 148 |
+
it enforces https/wss transport, treats the tunnel URL as a bearer secret
|
| 149 |
+
(never interpolated into errors), and never surfaces raw sandbox output
|
| 150 |
+
unless ``surface_server_logs=True``.
|
| 151 |
+
|
| 152 |
+
Only one sandbox is active per provider: calling ``start_container`` again
|
| 153 |
+
before ``stop_container()``/``close()`` raises ``RuntimeError`` rather than
|
| 154 |
+
orphaning the running sandbox. ``close()`` (and context-manager exit) stops
|
| 155 |
+
the active sandbox.
|
| 156 |
+
|
| 157 |
+
Example:
|
| 158 |
+
```python
|
| 159 |
+
with ModalProvider(app_name="openenv", cpu=2.0, memory=4096) as provider:
|
| 160 |
+
image = ModalProvider.image_from_dockerfile(
|
| 161 |
+
"envs/echo_env/server/Dockerfile"
|
| 162 |
+
)
|
| 163 |
+
base_url = provider.start_container(image)
|
| 164 |
+
provider.wait_for_ready(base_url)
|
| 165 |
+
# sandbox terminated on exit
|
| 166 |
+
```
|
| 167 |
+
|
| 168 |
+
Sandbox v2 (beta) is opt-in:
|
| 169 |
+
```python
|
| 170 |
+
provider = ModalProvider(app_name="openenv", use_sandbox_v2=True)
|
| 171 |
+
```
|
| 172 |
+
"""
|
| 173 |
+
|
| 174 |
+
_dockerfile_registry: Dict[str, Dict[str, Any]] = {}
|
| 175 |
+
|
| 176 |
+
def __init__(
|
| 177 |
+
self,
|
| 178 |
+
*,
|
| 179 |
+
image: str | None = None,
|
| 180 |
+
env_vars: dict[str, str] | None = None,
|
| 181 |
+
app_name: str = "openenv",
|
| 182 |
+
use_sandbox_v2: bool = False,
|
| 183 |
+
timeout: int = 300,
|
| 184 |
+
cmd: str | None = None,
|
| 185 |
+
cpu: float | None = None,
|
| 186 |
+
memory: int | None = None,
|
| 187 |
+
surface_server_logs: bool = False,
|
| 188 |
+
_adapter: Any = None,
|
| 189 |
+
):
|
| 190 |
+
"""
|
| 191 |
+
Args:
|
| 192 |
+
image (`str`, *optional*):
|
| 193 |
+
Registry image tag or ``"dockerfile:<path>"`` source to use
|
| 194 |
+
when ``start_container()`` is called without an image.
|
| 195 |
+
env_vars (`dict`, *optional*):
|
| 196 |
+
Environment variables to use when ``start_container()`` is
|
| 197 |
+
called without explicit ``env_vars``.
|
| 198 |
+
app_name (`str`, *optional*, defaults to `"openenv"`):
|
| 199 |
+
Modal app name the sandbox is created under. Looked up (and
|
| 200 |
+
created if missing) via ``modal.App.lookup``.
|
| 201 |
+
use_sandbox_v2 (`bool`, *optional*, defaults to `False`):
|
| 202 |
+
When `True`, sandboxes are created via the beta
|
| 203 |
+
``modal.Sandbox._experimental_create`` API (Sandbox v2). This is
|
| 204 |
+
an experimental, private SDK feature and is therefore off by
|
| 205 |
+
default; a feature check fails fast at construction if the
|
| 206 |
+
installed SDK lacks it. See https://modal.com/docs/guide/sandbox-v2 .
|
| 207 |
+
timeout (`int`, *optional*, defaults to `300`):
|
| 208 |
+
Maximum sandbox lifetime in seconds.
|
| 209 |
+
cmd (`str`, *optional*):
|
| 210 |
+
Shell command to start the server inside the sandbox. When
|
| 211 |
+
omitted, the command is auto-discovered from ``openenv.yaml``
|
| 212 |
+
(falling back to the Dockerfile ``CMD``).
|
| 213 |
+
cpu (`float`, *optional*):
|
| 214 |
+
Hard CPU-core limit for the sandbox. Passed as the limit half of
|
| 215 |
+
Modal's ``(request, limit)`` tuple (request stays at Modal's
|
| 216 |
+
default), so it caps usage rather than reserving cores. When
|
| 217 |
+
`None`, Modal's default applies.
|
| 218 |
+
memory (`int`, *optional*):
|
| 219 |
+
Hard memory limit in MiB for the sandbox (containers exceeding it
|
| 220 |
+
are OOM-killed). Passed as the limit half of Modal's
|
| 221 |
+
``(request, limit)`` tuple. When `None`, Modal's default applies.
|
| 222 |
+
surface_server_logs (`bool`, *optional*, defaults to `False`):
|
| 223 |
+
When `False` (default), captured sandbox output is withheld from
|
| 224 |
+
raised errors so secrets the workload printed cannot leak into
|
| 225 |
+
orchestrator/CI logs. When `True`, a best-effort redacted,
|
| 226 |
+
length-bounded excerpt is included in startup-crash errors.
|
| 227 |
+
"""
|
| 228 |
+
self._image = image
|
| 229 |
+
self._env_vars = env_vars
|
| 230 |
+
self._app_name = app_name
|
| 231 |
+
self._use_sandbox_v2 = use_sandbox_v2
|
| 232 |
+
self._timeout = timeout
|
| 233 |
+
self._cmd = cmd
|
| 234 |
+
self._cpu = cpu
|
| 235 |
+
self._memory = memory
|
| 236 |
+
self._surface_server_logs = surface_server_logs
|
| 237 |
+
self._sandbox: Any = None
|
| 238 |
+
self._base_url: str | None = None
|
| 239 |
+
# Injected env-var values, used to scrub captured server output before
|
| 240 |
+
# it is ever surfaced in an error.
|
| 241 |
+
self._redact_values: set[str] = set()
|
| 242 |
+
|
| 243 |
+
if _adapter is None:
|
| 244 |
+
# Import eagerly (inside the adapter) so SDK/configuration errors —
|
| 245 |
+
# including the Sandbox v2 feature check — surface at construction.
|
| 246 |
+
self._adapter: Any = _DefaultModalAdapter(
|
| 247 |
+
app_name=app_name, use_sandbox_v2=use_sandbox_v2
|
| 248 |
+
)
|
| 249 |
+
else:
|
| 250 |
+
self._adapter = _adapter
|
| 251 |
+
|
| 252 |
+
if use_sandbox_v2:
|
| 253 |
+
logger.info(
|
| 254 |
+
"Using Modal Sandbox v2 (experimental). This feature must be "
|
| 255 |
+
"explicitly enabled. Contact support@modal.com to get access."
|
| 256 |
+
)
|
| 257 |
+
|
| 258 |
+
def _discover_server_cmd(self, port: int = _DEFAULT_MODAL_PORT) -> str:
|
| 259 |
+
"""Discover the server command from ``openenv.yaml`` inside the sandbox.
|
| 260 |
+
|
| 261 |
+
Finds the file, reads the ``app`` field, and constructs a command
|
| 262 |
+
of the form ``cd <env_root> && python -m uvicorn <app> --host 0.0.0.0 --port <port>``.
|
| 263 |
+
|
| 264 |
+
Raises:
|
| 265 |
+
ValueError: If ``openenv.yaml`` is not found or lacks an ``app`` field.
|
| 266 |
+
"""
|
| 267 |
+
yaml_path = self._find_openenv_yaml()
|
| 268 |
+
if yaml_path is None:
|
| 269 |
+
raise ValueError(
|
| 270 |
+
"Could not find openenv.yaml inside the sandbox. "
|
| 271 |
+
"Pass an explicit cmd= to ModalProvider or start_container()."
|
| 272 |
+
)
|
| 273 |
+
|
| 274 |
+
content = self._adapter.exec(self._sandbox, f"cat {shlex.quote(yaml_path)}")
|
| 275 |
+
app = self._parse_app_field(content)
|
| 276 |
+
if app is None:
|
| 277 |
+
raise ValueError(
|
| 278 |
+
f"openenv.yaml at {yaml_path} does not contain an 'app' field. "
|
| 279 |
+
"Pass an explicit cmd= to ModalProvider or start_container()."
|
| 280 |
+
)
|
| 281 |
+
|
| 282 |
+
# The directory containing openenv.yaml is the env root
|
| 283 |
+
env_root = yaml_path.rsplit("/", 1)[0]
|
| 284 |
+
return (
|
| 285 |
+
f"cd {shlex.quote(env_root)} && "
|
| 286 |
+
f"python -m uvicorn {shlex.quote(app)} --host 0.0.0.0 --port {port}"
|
| 287 |
+
)
|
| 288 |
+
|
| 289 |
+
def _find_openenv_yaml(self) -> str | None:
|
| 290 |
+
"""Locate ``openenv.yaml`` inside the sandbox.
|
| 291 |
+
|
| 292 |
+
Tries the modern layout path ``/app/env/openenv.yaml`` first,
|
| 293 |
+
then falls back to a ``find`` command for the old layout.
|
| 294 |
+
"""
|
| 295 |
+
# Fast path: modern Dockerfile layout
|
| 296 |
+
out = self._adapter.exec(
|
| 297 |
+
self._sandbox, "test -f /app/env/openenv.yaml && echo found"
|
| 298 |
+
)
|
| 299 |
+
if "found" in (out or ""):
|
| 300 |
+
return "/app/env/openenv.yaml"
|
| 301 |
+
|
| 302 |
+
# Fallback: search for it (redirect stderr so error messages
|
| 303 |
+
# like "No such file or directory" don't get mistaken for paths).
|
| 304 |
+
path = self._adapter.exec(
|
| 305 |
+
self._sandbox,
|
| 306 |
+
"find /app -maxdepth 4 -name openenv.yaml -print -quit 2>/dev/null",
|
| 307 |
+
).strip()
|
| 308 |
+
if path and path.startswith("/"):
|
| 309 |
+
return path
|
| 310 |
+
|
| 311 |
+
return None
|
| 312 |
+
|
| 313 |
+
@staticmethod
|
| 314 |
+
def _parse_app_field(yaml_content: str) -> str | None:
|
| 315 |
+
"""Extract the ``app`` value from raw openenv.yaml content.
|
| 316 |
+
|
| 317 |
+
Uses PyYAML to handle comments, quotes, and nested keys correctly.
|
| 318 |
+
"""
|
| 319 |
+
return parse_openenv_app_field(yaml_content)
|
| 320 |
+
|
| 321 |
+
@staticmethod
|
| 322 |
+
def _parse_dockerfile_cmd(dockerfile_content: str) -> str | None:
|
| 323 |
+
"""Extract the server command from the last ``CMD`` in a Dockerfile.
|
| 324 |
+
|
| 325 |
+
Handles exec form (``CMD ["prog", "arg"]``) and shell form
|
| 326 |
+
(``CMD prog arg``). When a Dockerfile has multiple ``CMD``
|
| 327 |
+
instructions (e.g. multi-stage builds), the last one wins - same
|
| 328 |
+
semantics as Docker itself. Lines where ``CMD`` appears inside a
|
| 329 |
+
comment are ignored.
|
| 330 |
+
|
| 331 |
+
Returns:
|
| 332 |
+
The command as a single string, or ``None`` if no ``CMD`` found.
|
| 333 |
+
"""
|
| 334 |
+
return parse_dockerfile_cmd(dockerfile_content)
|
| 335 |
+
|
| 336 |
+
@classmethod
|
| 337 |
+
def image_from_dockerfile(
|
| 338 |
+
cls,
|
| 339 |
+
dockerfile_path: str,
|
| 340 |
+
context_dir: str | None = None,
|
| 341 |
+
) -> str:
|
| 342 |
+
"""Validate a Dockerfile and return a ``dockerfile:`` URI for
|
| 343 |
+
:meth:`start_container`.
|
| 344 |
+
|
| 345 |
+
Eagerly validates the Dockerfile (existence, COPY sources) and stores
|
| 346 |
+
its content in an internal registry. The actual ``modal.Image`` is
|
| 347 |
+
created later inside ``start_container``.
|
| 348 |
+
|
| 349 |
+
Args:
|
| 350 |
+
dockerfile_path (`str`):
|
| 351 |
+
Path to the Dockerfile on disk.
|
| 352 |
+
context_dir (`str`, *optional*):
|
| 353 |
+
Build context directory. Defaults to the Dockerfile's
|
| 354 |
+
grandparent directory, matching the ``openenv init``
|
| 355 |
+
convention where Dockerfiles live in
|
| 356 |
+
``<env>/server/Dockerfile`` and the build context is
|
| 357 |
+
``<env>/``. Pass explicitly for non-standard layouts
|
| 358 |
+
(e.g. ``context_dir="."`` for repo-root contexts).
|
| 359 |
+
|
| 360 |
+
Returns:
|
| 361 |
+
`str`: A ``"dockerfile:<abs_path>"`` string to pass to
|
| 362 |
+
``start_container``.
|
| 363 |
+
|
| 364 |
+
Raises:
|
| 365 |
+
FileNotFoundError: If *dockerfile_path* does not exist.
|
| 366 |
+
ValueError: If *context_dir* is given but does not exist,
|
| 367 |
+
or if COPY sources in the Dockerfile cannot be found
|
| 368 |
+
under the resolved context directory.
|
| 369 |
+
"""
|
| 370 |
+
import pathlib
|
| 371 |
+
import re
|
| 372 |
+
|
| 373 |
+
src = pathlib.Path(dockerfile_path).resolve()
|
| 374 |
+
if not src.is_file():
|
| 375 |
+
raise FileNotFoundError(f"Dockerfile not found: {dockerfile_path}")
|
| 376 |
+
|
| 377 |
+
if context_dir is not None:
|
| 378 |
+
ctx = pathlib.Path(context_dir)
|
| 379 |
+
if not ctx.is_dir():
|
| 380 |
+
raise ValueError(f"context_dir does not exist: {context_dir}")
|
| 381 |
+
else:
|
| 382 |
+
# Default: grandparent of the Dockerfile, matching the
|
| 383 |
+
# openenv init layout (<env>/server/Dockerfile -> <env>/).
|
| 384 |
+
ctx = src.parent.parent
|
| 385 |
+
|
| 386 |
+
content = src.read_text()
|
| 387 |
+
|
| 388 |
+
# Validate that COPY sources exist under the context directory.
|
| 389 |
+
# This catches mismatches early (e.g. a Dockerfile expecting repo
|
| 390 |
+
# root as context when we defaulted to the env directory).
|
| 391 |
+
for line in content.splitlines():
|
| 392 |
+
m = re.match(r"^\s*COPY\s+(?!--from=)(\S+)\s+", line, re.IGNORECASE)
|
| 393 |
+
if not m:
|
| 394 |
+
continue
|
| 395 |
+
copy_src = m.group(1)
|
| 396 |
+
if copy_src.startswith("/"):
|
| 397 |
+
continue
|
| 398 |
+
resolved = ctx / copy_src
|
| 399 |
+
if not resolved.exists() and not any(ctx.glob(copy_src)):
|
| 400 |
+
raise ValueError(
|
| 401 |
+
f"Dockerfile COPY source '{copy_src}' not found "
|
| 402 |
+
f"under context_dir '{ctx}'. This Dockerfile may "
|
| 403 |
+
f"expect a different build context (e.g. the repo "
|
| 404 |
+
f"root). Pass context_dir explicitly."
|
| 405 |
+
)
|
| 406 |
+
|
| 407 |
+
# Parse CMD from the Dockerfile so start_container can use it as a
|
| 408 |
+
# fallback when openenv.yaml is unavailable.
|
| 409 |
+
parsed_cmd = cls._parse_dockerfile_cmd(content)
|
| 410 |
+
|
| 411 |
+
cls._dockerfile_registry[str(src)] = {
|
| 412 |
+
"dockerfile_path": str(src),
|
| 413 |
+
"context_dir": str(ctx),
|
| 414 |
+
"server_cmd": parsed_cmd,
|
| 415 |
+
}
|
| 416 |
+
|
| 417 |
+
return f"dockerfile:{src}"
|
| 418 |
+
|
| 419 |
+
def _build_image(self, image: str) -> Any:
|
| 420 |
+
"""Build the ``modal.Image`` for *image* (registry tag or dockerfile:)."""
|
| 421 |
+
if image.startswith("dockerfile:"):
|
| 422 |
+
dockerfile_path = image[len("dockerfile:") :]
|
| 423 |
+
meta = self._dockerfile_registry.get(dockerfile_path)
|
| 424 |
+
if meta is None:
|
| 425 |
+
raise ValueError(
|
| 426 |
+
f"No registered Dockerfile metadata for {dockerfile_path}. "
|
| 427 |
+
"Call ModalProvider.image_from_dockerfile() first."
|
| 428 |
+
)
|
| 429 |
+
return self._adapter.image_from_dockerfile(
|
| 430 |
+
meta["dockerfile_path"], meta["context_dir"]
|
| 431 |
+
)
|
| 432 |
+
|
| 433 |
+
# Plain registry tag (e.g. "echo-env:latest").
|
| 434 |
+
return self._adapter.image_from_registry(image)
|
| 435 |
+
|
| 436 |
+
def start_container(
|
| 437 |
+
self,
|
| 438 |
+
image: str | None = None,
|
| 439 |
+
port: int | None = None,
|
| 440 |
+
env_vars: dict[str, str] | None = None,
|
| 441 |
+
**kwargs: Any,
|
| 442 |
+
) -> str:
|
| 443 |
+
"""
|
| 444 |
+
Create a Modal sandbox from a Docker image or Dockerfile.
|
| 445 |
+
|
| 446 |
+
The sandbox is started with a keep-alive process and the server
|
| 447 |
+
command is launched via ``exec`` afterwards, mirroring the discovery
|
| 448 |
+
flow used by other cloud providers. The server command is resolved in
|
| 449 |
+
order:
|
| 450 |
+
|
| 451 |
+
1. Explicit ``cmd`` passed to the constructor.
|
| 452 |
+
2. ``cmd`` key in ``**kwargs`` (popped before forwarding).
|
| 453 |
+
3. Auto-discovered from ``openenv.yaml`` inside the sandbox.
|
| 454 |
+
4. ``CMD`` parsed from the Dockerfile (when *image* came from
|
| 455 |
+
``image_from_dockerfile``).
|
| 456 |
+
|
| 457 |
+
Args:
|
| 458 |
+
image (`str`, *optional*):
|
| 459 |
+
Registry image tag (e.g. ``"echo-env:latest"``) or
|
| 460 |
+
``"dockerfile:<path>"`` returned by
|
| 461 |
+
:meth:`image_from_dockerfile`. May be omitted when supplied to
|
| 462 |
+
the constructor.
|
| 463 |
+
port (`int`, *optional*):
|
| 464 |
+
Must be ``None`` or ``8000``. Modal exposes port 8000 via an
|
| 465 |
+
encrypted tunnel; other ports raise ``ValueError``.
|
| 466 |
+
env_vars (`dict`, *optional*):
|
| 467 |
+
Environment variables forwarded to the sandbox.
|
| 468 |
+
**kwargs:
|
| 469 |
+
``cmd`` (`str`) to override the server command; any remaining
|
| 470 |
+
keyword arguments are forwarded to ``modal.Sandbox.create``.
|
| 471 |
+
|
| 472 |
+
Returns:
|
| 473 |
+
`str`: HTTPS tunnel URL for the sandbox (base_url).
|
| 474 |
+
"""
|
| 475 |
+
if self._sandbox is not None:
|
| 476 |
+
raise RuntimeError(
|
| 477 |
+
"ModalProvider already has an active sandbox. Call "
|
| 478 |
+
"stop_container() (or close()) before starting another — a "
|
| 479 |
+
"second start would orphan the running sandbox."
|
| 480 |
+
)
|
| 481 |
+
|
| 482 |
+
if port is not None and port != _DEFAULT_MODAL_PORT:
|
| 483 |
+
raise ValueError(
|
| 484 |
+
f"ModalProvider only supports port {_DEFAULT_MODAL_PORT} "
|
| 485 |
+
f"(got {port}). The Modal tunnel routes to port "
|
| 486 |
+
f"{_DEFAULT_MODAL_PORT} inside the sandbox."
|
| 487 |
+
)
|
| 488 |
+
|
| 489 |
+
effective_image = image if image is not None else self._image
|
| 490 |
+
if effective_image is None:
|
| 491 |
+
raise ValueError(
|
| 492 |
+
"ModalProvider requires an image. Pass it to the constructor "
|
| 493 |
+
"or start_container()."
|
| 494 |
+
)
|
| 495 |
+
effective_env_vars = self._env_vars if env_vars is None else env_vars
|
| 496 |
+
|
| 497 |
+
# Resolve the server command (may be None; discovery happens after
|
| 498 |
+
# sandbox creation when we can inspect the filesystem).
|
| 499 |
+
cmd = kwargs.pop("cmd", None) or self._cmd
|
| 500 |
+
|
| 501 |
+
# CMD parsed from Dockerfile (populated for "dockerfile:" images).
|
| 502 |
+
parsed_cmd: str | None = None
|
| 503 |
+
if effective_image.startswith("dockerfile:"):
|
| 504 |
+
meta = self._dockerfile_registry.get(effective_image[len("dockerfile:") :])
|
| 505 |
+
if meta is not None:
|
| 506 |
+
parsed_cmd = meta.get("server_cmd")
|
| 507 |
+
|
| 508 |
+
modal_image = self._build_image(effective_image)
|
| 509 |
+
|
| 510 |
+
extra: Dict[str, Any] = dict(kwargs)
|
| 511 |
+
if self._cpu is not None:
|
| 512 |
+
extra["cpu"] = (_DEFAULT_CPU_REQUEST, self._cpu)
|
| 513 |
+
if self._memory is not None:
|
| 514 |
+
extra["memory"] = (_DEFAULT_MEMORY_REQUEST, self._memory)
|
| 515 |
+
|
| 516 |
+
# Record injected secret values so captured server output can be
|
| 517 |
+
# scrubbed before it is ever surfaced in an error.
|
| 518 |
+
self._redact_values = {
|
| 519 |
+
value for value in (effective_env_vars or {}).values() if value
|
| 520 |
+
}
|
| 521 |
+
|
| 522 |
+
# A create failure created nothing, so just drop the recorded secrets
|
| 523 |
+
# and re-raise (the double-start guard above guarantees there is no
|
| 524 |
+
# pre-existing sandbox to delete).
|
| 525 |
+
try:
|
| 526 |
+
self._sandbox = self._adapter.create_sandbox(
|
| 527 |
+
image=modal_image,
|
| 528 |
+
encrypted_ports=[_DEFAULT_MODAL_PORT],
|
| 529 |
+
timeout=self._timeout,
|
| 530 |
+
env=effective_env_vars,
|
| 531 |
+
extra=extra,
|
| 532 |
+
)
|
| 533 |
+
except Exception:
|
| 534 |
+
self._redact_values = set()
|
| 535 |
+
raise
|
| 536 |
+
|
| 537 |
+
try:
|
| 538 |
+
# Discover server command from openenv.yaml if not explicitly set.
|
| 539 |
+
if cmd is None:
|
| 540 |
+
try:
|
| 541 |
+
cmd = self._discover_server_cmd()
|
| 542 |
+
except ValueError:
|
| 543 |
+
# Fall back to CMD parsed from Dockerfile (if available).
|
| 544 |
+
if parsed_cmd:
|
| 545 |
+
cmd = parsed_cmd
|
| 546 |
+
else:
|
| 547 |
+
raise
|
| 548 |
+
|
| 549 |
+
# Launch the server in the background. Write the PID so we can
|
| 550 |
+
# check whether the process crashed in wait_for_ready().
|
| 551 |
+
escaped_cmd = shlex.quote(cmd)
|
| 552 |
+
self._adapter.exec(
|
| 553 |
+
self._sandbox,
|
| 554 |
+
f"nohup bash -c {escaped_cmd} > /tmp/openenv-server.log 2>&1 &"
|
| 555 |
+
" echo $! > /tmp/openenv-server.pid",
|
| 556 |
+
)
|
| 557 |
+
|
| 558 |
+
# Resolve the public tunnel URL for port 8000.
|
| 559 |
+
self._base_url = _require_secure_url(
|
| 560 |
+
self._adapter.tunnel_url(self._sandbox, _DEFAULT_MODAL_PORT)
|
| 561 |
+
)
|
| 562 |
+
except Exception:
|
| 563 |
+
# A cleanup failure here must not mask the original error: swallow
|
| 564 |
+
# any exception from stop_container() so the root cause propagates.
|
| 565 |
+
try:
|
| 566 |
+
self.stop_container()
|
| 567 |
+
except Exception:
|
| 568 |
+
pass
|
| 569 |
+
raise
|
| 570 |
+
|
| 571 |
+
return self._base_url
|
| 572 |
+
|
| 573 |
+
def stop_container(self) -> None:
|
| 574 |
+
"""Terminate the Modal sandbox."""
|
| 575 |
+
if self._sandbox is None:
|
| 576 |
+
# Still drop any injected secret values recorded by a failed start.
|
| 577 |
+
self._redact_values = set()
|
| 578 |
+
return
|
| 579 |
+
|
| 580 |
+
try:
|
| 581 |
+
self._adapter.terminate(self._sandbox)
|
| 582 |
+
finally:
|
| 583 |
+
self._sandbox = None
|
| 584 |
+
self._base_url = None
|
| 585 |
+
self._redact_values = set()
|
| 586 |
+
|
| 587 |
+
def close(self) -> None:
|
| 588 |
+
"""Stop the active sandbox.
|
| 589 |
+
|
| 590 |
+
Overrides the base no-op so a caller holding a bare ``ContainerProvider``
|
| 591 |
+
reference can release the sandbox polymorphically (also invoked on
|
| 592 |
+
context-manager exit). ``ModalProvider`` holds no separate SDK client, so
|
| 593 |
+
this is equivalent to ``stop_container()``.
|
| 594 |
+
"""
|
| 595 |
+
self.stop_container()
|
| 596 |
+
|
| 597 |
+
@property
|
| 598 |
+
def base_url(self) -> str:
|
| 599 |
+
"""URL returned by the last ``start_container``."""
|
| 600 |
+
if self._base_url is None:
|
| 601 |
+
raise RuntimeError(
|
| 602 |
+
"ModalProvider has no active base_url. Start the provider "
|
| 603 |
+
"before reading base_url."
|
| 604 |
+
)
|
| 605 |
+
return self._base_url
|
| 606 |
+
|
| 607 |
+
def _redact(self, text: str, *, max_chars: int = 2000) -> str:
|
| 608 |
+
"""Scrub injected secret values and bound length before surfacing output.
|
| 609 |
+
|
| 610 |
+
Replaces any injected env-var value with `***` and keeps only the tail.
|
| 611 |
+
This is best-effort (exact-match only), which is why server output is
|
| 612 |
+
withheld entirely unless ``surface_server_logs=True``.
|
| 613 |
+
"""
|
| 614 |
+
redacted = text or ""
|
| 615 |
+
for value in self._redact_values:
|
| 616 |
+
redacted = redacted.replace(value, "***")
|
| 617 |
+
if len(redacted) > max_chars:
|
| 618 |
+
redacted = "...(truncated)...\n" + redacted[-max_chars:]
|
| 619 |
+
return redacted
|
| 620 |
+
|
| 621 |
+
def _server_died_message(self) -> str:
|
| 622 |
+
"""Build the startup-crash error, secure by default.
|
| 623 |
+
|
| 624 |
+
Untrusted code can print secrets then force a crash to exfiltrate them
|
| 625 |
+
through the exception (which lands in orchestrator/CI logs), so sandbox
|
| 626 |
+
output is excluded unless ``surface_server_logs=True``, in which case a
|
| 627 |
+
best-effort redacted, bounded excerpt is included.
|
| 628 |
+
"""
|
| 629 |
+
base = (
|
| 630 |
+
"Modal sandbox server process died during startup. Server output is "
|
| 631 |
+
"not surfaced to avoid leaking secrets injected into the sandbox; "
|
| 632 |
+
"retrieve /tmp/openenv-server.log from the sandbox out of band, or "
|
| 633 |
+
"construct the provider with surface_server_logs=True to include a "
|
| 634 |
+
"redacted excerpt."
|
| 635 |
+
)
|
| 636 |
+
if not self._surface_server_logs or self._sandbox is None:
|
| 637 |
+
return base
|
| 638 |
+
|
| 639 |
+
log = self._redact(
|
| 640 |
+
self._adapter.exec(self._sandbox, "cat /tmp/openenv-server.log 2>/dev/null")
|
| 641 |
+
)
|
| 642 |
+
return (
|
| 643 |
+
"Modal sandbox server process died during startup. The excerpt below "
|
| 644 |
+
"is the sandbox server output with injected secret values redacted "
|
| 645 |
+
"(best-effort); it may still contain secrets the workload printed "
|
| 646 |
+
f"by other means.\nLog (redacted):\n{log}"
|
| 647 |
+
)
|
| 648 |
+
|
| 649 |
+
def wait_for_ready(self, base_url: str, timeout_s: float = 120.0) -> None:
|
| 650 |
+
"""
|
| 651 |
+
Poll the /health endpoint until the sandbox is ready.
|
| 652 |
+
|
| 653 |
+
Uses a longer default timeout (120s) than local Docker providers
|
| 654 |
+
because Modal sandboxes may have cold-start latency.
|
| 655 |
+
|
| 656 |
+
Args:
|
| 657 |
+
base_url (`str`):
|
| 658 |
+
Tunnel URL returned by ``start_container()``.
|
| 659 |
+
timeout_s (`float`, *optional*, defaults to `120.0`):
|
| 660 |
+
Maximum seconds to wait.
|
| 661 |
+
|
| 662 |
+
Raises:
|
| 663 |
+
TimeoutError: If the sandbox doesn't become ready in time.
|
| 664 |
+
RuntimeError: If the server process died (detected via PID check).
|
| 665 |
+
"""
|
| 666 |
+
import requests
|
| 667 |
+
|
| 668 |
+
health_url = f"{base_url}/health"
|
| 669 |
+
|
| 670 |
+
deadline = time.time() + timeout_s
|
| 671 |
+
while time.time() < deadline:
|
| 672 |
+
try:
|
| 673 |
+
response = requests.get(health_url, timeout=5.0)
|
| 674 |
+
if response.status_code == 200:
|
| 675 |
+
return
|
| 676 |
+
except requests.RequestException:
|
| 677 |
+
pass
|
| 678 |
+
|
| 679 |
+
# Early exit: if the server process died, raise immediately
|
| 680 |
+
# instead of waiting for the full health-check timeout.
|
| 681 |
+
if self._sandbox is not None:
|
| 682 |
+
out = self._adapter.exec(
|
| 683 |
+
self._sandbox,
|
| 684 |
+
"kill -0 $(cat /tmp/openenv-server.pid) 2>/dev/null"
|
| 685 |
+
" && echo RUNNING || echo DEAD",
|
| 686 |
+
)
|
| 687 |
+
if "DEAD" in (out or ""):
|
| 688 |
+
raise RuntimeError(self._server_died_message())
|
| 689 |
+
|
| 690 |
+
time.sleep(1.0)
|
| 691 |
+
|
| 692 |
+
# The tunnel URL is a bearer capability, so it is deliberately omitted
|
| 693 |
+
# from the timeout error.
|
| 694 |
+
raise TimeoutError(f"Modal sandbox did not become ready within {timeout_s}s.")
|
| 695 |
+
|
| 696 |
+
|
| 697 |
+
__all__ = ["ModalProvider"]
|
src/core/containers/runtime/novita_provider.py
ADDED
|
@@ -0,0 +1,1216 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-License-Identifier: BSD-3-Clause
|
| 2 |
+
|
| 3 |
+
"""
|
| 4 |
+
Novita AI Sandbox container provider for running OpenEnv environments.
|
| 5 |
+
|
| 6 |
+
Requires the ``novita-sandbox`` SDK: ``pip install openenv[novita]``
|
| 7 |
+
|
| 8 |
+
The provider boots an OpenEnv server inside a Novita sandbox, exposes port 8000
|
| 9 |
+
on the sandbox's public host, and returns an ``https://`` URL that ``EnvClient``
|
| 10 |
+
connects to over ``wss://``.
|
| 11 |
+
|
| 12 |
+
Note: the ``RFC 002 security invariant S<n>`` references in this module point to
|
| 13 |
+
the **proposed** "Cloud Sandbox Providers" amendment to RFC 002, which is pending
|
| 14 |
+
review/sign-off by the RFC authors. They describe the security properties this
|
| 15 |
+
provider enforces; treat them as proposed (not yet ratified) until that sign-off.
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
from __future__ import annotations
|
| 19 |
+
|
| 20 |
+
import re
|
| 21 |
+
import shlex
|
| 22 |
+
import time
|
| 23 |
+
from typing import Any, Callable, Dict, List, Optional
|
| 24 |
+
|
| 25 |
+
from ._server_config import parse_openenv_app_field
|
| 26 |
+
from .providers import ContainerProvider
|
| 27 |
+
|
| 28 |
+
_DEFAULT_NOVITA_PORT = 8000
|
| 29 |
+
|
| 30 |
+
# Novita defaults a sandbox to a 300s hard lifetime, which would kill an RL
|
| 31 |
+
# rollout mid-episode. One hour is the documented ceiling for Hobby accounts, so
|
| 32 |
+
# it is the largest default that cannot make ``create`` fail on plan limits;
|
| 33 |
+
# longer-running work passes ``timeout=`` explicitly.
|
| 34 |
+
_DEFAULT_SANDBOX_TIMEOUT_S = 3600
|
| 35 |
+
|
| 36 |
+
# The sandbox is created with this as its start command so the image's own CMD
|
| 37 |
+
# is not launched (see `_launch_server`). `sleep infinity` keeps the sandbox
|
| 38 |
+
# alive with nothing bound to port 8000, leaving the port free for the server
|
| 39 |
+
# this provider starts itself.
|
| 40 |
+
_KEEPALIVE_CMD = "sleep infinity"
|
| 41 |
+
_KEEPALIVE_READY_MS = 5_000
|
| 42 |
+
|
| 43 |
+
# Resource defaults for a template built from a Dockerfile. The SDK's own
|
| 44 |
+
# defaults, restated here so they are visible at the call site.
|
| 45 |
+
_DEFAULT_TEMPLATE_CPU = 2
|
| 46 |
+
_DEFAULT_TEMPLATE_MEMORY_MB = 1024
|
| 47 |
+
|
| 48 |
+
# User the sandbox runs as. Root because the environment installs under /app,
|
| 49 |
+
# which the image creates as root and the Novita parser's default user ("user")
|
| 50 |
+
# cannot write to -- see `build_template`. Matches what a plain `docker run` of
|
| 51 |
+
# the same image gives, which is what the other providers' users observe.
|
| 52 |
+
_SANDBOX_USER = "root"
|
| 53 |
+
|
| 54 |
+
_FROM_RE = re.compile(r"^\s*FROM\s+(?P<rest>.+?)\s*$", re.IGNORECASE)
|
| 55 |
+
_COPY_FROM_RE = re.compile(
|
| 56 |
+
r"^\s*COPY\s+--from=(?P<stage>\S+)\s+(?P<src>\S+)\s+(?P<dst>\S+)\s*$",
|
| 57 |
+
re.IGNORECASE,
|
| 58 |
+
)
|
| 59 |
+
_COPY_FROM_REMAINDER_RE = re.compile(r"^\s*COPY\b.*\s--from(?:=|\s|$)", re.IGNORECASE)
|
| 60 |
+
_USER_RE = re.compile(r"^\s*USER(?:\s+|$)", re.IGNORECASE)
|
| 61 |
+
_RUN_RE = re.compile(r"^\s*RUN\s+(?P<rest>.*)$", re.IGNORECASE)
|
| 62 |
+
_ARG_DEFAULT_RE = re.compile(
|
| 63 |
+
r"^\s*ARG\s+(?P<name>\w+)=(?P<value>\S+)\s*$", re.IGNORECASE
|
| 64 |
+
)
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def _strip_mount_flags(content: str) -> str:
|
| 68 |
+
"""Remove supported BuildKit cache mounts from ``RUN`` instructions.
|
| 69 |
+
|
| 70 |
+
Novita's template parser copies a ``RUN`` body verbatim into the build
|
| 71 |
+
command, so a leading ``--mount=type=cache,...`` would be handed to the
|
| 72 |
+
shell as an argument rather than honored as BuildKit syntax. Every in-repo
|
| 73 |
+
OpenEnv Dockerfile uses one to cache ``uv`` downloads, so explicit cache
|
| 74 |
+
mounts are stripped rather than left to fail the build. Other mount types
|
| 75 |
+
affect build semantics and cannot be represented by Novita's parser, so
|
| 76 |
+
they are rejected instead of silently removed.
|
| 77 |
+
"""
|
| 78 |
+
lines = content.split("\n")
|
| 79 |
+
out: List[str] = []
|
| 80 |
+
in_run = False
|
| 81 |
+
for line in lines:
|
| 82 |
+
if _RUN_RE.match(line):
|
| 83 |
+
in_run = True
|
| 84 |
+
if in_run:
|
| 85 |
+
# Strip leading --mount flags from the RUN line itself and from any
|
| 86 |
+
# continuation lines that follow it.
|
| 87 |
+
stripped = line
|
| 88 |
+
prefix = ""
|
| 89 |
+
run_match = _RUN_RE.match(line)
|
| 90 |
+
if run_match:
|
| 91 |
+
prefix = line[: run_match.start("rest")]
|
| 92 |
+
stripped = run_match.group("rest")
|
| 93 |
+
while True:
|
| 94 |
+
mount = re.match(r"\s*--mount=(?P<spec>\S+)(?P<space>\s*)", stripped)
|
| 95 |
+
if not mount:
|
| 96 |
+
break
|
| 97 |
+
spec = mount.group("spec")
|
| 98 |
+
options = spec.split(",")
|
| 99 |
+
types = [
|
| 100 |
+
option.split("=", 1)[1].lower()
|
| 101 |
+
for option in options
|
| 102 |
+
if option.lower().startswith("type=")
|
| 103 |
+
]
|
| 104 |
+
if types != ["cache"]:
|
| 105 |
+
raise ValueError(
|
| 106 |
+
"Novita Dockerfile rewriting only supports explicit "
|
| 107 |
+
"RUN --mount=type=cache options; unsupported mount "
|
| 108 |
+
"types must be handled by `openenv build` and a "
|
| 109 |
+
"registry image."
|
| 110 |
+
)
|
| 111 |
+
stripped = stripped[mount.end() :]
|
| 112 |
+
line = prefix + stripped
|
| 113 |
+
if not line.rstrip().endswith("\\"):
|
| 114 |
+
in_run = False
|
| 115 |
+
out.append(line)
|
| 116 |
+
return "\n".join(out)
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def _resolve_from_references(content: str) -> str:
|
| 120 |
+
"""Substitute ``ARG`` defaults into ``FROM`` lines and drop ``--platform``.
|
| 121 |
+
|
| 122 |
+
Two parser limitations, handled together because both corrupt the base
|
| 123 |
+
image reference:
|
| 124 |
+
|
| 125 |
+
- The parser stores a ``FROM ${BASE_IMAGE}`` line verbatim, so the template
|
| 126 |
+
would be built from a literal image named ``${BASE_IMAGE}``. Only global
|
| 127 |
+
ARGs (declared before the first ``FROM``) are in scope for a ``FROM``
|
| 128 |
+
line, which is the form every in-repo Dockerfile uses.
|
| 129 |
+
- ``FROM --platform=linux/amd64 python:3.10-slim`` likewise keeps the flag
|
| 130 |
+
as part of the name. Novita builds for its own platform, so the flag is
|
| 131 |
+
dropped rather than propagated.
|
| 132 |
+
|
| 133 |
+
Substitutions are applied longest-name-first so a short unbraced prefix
|
| 134 |
+
cannot corrupt a longer sibling (e.g. ``$BASE`` must not rewrite
|
| 135 |
+
``$BASE_IMAGE`` / ``${BASE_IMAGE}`` into ``python:3.12_IMAGE``).
|
| 136 |
+
"""
|
| 137 |
+
arg_defaults: Dict[str, str] = {}
|
| 138 |
+
for line in content.split("\n"):
|
| 139 |
+
if _FROM_RE.match(line):
|
| 140 |
+
break
|
| 141 |
+
match = _ARG_DEFAULT_RE.match(line)
|
| 142 |
+
if match:
|
| 143 |
+
arg_defaults[match.group("name")] = match.group("value")
|
| 144 |
+
|
| 145 |
+
# Longest names first: `$BASE` must not rewrite `$BASE_IMAGE` mid-token.
|
| 146 |
+
arg_items = sorted(
|
| 147 |
+
arg_defaults.items(), key=lambda item: len(item[0]), reverse=True
|
| 148 |
+
)
|
| 149 |
+
|
| 150 |
+
out: List[str] = []
|
| 151 |
+
for line in content.split("\n"):
|
| 152 |
+
match = _FROM_RE.match(line)
|
| 153 |
+
if not match:
|
| 154 |
+
out.append(line)
|
| 155 |
+
continue
|
| 156 |
+
|
| 157 |
+
reference = match.group("rest").strip()
|
| 158 |
+
reference = re.sub(r"^(--platform=\S+\s*)+", "", reference).strip()
|
| 159 |
+
for name, value in arg_items:
|
| 160 |
+
reference = reference.replace(f"${{{name}}}", value)
|
| 161 |
+
reference = reference.replace(f"${name}", value)
|
| 162 |
+
out.append(f"FROM {reference}")
|
| 163 |
+
return "\n".join(out)
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
def _split_stages(content: str) -> List[Dict[str, Any]]:
|
| 167 |
+
"""Split a Dockerfile into its ``FROM``-delimited stages."""
|
| 168 |
+
stages: List[Dict[str, Any]] = []
|
| 169 |
+
current: Optional[Dict[str, Any]] = None
|
| 170 |
+
for line in content.split("\n"):
|
| 171 |
+
match = _FROM_RE.match(line)
|
| 172 |
+
if match:
|
| 173 |
+
rest = match.group("rest").strip()
|
| 174 |
+
alias_match = re.match(r"(?P<base>.+?)\s+[Aa][Ss]\s+(?P<alias>\S+)$", rest)
|
| 175 |
+
if alias_match:
|
| 176 |
+
base = alias_match.group("base").strip()
|
| 177 |
+
alias = alias_match.group("alias")
|
| 178 |
+
else:
|
| 179 |
+
base, alias = rest, None
|
| 180 |
+
current = {"base": base, "alias": alias, "body": []}
|
| 181 |
+
stages.append(current)
|
| 182 |
+
elif current is not None:
|
| 183 |
+
current["body"].append(line)
|
| 184 |
+
return stages
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
def _dockerfile_declares_user(content: str) -> bool:
|
| 188 |
+
"""Return whether *content* contains a Dockerfile ``USER`` instruction."""
|
| 189 |
+
return any(_USER_RE.match(line) for line in content.splitlines())
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
def _reject_unflattened_copy_from(content: str) -> None:
|
| 193 |
+
"""Reject COPY instructions with ``--from`` left after flattening."""
|
| 194 |
+
if any(_COPY_FROM_REMAINDER_RE.match(line) for line in content.splitlines()):
|
| 195 |
+
raise ValueError(
|
| 196 |
+
"Dockerfile contains a COPY --from instruction that Novita cannot "
|
| 197 |
+
"flatten. Use a simple `COPY --from=<stage> <src> <dst>` form or "
|
| 198 |
+
"build the image with `openenv build` and pass the registry image."
|
| 199 |
+
)
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
def _flatten_multistage(content: str) -> str:
|
| 203 |
+
"""Collapse a multi-stage Dockerfile into a single stage.
|
| 204 |
+
|
| 205 |
+
Novita's parser rejects multi-stage Dockerfiles outright, and that is the
|
| 206 |
+
layout every in-repo OpenEnv environment uses: a ``builder`` stage that runs
|
| 207 |
+
``uv sync`` into ``/app/env/.venv``, then a runtime stage that copies the
|
| 208 |
+
result across. Both stages name the *same* base image, so the build can be
|
| 209 |
+
replayed linearly in one stage -- the builder's ``RUN`` steps execute, then
|
| 210 |
+
each ``COPY --from=builder`` becomes an in-place ``cp``.
|
| 211 |
+
|
| 212 |
+
A copy whose source and destination are equal is dropped: after flattening,
|
| 213 |
+
the file is already at its destination, and Novita rejects an absolute
|
| 214 |
+
source path.
|
| 215 |
+
|
| 216 |
+
Raises:
|
| 217 |
+
ValueError: If the stages do not share one base image, a copy pulls from
|
| 218 |
+
a stage other than the builder, or a ``COPY --from`` cannot be
|
| 219 |
+
flattened.
|
| 220 |
+
"""
|
| 221 |
+
stages = _split_stages(content)
|
| 222 |
+
if len(stages) < 2:
|
| 223 |
+
_reject_unflattened_copy_from(content)
|
| 224 |
+
return content
|
| 225 |
+
|
| 226 |
+
bases = {stage["base"] for stage in stages}
|
| 227 |
+
if len(bases) != 1:
|
| 228 |
+
raise ValueError(
|
| 229 |
+
"Novita templates cannot express a multi-stage Dockerfile whose "
|
| 230 |
+
f"stages use different base images: {sorted(bases)}. Build the "
|
| 231 |
+
"image with `openenv build` and push it with "
|
| 232 |
+
"`openenv push --registry`, then pass the registry reference to "
|
| 233 |
+
"NovitaSandboxProvider(image=...)."
|
| 234 |
+
)
|
| 235 |
+
|
| 236 |
+
builder_alias = stages[0]["alias"]
|
| 237 |
+
if builder_alias is None:
|
| 238 |
+
raise ValueError(
|
| 239 |
+
"The first stage of a multi-stage Dockerfile must be named with "
|
| 240 |
+
"`AS <alias>` so its output can be referenced after flattening."
|
| 241 |
+
)
|
| 242 |
+
|
| 243 |
+
out: List[str] = [f"FROM {stages[0]['base']}"]
|
| 244 |
+
for index, stage in enumerate(stages):
|
| 245 |
+
for line in stage["body"]:
|
| 246 |
+
copy_match = _COPY_FROM_RE.match(line)
|
| 247 |
+
if copy_match is None:
|
| 248 |
+
out.append(line)
|
| 249 |
+
continue
|
| 250 |
+
if index == 0:
|
| 251 |
+
# A copy within the builder stage is already valid as written.
|
| 252 |
+
out.append(line)
|
| 253 |
+
continue
|
| 254 |
+
source_stage = copy_match.group("stage")
|
| 255 |
+
if source_stage != builder_alias:
|
| 256 |
+
raise ValueError(
|
| 257 |
+
f"COPY --from={source_stage} does not reference the builder "
|
| 258 |
+
f"stage ({builder_alias}), so it cannot be flattened into a "
|
| 259 |
+
"single-stage template."
|
| 260 |
+
)
|
| 261 |
+
src = copy_match.group("src")
|
| 262 |
+
dst = copy_match.group("dst")
|
| 263 |
+
if src == dst:
|
| 264 |
+
continue
|
| 265 |
+
out.append(
|
| 266 |
+
f"RUN mkdir -p $(dirname {shlex.quote(dst)}) && "
|
| 267 |
+
f"cp -a {shlex.quote(src)} {shlex.quote(dst)}"
|
| 268 |
+
)
|
| 269 |
+
|
| 270 |
+
flattened = "\n".join(out)
|
| 271 |
+
_reject_unflattened_copy_from(flattened)
|
| 272 |
+
return flattened
|
| 273 |
+
|
| 274 |
+
|
| 275 |
+
def _prepare_dockerfile(content: str) -> str:
|
| 276 |
+
"""Rewrite an OpenEnv Dockerfile into what Novita's template parser accepts.
|
| 277 |
+
|
| 278 |
+
Applies, in order: BuildKit ``--mount`` stripping, ``ARG``/``--platform``
|
| 279 |
+
resolution in ``FROM``, then multi-stage flattening. Each transform
|
| 280 |
+
compensates for a parser limitation rather than changing the build's
|
| 281 |
+
meaning.
|
| 282 |
+
"""
|
| 283 |
+
return _flatten_multistage(_resolve_from_references(_strip_mount_flags(content)))
|
| 284 |
+
|
| 285 |
+
|
| 286 |
+
def _require_secure_url(url: str) -> str:
|
| 287 |
+
"""Enforce https/wss transport (RFC 002 security invariant S1).
|
| 288 |
+
|
| 289 |
+
``EnvClient`` derives its WebSocket URL from this base URL, so a plaintext
|
| 290 |
+
URL would become a cleartext ``ws://`` connection. The offending URL is
|
| 291 |
+
deliberately omitted from the error: a sandbox host is reachable only
|
| 292 |
+
through the account that owns it, so the address is treated as
|
| 293 |
+
account-scoped rather than pasted into logs.
|
| 294 |
+
"""
|
| 295 |
+
if (
|
| 296 |
+
not isinstance(url, str)
|
| 297 |
+
or not url.lower().startswith("https://")
|
| 298 |
+
or "://" in url[len("https://") :]
|
| 299 |
+
):
|
| 300 |
+
raise RuntimeError(
|
| 301 |
+
"Novita sandbox returned a non-HTTPS host URL. OpenEnv requires an "
|
| 302 |
+
"https/wss base_url so EnvClient traffic is encrypted. Refusing to "
|
| 303 |
+
"connect over plaintext."
|
| 304 |
+
)
|
| 305 |
+
|
| 306 |
+
# With NOVITA_DEBUG set, the SDK's `get_host` returns `localhost:<port>`
|
| 307 |
+
# instead of the sandbox host. Adding the scheme would yield a URL that
|
| 308 |
+
# looks valid and silently targets whatever is listening on the caller's
|
| 309 |
+
# machine, so reject it explicitly rather than connect to the wrong thing.
|
| 310 |
+
hostname = url.split("://", 1)[1].split("/", 1)[0].rsplit(":", 1)[0]
|
| 311 |
+
if hostname in ("localhost", "127.0.0.1", "::1"):
|
| 312 |
+
raise RuntimeError(
|
| 313 |
+
"Novita sandbox returned a loopback host URL, which means the SDK is "
|
| 314 |
+
"running with NOVITA_DEBUG enabled. Debug mode bypasses the sandbox "
|
| 315 |
+
"host and would connect to the local machine instead; unset "
|
| 316 |
+
"NOVITA_DEBUG to reach the sandbox."
|
| 317 |
+
)
|
| 318 |
+
|
| 319 |
+
return url
|
| 320 |
+
|
| 321 |
+
|
| 322 |
+
def _require_bare_host(host: str) -> str:
|
| 323 |
+
"""Require the SDK host value to contain no URL scheme."""
|
| 324 |
+
if not isinstance(host, str) or not host or "://" in host:
|
| 325 |
+
raise RuntimeError(
|
| 326 |
+
"Novita sandbox returned an invalid host. Expected a bare hostname "
|
| 327 |
+
"without a URL scheme."
|
| 328 |
+
)
|
| 329 |
+
return host
|
| 330 |
+
|
| 331 |
+
|
| 332 |
+
def _raise_install_error(exc: ImportError) -> None:
|
| 333 |
+
raise RuntimeError(
|
| 334 |
+
"Novita sandbox support requires optional dependencies. "
|
| 335 |
+
"Install them with `pip install openenv[novita]`."
|
| 336 |
+
) from exc
|
| 337 |
+
|
| 338 |
+
|
| 339 |
+
class _DefaultNovitaAdapter:
|
| 340 |
+
"""Thin adapter over the ``novita-sandbox`` SDK.
|
| 341 |
+
|
| 342 |
+
The provider talks to this private adapter instead of spreading SDK details
|
| 343 |
+
through its own logic; tests inject a duck-typed fake in its place. Keeping
|
| 344 |
+
the SDK surface here is what makes the provider testable without a Novita
|
| 345 |
+
account and localizes API churn (RFC 002 implementation-hygiene invariant).
|
| 346 |
+
"""
|
| 347 |
+
|
| 348 |
+
def __init__(
|
| 349 |
+
self,
|
| 350 |
+
*,
|
| 351 |
+
api_key: Optional[str],
|
| 352 |
+
domain: Optional[str],
|
| 353 |
+
):
|
| 354 |
+
try:
|
| 355 |
+
from novita_sandbox import Novita
|
| 356 |
+
except ImportError as exc: # pragma: no cover - exercised via provider tests
|
| 357 |
+
_raise_install_error(exc)
|
| 358 |
+
|
| 359 |
+
# `api_key`/`domain` fall back to NOVITA_API_KEY / NOVITA_DOMAIN inside
|
| 360 |
+
# the SDK's ConnectionConfig, so `None` is the documented "use the env
|
| 361 |
+
# var" spelling rather than a missing value.
|
| 362 |
+
self._novita = Novita(api_key=api_key, domain=domain)
|
| 363 |
+
|
| 364 |
+
def create_sandbox(
|
| 365 |
+
self,
|
| 366 |
+
*,
|
| 367 |
+
image: Optional[str],
|
| 368 |
+
template: Optional[str],
|
| 369 |
+
env_vars: Optional[Dict[str, str]],
|
| 370 |
+
timeout: int,
|
| 371 |
+
metadata: Optional[Dict[str, str]],
|
| 372 |
+
secure: Optional[bool],
|
| 373 |
+
) -> Any:
|
| 374 |
+
# Exactly one source: a registry image (resolved and cached by the SDK
|
| 375 |
+
# itself) or an already-built template id from `image_from_dockerfile`.
|
| 376 |
+
kwargs: Dict[str, Any] = {
|
| 377 |
+
"timeout": timeout,
|
| 378 |
+
}
|
| 379 |
+
|
| 380 |
+
if template is not None:
|
| 381 |
+
kwargs["template"] = template
|
| 382 |
+
else:
|
| 383 |
+
from novita_sandbox import wait_for_timeout
|
| 384 |
+
|
| 385 |
+
# `image` is an OCI reference; the SDK resolves it to a template
|
| 386 |
+
# itself (Template.from_image -> Template.build) and caches the
|
| 387 |
+
# result by image fingerprint, so repeated starts do not rebuild.
|
| 388 |
+
#
|
| 389 |
+
# The `build` block exists solely to pin the template's start
|
| 390 |
+
# command to a keepalive. `from_image` defaults to
|
| 391 |
+
# `inherit_config=True`, which restores the image's ENV/WORKDIR
|
| 392 |
+
# *and* its ENTRYPOINT/CMD as the template start command -- for an
|
| 393 |
+
# OpenEnv image that CMD is the uvicorn server, so leaving it in
|
| 394 |
+
# place would bind port 8000 before this provider launches its own
|
| 395 |
+
# copy. Setting `start_cmd` here wins over the inherited value (the
|
| 396 |
+
# caller always overrides the image). The SDK attempts to restore
|
| 397 |
+
# the image's ENV, but callers must use explicit paths when a
|
| 398 |
+
# registry config cannot be read during OCI template conversion.
|
| 399 |
+
ready_cmd = wait_for_timeout(_KEEPALIVE_READY_MS)
|
| 400 |
+
kwargs["image"] = image
|
| 401 |
+
kwargs["build"] = {
|
| 402 |
+
"cmd": _KEEPALIVE_CMD,
|
| 403 |
+
# Sandbox.create fingerprints this mapping with json.dumps
|
| 404 |
+
# before converting it into a TemplateBuilder. Keep the
|
| 405 |
+
# serialized command here; Template.set_start_cmd accepts the
|
| 406 |
+
# string as well as the ReadyCmd helper.
|
| 407 |
+
"ready_cmd": ready_cmd.get_cmd(),
|
| 408 |
+
}
|
| 409 |
+
|
| 410 |
+
if env_vars:
|
| 411 |
+
kwargs["envs"] = dict(env_vars)
|
| 412 |
+
if metadata:
|
| 413 |
+
kwargs["metadata"] = dict(metadata)
|
| 414 |
+
if secure is not None:
|
| 415 |
+
kwargs["secure"] = secure
|
| 416 |
+
|
| 417 |
+
return self._novita.sandbox.create(**kwargs)
|
| 418 |
+
|
| 419 |
+
def exec(
|
| 420 |
+
self,
|
| 421 |
+
sandbox: Any,
|
| 422 |
+
command: str,
|
| 423 |
+
*,
|
| 424 |
+
timeout: float = 10,
|
| 425 |
+
background: bool = False,
|
| 426 |
+
user: Optional[str] = None,
|
| 427 |
+
) -> str:
|
| 428 |
+
"""Run *command* through a login shell inside *sandbox*, returning stdout.
|
| 429 |
+
|
| 430 |
+
The SDK runs commands as ``/bin/bash -l -c <command>`` and raises
|
| 431 |
+
``CommandExitException`` on a non-zero exit. The discovery and liveness
|
| 432 |
+
probes below are *expected* to exit non-zero ("no openenv.yaml", "process
|
| 433 |
+
is dead"), so the exit code is swallowed and stdout returned; callers
|
| 434 |
+
distinguish states by the marker the command echoes, not by the status.
|
| 435 |
+
"""
|
| 436 |
+
from novita_sandbox import CommandExitException
|
| 437 |
+
|
| 438 |
+
run_kwargs: Dict[str, Any] = {
|
| 439 |
+
"timeout": timeout,
|
| 440 |
+
"background": background,
|
| 441 |
+
}
|
| 442 |
+
if user is not None:
|
| 443 |
+
run_kwargs["user"] = user
|
| 444 |
+
|
| 445 |
+
try:
|
| 446 |
+
result = sandbox.commands.run(command, **run_kwargs)
|
| 447 |
+
except CommandExitException as exc:
|
| 448 |
+
return exc.stdout or ""
|
| 449 |
+
except ImportError: # pragma: no cover - SDK always exports this
|
| 450 |
+
result = sandbox.commands.run(command, **run_kwargs)
|
| 451 |
+
|
| 452 |
+
return getattr(result, "stdout", "") or ""
|
| 453 |
+
|
| 454 |
+
def host(self, sandbox: Any, port: int) -> str:
|
| 455 |
+
"""Return the public host (no scheme) exposing *port* on *sandbox*."""
|
| 456 |
+
return str(sandbox.get_host(port))
|
| 457 |
+
|
| 458 |
+
def kill(self, sandbox: Any) -> None:
|
| 459 |
+
sandbox.kill()
|
| 460 |
+
|
| 461 |
+
def build_template(
|
| 462 |
+
self,
|
| 463 |
+
*,
|
| 464 |
+
dockerfile_content: str,
|
| 465 |
+
context_dir: str,
|
| 466 |
+
name: str,
|
| 467 |
+
cpu_count: int,
|
| 468 |
+
memory_mb: int,
|
| 469 |
+
on_build_logs: Optional[Callable[[Any], None]],
|
| 470 |
+
) -> str:
|
| 471 |
+
"""Build a Novita template from Dockerfile *content* and return its id.
|
| 472 |
+
|
| 473 |
+
Uses ``Template.from_dockerfile`` to translate the Dockerfile into
|
| 474 |
+
builder instructions, pins the template's start command to a keepalive
|
| 475 |
+
(so the image's own ``CMD`` does not bind port 8000 before the provider
|
| 476 |
+
launches the server), and builds it. ``file_context_path`` is what makes
|
| 477 |
+
relative ``COPY`` sources resolve against the build context.
|
| 478 |
+
|
| 479 |
+
Returns:
|
| 480 |
+
`str`: The built template id, to pass to ``Sandbox.create``.
|
| 481 |
+
"""
|
| 482 |
+
from novita_sandbox import Template, wait_for_timeout
|
| 483 |
+
|
| 484 |
+
template = Template(file_context_path=context_dir)
|
| 485 |
+
builder = template.from_dockerfile(dockerfile_content)
|
| 486 |
+
|
| 487 |
+
# The parser rewrites USER to "user" when the Dockerfile declares none
|
| 488 |
+
# (dockerfile_parser.py: `if not user_changed: set_user("user")`). For an
|
| 489 |
+
# OpenEnv image that is wrong: `WORKDIR /app`, created by root, stays
|
| 490 |
+
# root-owned, so the server and every command the environment later runs
|
| 491 |
+
# in-process (`TB2_MODE=local`, and any env doing the same) fail with
|
| 492 |
+
# "Permission denied" on the install tree. Preserve an explicit Dockerfile
|
| 493 |
+
# USER, though, so the provider does not override the image author's
|
| 494 |
+
# requested runtime identity.
|
| 495 |
+
if not _dockerfile_declares_user(dockerfile_content):
|
| 496 |
+
builder.set_user(_SANDBOX_USER)
|
| 497 |
+
|
| 498 |
+
# `set_start_cmd` is the only way to pin the start command and it
|
| 499 |
+
# requires a readiness check, so the keepalive gets a short timer. The
|
| 500 |
+
# provider's own `wait_for_ready` does the real health polling.
|
| 501 |
+
builder.set_start_cmd(_KEEPALIVE_CMD, wait_for_timeout(_KEEPALIVE_READY_MS))
|
| 502 |
+
|
| 503 |
+
build_kwargs: Dict[str, Any] = {
|
| 504 |
+
"cpu_count": cpu_count,
|
| 505 |
+
"memory_mb": memory_mb,
|
| 506 |
+
}
|
| 507 |
+
if on_build_logs is not None:
|
| 508 |
+
build_kwargs["on_build_logs"] = on_build_logs
|
| 509 |
+
|
| 510 |
+
# Build through the configured namespace so the SDK merges the
|
| 511 |
+
# api_key/domain passed to Novita(...) into Template.build's connection
|
| 512 |
+
# config. Calling Template.build directly would fall back to an empty
|
| 513 |
+
# ConnectionConfig when those values are not present in the environment.
|
| 514 |
+
info = self._novita.template.build(builder, name, **build_kwargs)
|
| 515 |
+
return str(info.template_id)
|
| 516 |
+
|
| 517 |
+
|
| 518 |
+
class NovitaSandboxProvider(ContainerProvider):
|
| 519 |
+
"""
|
| 520 |
+
Container provider that runs environments in Novita AI sandboxes.
|
| 521 |
+
|
| 522 |
+
``start_container`` accepts either form of source:
|
| 523 |
+
|
| 524 |
+
- A Docker/OCI registry reference (e.g. ``"ghcr.io/org/env:tag"``), which is
|
| 525 |
+
the form the ``ContainerProvider`` contract specifies. The SDK resolves it
|
| 526 |
+
into a Novita template internally and caches it by image fingerprint.
|
| 527 |
+
- A ``"template:<id>"`` reference returned by
|
| 528 |
+
:meth:`image_from_dockerfile`, which builds a template from a local
|
| 529 |
+
Dockerfile.
|
| 530 |
+
|
| 531 |
+
:meth:`image_from_dockerfile` rewrites the Dockerfile for Novita's template
|
| 532 |
+
parser, which does not accept multi-stage build definitions. The rewrite is
|
| 533 |
+
mechanical and preserves the build's meaning: BuildKit ``--mount`` flags are
|
| 534 |
+
stripped, ``ARG``/``--platform`` in ``FROM`` lines are resolved, and a
|
| 535 |
+
two-stage build whose stages share one base image is replayed as a single
|
| 536 |
+
stage (see `_prepare_dockerfile`). A Dockerfile that does not fit those
|
| 537 |
+
rules raises `ValueError` with the registry route as the alternative.
|
| 538 |
+
|
| 539 |
+
The environment runs untrusted code, so the provider is secure by default: it
|
| 540 |
+
enforces https/wss transport (S1) and never surfaces raw sandbox output
|
| 541 |
+
unless ``surface_server_logs=True`` (S4).
|
| 542 |
+
|
| 543 |
+
Only one sandbox is active per provider: calling ``start_container`` again
|
| 544 |
+
before ``stop_container()``/``close()`` raises ``RuntimeError`` rather than
|
| 545 |
+
orphaning the running sandbox.
|
| 546 |
+
|
| 547 |
+
Examples:
|
| 548 |
+
|
| 549 |
+
```python
|
| 550 |
+
# From a pre-built registry image
|
| 551 |
+
with NovitaSandboxProvider(image="ghcr.io/org/echo-env:latest") as provider:
|
| 552 |
+
base_url = provider.start_container()
|
| 553 |
+
provider.wait_for_ready(base_url)
|
| 554 |
+
|
| 555 |
+
# From a local Dockerfile (builds a template on first use)
|
| 556 |
+
image = NovitaSandboxProvider.image_from_dockerfile(
|
| 557 |
+
"envs/echo_env/server/Dockerfile"
|
| 558 |
+
)
|
| 559 |
+
with NovitaSandboxProvider(image=image) as provider:
|
| 560 |
+
base_url = provider.start_container()
|
| 561 |
+
# sandbox killed on exit
|
| 562 |
+
```
|
| 563 |
+
"""
|
| 564 |
+
|
| 565 |
+
# Prepared Dockerfile content, keyed by "template:<abs_path>". Populated by
|
| 566 |
+
# `image_from_dockerfile` so `start_container` can build the template later,
|
| 567 |
+
# mirroring DaytonaProvider / ModalProvider.
|
| 568 |
+
_dockerfile_registry: Dict[str, Dict[str, Any]] = {}
|
| 569 |
+
|
| 570 |
+
@classmethod
|
| 571 |
+
def image_from_dockerfile(
|
| 572 |
+
cls,
|
| 573 |
+
dockerfile_path: str,
|
| 574 |
+
context_dir: Optional[str] = None,
|
| 575 |
+
) -> str:
|
| 576 |
+
"""Validate a Dockerfile and return a ``template:`` reference for
|
| 577 |
+
:meth:`start_container`.
|
| 578 |
+
|
| 579 |
+
Eagerly validates the Dockerfile (existence, COPY sources) and stores
|
| 580 |
+
the rewritten content in an internal registry. The Novita template is
|
| 581 |
+
built later, inside ``start_container``, by the adapter's
|
| 582 |
+
``build_template`` — building needs credentials and network, which this
|
| 583 |
+
class-level helper deliberately does not.
|
| 584 |
+
|
| 585 |
+
The rewrite compensates for Novita's Dockerfile parser, which rejects
|
| 586 |
+
multi-stage build definitions (the layout every in-repo OpenEnv
|
| 587 |
+
environment uses). BuildKit ``--mount`` flags are stripped, ``ARG`` and
|
| 588 |
+
``--platform`` in ``FROM`` lines are resolved, and a two-stage build
|
| 589 |
+
whose stages share one base image is replayed as a single stage.
|
| 590 |
+
|
| 591 |
+
Args:
|
| 592 |
+
dockerfile_path (`str`):
|
| 593 |
+
Path to the Dockerfile on disk.
|
| 594 |
+
context_dir (`str`, *optional*):
|
| 595 |
+
Build context directory, used to resolve relative ``COPY``
|
| 596 |
+
sources. Defaults to the Dockerfile's grandparent directory,
|
| 597 |
+
matching the `openenv init` convention where Dockerfiles live in
|
| 598 |
+
`<env>/server/Dockerfile` and the build context is `<env>/`.
|
| 599 |
+
|
| 600 |
+
Returns:
|
| 601 |
+
`str`: A `"template:<abs_path>"` reference to pass to
|
| 602 |
+
`start_container` or the constructor's `image`.
|
| 603 |
+
|
| 604 |
+
Raises:
|
| 605 |
+
FileNotFoundError: If *dockerfile_path* does not exist.
|
| 606 |
+
ValueError: If *context_dir* does not exist, if COPY sources cannot
|
| 607 |
+
be found under the resolved context directory, or if the
|
| 608 |
+
Dockerfile cannot be expressed as a Novita template (see
|
| 609 |
+
`_prepare_dockerfile`).
|
| 610 |
+
|
| 611 |
+
Examples:
|
| 612 |
+
|
| 613 |
+
```python
|
| 614 |
+
image = NovitaSandboxProvider.image_from_dockerfile(
|
| 615 |
+
"envs/echo_env/server/Dockerfile"
|
| 616 |
+
)
|
| 617 |
+
provider = NovitaSandboxProvider(image=image)
|
| 618 |
+
base_url = provider.start_container()
|
| 619 |
+
```
|
| 620 |
+
"""
|
| 621 |
+
import pathlib
|
| 622 |
+
|
| 623 |
+
src = pathlib.Path(dockerfile_path).resolve()
|
| 624 |
+
if not src.is_file():
|
| 625 |
+
raise FileNotFoundError(f"Dockerfile not found: {dockerfile_path}")
|
| 626 |
+
|
| 627 |
+
if context_dir is not None:
|
| 628 |
+
ctx = pathlib.Path(context_dir)
|
| 629 |
+
if not ctx.is_dir():
|
| 630 |
+
raise ValueError(f"context_dir does not exist: {context_dir}")
|
| 631 |
+
else:
|
| 632 |
+
# Default: grandparent of the Dockerfile, matching the openenv init
|
| 633 |
+
# layout (<env>/server/Dockerfile -> <env>/).
|
| 634 |
+
ctx = src.parent.parent
|
| 635 |
+
|
| 636 |
+
prepared = _prepare_dockerfile(src.read_text())
|
| 637 |
+
|
| 638 |
+
# Validate that COPY sources exist under the context directory. This
|
| 639 |
+
# catches mismatches early (e.g. a Dockerfile expecting the repo root as
|
| 640 |
+
# context when we defaulted to the env directory).
|
| 641 |
+
for line in prepared.splitlines():
|
| 642 |
+
match = re.match(r"^\s*COPY\s+(?!--from=)(\S+)\s+", line, re.IGNORECASE)
|
| 643 |
+
if not match:
|
| 644 |
+
continue
|
| 645 |
+
copy_src = match.group(1)
|
| 646 |
+
if copy_src.startswith("/"):
|
| 647 |
+
continue
|
| 648 |
+
resolved = ctx / copy_src
|
| 649 |
+
if not resolved.exists() and not any(ctx.glob(copy_src)):
|
| 650 |
+
raise ValueError(
|
| 651 |
+
f"Dockerfile COPY source '{copy_src}' not found under "
|
| 652 |
+
f"context_dir '{ctx}'. This Dockerfile may expect a "
|
| 653 |
+
"different build context (e.g. the repo root). Pass "
|
| 654 |
+
"context_dir explicitly."
|
| 655 |
+
)
|
| 656 |
+
|
| 657 |
+
cls._dockerfile_registry[str(src)] = {
|
| 658 |
+
"content": prepared,
|
| 659 |
+
"context_dir": str(ctx),
|
| 660 |
+
}
|
| 661 |
+
|
| 662 |
+
return f"template:{src}"
|
| 663 |
+
|
| 664 |
+
def _resolve_template_ref(self, reference: str) -> str:
|
| 665 |
+
"""Build the template for a ``template:<path>`` reference; return its id."""
|
| 666 |
+
path = reference[len("template:") :]
|
| 667 |
+
meta = self._dockerfile_registry.get(path)
|
| 668 |
+
if meta is None:
|
| 669 |
+
raise ValueError(
|
| 670 |
+
f"No registered Dockerfile metadata for {path}. Call "
|
| 671 |
+
"NovitaSandboxProvider.image_from_dockerfile() first."
|
| 672 |
+
)
|
| 673 |
+
|
| 674 |
+
return self._adapter.build_template(
|
| 675 |
+
dockerfile_content=meta["content"],
|
| 676 |
+
context_dir=meta["context_dir"],
|
| 677 |
+
name=self._template_name or self._default_template_name(path),
|
| 678 |
+
cpu_count=self._cpu_count,
|
| 679 |
+
memory_mb=self._memory_mb,
|
| 680 |
+
on_build_logs=self._on_build_logs,
|
| 681 |
+
)
|
| 682 |
+
|
| 683 |
+
@staticmethod
|
| 684 |
+
def _default_template_name(dockerfile_path: str) -> str:
|
| 685 |
+
"""Derive a stable template name from the Dockerfile's env directory.
|
| 686 |
+
|
| 687 |
+
The env directory keeps the template recognizable in the Novita
|
| 688 |
+
dashboard; the content hash keeps two different Dockerfiles from
|
| 689 |
+
colliding on one name, while a rebuild of identical content still hits
|
| 690 |
+
the build cache.
|
| 691 |
+
"""
|
| 692 |
+
import hashlib
|
| 693 |
+
import pathlib
|
| 694 |
+
|
| 695 |
+
src = pathlib.Path(dockerfile_path)
|
| 696 |
+
env_dir = src.parent.parent.name or src.parent.name
|
| 697 |
+
slug = re.sub(r"[^a-z0-9-]+", "-", env_dir.lower().replace("_", "-"))
|
| 698 |
+
digest = hashlib.sha256(src.read_bytes()).hexdigest()[:8]
|
| 699 |
+
return f"openenv-{slug.strip('-')}-{digest}"
|
| 700 |
+
|
| 701 |
+
def __init__(
|
| 702 |
+
self,
|
| 703 |
+
*,
|
| 704 |
+
image: Optional[str] = None,
|
| 705 |
+
env_vars: Optional[Dict[str, str]] = None,
|
| 706 |
+
api_key: Optional[str] = None,
|
| 707 |
+
domain: Optional[str] = None,
|
| 708 |
+
timeout: int = _DEFAULT_SANDBOX_TIMEOUT_S,
|
| 709 |
+
metadata: Optional[Dict[str, str]] = None,
|
| 710 |
+
secure: Optional[bool] = None,
|
| 711 |
+
cmd: Optional[str] = None,
|
| 712 |
+
working_directory: Optional[str] = None,
|
| 713 |
+
surface_server_logs: bool = False,
|
| 714 |
+
cpu_count: int = _DEFAULT_TEMPLATE_CPU,
|
| 715 |
+
memory_mb: int = _DEFAULT_TEMPLATE_MEMORY_MB,
|
| 716 |
+
template_name: Optional[str] = None,
|
| 717 |
+
on_build_logs: Optional[Callable[[Any], None]] = None,
|
| 718 |
+
_adapter: Any = None,
|
| 719 |
+
):
|
| 720 |
+
"""
|
| 721 |
+
Args:
|
| 722 |
+
image (`str`, *optional*):
|
| 723 |
+
Registry image reference (e.g. `"ghcr.io/org/env:latest"`) or a
|
| 724 |
+
`"template:<id>"` reference from
|
| 725 |
+
[`~image_from_dockerfile`], to use when `start_container()` is
|
| 726 |
+
called without an image.
|
| 727 |
+
env_vars (`dict`, *optional*):
|
| 728 |
+
Environment variables to use when `start_container()` is called
|
| 729 |
+
without explicit `env_vars`.
|
| 730 |
+
api_key (`str`, *optional*):
|
| 731 |
+
Novita API key. Falls back to the `NOVITA_API_KEY` environment
|
| 732 |
+
variable.
|
| 733 |
+
domain (`str`, *optional*):
|
| 734 |
+
Novita region domain. Falls back to the `NOVITA_DOMAIN`
|
| 735 |
+
environment variable, then to the SDK default (`us-phx-1`).
|
| 736 |
+
Features marked modern-domain-only (snapshots, secrets) are
|
| 737 |
+
unavailable on the legacy `us-virginia-1` domain.
|
| 738 |
+
timeout (`int`, *optional*, defaults to `3600`):
|
| 739 |
+
Hard sandbox lifetime in seconds. Counts down from creation
|
| 740 |
+
regardless of activity and always fires, so it must exceed the
|
| 741 |
+
longest expected episode.
|
| 742 |
+
metadata (`dict`, *optional*):
|
| 743 |
+
Arbitrary key/value labels on the sandbox. The `idle_timeout`
|
| 744 |
+
key is meaningful to Novita and takes seconds as a **string**
|
| 745 |
+
(e.g. `{"idle_timeout": "900"}`); omitting it disables the idle
|
| 746 |
+
timer, which is the conservative default for RL rollouts — an
|
| 747 |
+
automatic transition can drop a live WebSocket mid-episode
|
| 748 |
+
(RFC 002 invariant 6).
|
| 749 |
+
secure (`bool`, *optional*):
|
| 750 |
+
Whether envd requires its access token. `None` uses the SDK
|
| 751 |
+
default (secured on modern domains). This governs the sandbox
|
| 752 |
+
control plane, not the exposed server port.
|
| 753 |
+
cmd (`str`, *optional*):
|
| 754 |
+
Shell command to start the server inside the sandbox. When
|
| 755 |
+
omitted, the command is auto-discovered from `openenv.yaml`.
|
| 756 |
+
working_directory (`str`, *optional*):
|
| 757 |
+
Directory to `cd` into before running `cmd`.
|
| 758 |
+
surface_server_logs (`bool`, *optional*, defaults to `False`):
|
| 759 |
+
When `False` (default), captured sandbox output is withheld from
|
| 760 |
+
raised errors so secrets the workload printed cannot leak into
|
| 761 |
+
orchestrator/CI logs. When `True`, a best-effort redacted,
|
| 762 |
+
length-bounded excerpt is included in startup-crash errors (S4).
|
| 763 |
+
cpu_count (`int`, *optional*, defaults to `2`):
|
| 764 |
+
vCPU cores for sandboxes built from a Dockerfile via
|
| 765 |
+
[`~image_from_dockerfile`]. Ignored for registry images, whose
|
| 766 |
+
resources come from the image's own template.
|
| 767 |
+
memory_mb (`int`, *optional*, defaults to `1024`):
|
| 768 |
+
Memory in MiB for sandboxes built from a Dockerfile. Ignored for
|
| 769 |
+
registry images.
|
| 770 |
+
template_name (`str`, *optional*):
|
| 771 |
+
Template name to register when building from a Dockerfile.
|
| 772 |
+
Defaults to a name derived from the Dockerfile's directory plus a
|
| 773 |
+
content hash, so re-running with the same Dockerfile reuses the
|
| 774 |
+
build cache.
|
| 775 |
+
on_build_logs (`Callable`, *optional*):
|
| 776 |
+
Callback receiving build-log entries while a Dockerfile template
|
| 777 |
+
is built. Off by default: build logs are not redacted, so they
|
| 778 |
+
are withheld rather than printed (RFC 002 S4). Pass a callable to
|
| 779 |
+
surface them.
|
| 780 |
+
_adapter (`Any`, *optional*):
|
| 781 |
+
Injection seam for tests; a duck-typed fake replaces the SDK.
|
| 782 |
+
"""
|
| 783 |
+
self._image = image
|
| 784 |
+
self._env_vars = env_vars
|
| 785 |
+
self._timeout = timeout
|
| 786 |
+
self._metadata = metadata
|
| 787 |
+
self._secure = secure
|
| 788 |
+
self._cmd = cmd
|
| 789 |
+
self._working_directory = working_directory
|
| 790 |
+
self.surface_server_logs = surface_server_logs
|
| 791 |
+
self._cpu_count = cpu_count
|
| 792 |
+
self._memory_mb = memory_mb
|
| 793 |
+
self._template_name = template_name
|
| 794 |
+
self._on_build_logs = on_build_logs
|
| 795 |
+
|
| 796 |
+
self._sandbox: Any = None
|
| 797 |
+
self._base_url: Optional[str] = None
|
| 798 |
+
self._server_user: Optional[str] = None
|
| 799 |
+
# Injected env-var values, used to scrub captured server output before
|
| 800 |
+
# it is ever surfaced in an error.
|
| 801 |
+
self._redact_values: set[str] = set()
|
| 802 |
+
|
| 803 |
+
if _adapter is None:
|
| 804 |
+
# Build the client eagerly so credential/region problems surface at
|
| 805 |
+
# construction rather than mid-episode, matching ModalProvider.
|
| 806 |
+
self._adapter: Any = _DefaultNovitaAdapter(api_key=api_key, domain=domain)
|
| 807 |
+
else:
|
| 808 |
+
self._adapter = _adapter
|
| 809 |
+
|
| 810 |
+
def _discover_server_cmd(self, port: int = _DEFAULT_NOVITA_PORT) -> str:
|
| 811 |
+
"""Discover the server command from ``openenv.yaml`` inside the sandbox.
|
| 812 |
+
|
| 813 |
+
Finds the file, reads the ``app`` field, and constructs a command of the
|
| 814 |
+
form ``cd <env_root> && python -m uvicorn <app> --host 0.0.0.0 --port <port>``.
|
| 815 |
+
|
| 816 |
+
Raises:
|
| 817 |
+
ValueError: If ``openenv.yaml`` is not found or lacks an ``app`` field.
|
| 818 |
+
"""
|
| 819 |
+
yaml_path = self._find_openenv_yaml()
|
| 820 |
+
if yaml_path is None:
|
| 821 |
+
raise ValueError(
|
| 822 |
+
"Could not find openenv.yaml inside the sandbox. Pass an "
|
| 823 |
+
"explicit cmd= to NovitaSandboxProvider or start_container()."
|
| 824 |
+
)
|
| 825 |
+
|
| 826 |
+
content = self._adapter.exec(self._sandbox, f"cat {shlex.quote(yaml_path)}")
|
| 827 |
+
app = self._parse_app_field(content)
|
| 828 |
+
if app is None:
|
| 829 |
+
raise ValueError(
|
| 830 |
+
f"openenv.yaml at {yaml_path} does not contain an 'app' field. "
|
| 831 |
+
"Pass an explicit cmd= to NovitaSandboxProvider or start_container()."
|
| 832 |
+
)
|
| 833 |
+
|
| 834 |
+
# The directory containing openenv.yaml is the env root
|
| 835 |
+
env_root = yaml_path.rsplit("/", 1)[0]
|
| 836 |
+
return (
|
| 837 |
+
f"cd {shlex.quote(env_root)} && "
|
| 838 |
+
f"python -m uvicorn {shlex.quote(app)} --host 0.0.0.0 --port {port}"
|
| 839 |
+
)
|
| 840 |
+
|
| 841 |
+
def _find_openenv_yaml(self) -> Optional[str]:
|
| 842 |
+
"""Locate ``openenv.yaml`` inside the sandbox.
|
| 843 |
+
|
| 844 |
+
Tries the modern layout path ``/app/env/openenv.yaml`` first, then falls
|
| 845 |
+
back to a ``find`` command for the old layout.
|
| 846 |
+
"""
|
| 847 |
+
# Fast path: modern Dockerfile layout
|
| 848 |
+
out = self._adapter.exec(
|
| 849 |
+
self._sandbox, "test -f /app/env/openenv.yaml && echo found"
|
| 850 |
+
)
|
| 851 |
+
if "found" in (out or ""):
|
| 852 |
+
return "/app/env/openenv.yaml"
|
| 853 |
+
|
| 854 |
+
# Fallback: search for it (redirect stderr so error messages like
|
| 855 |
+
# "No such file or directory" don't get mistaken for paths).
|
| 856 |
+
path = self._adapter.exec(
|
| 857 |
+
self._sandbox,
|
| 858 |
+
"find /app -maxdepth 4 -name openenv.yaml -print -quit 2>/dev/null",
|
| 859 |
+
).strip()
|
| 860 |
+
if path and path.startswith("/"):
|
| 861 |
+
return path
|
| 862 |
+
|
| 863 |
+
return None
|
| 864 |
+
|
| 865 |
+
@staticmethod
|
| 866 |
+
def _parse_app_field(yaml_content: str) -> Optional[str]:
|
| 867 |
+
"""Extract the ``app`` value from raw openenv.yaml content.
|
| 868 |
+
|
| 869 |
+
Uses PyYAML to handle comments, quotes, and nested keys correctly.
|
| 870 |
+
"""
|
| 871 |
+
return parse_openenv_app_field(yaml_content)
|
| 872 |
+
|
| 873 |
+
def start_container(
|
| 874 |
+
self,
|
| 875 |
+
image: Optional[str] = None,
|
| 876 |
+
port: Optional[int] = None,
|
| 877 |
+
env_vars: Optional[Dict[str, str]] = None,
|
| 878 |
+
**kwargs: Any,
|
| 879 |
+
) -> str:
|
| 880 |
+
"""
|
| 881 |
+
Create a Novita sandbox and start an OpenEnv server inside it.
|
| 882 |
+
|
| 883 |
+
Args:
|
| 884 |
+
image (`str`, *optional*):
|
| 885 |
+
Either a registry image reference (e.g.
|
| 886 |
+
`"ghcr.io/org/env:latest"`) or a `"template:<path>"` reference
|
| 887 |
+
returned by [`~image_from_dockerfile`]. May be omitted when
|
| 888 |
+
supplied to the constructor.
|
| 889 |
+
port (`int`, *optional*):
|
| 890 |
+
Must be `None` or `8000`. Novita exposes port 8000 on the
|
| 891 |
+
sandbox host; other ports raise `ValueError`.
|
| 892 |
+
env_vars (`dict`, *optional*):
|
| 893 |
+
Environment variables forwarded to the sandbox, overriding the
|
| 894 |
+
constructor's.
|
| 895 |
+
**kwargs:
|
| 896 |
+
`cmd` (`str`) to override the server command. For a registry
|
| 897 |
+
OCI image, pass the command explicitly together with any
|
| 898 |
+
runtime environment the image needs: Novita may not be able to
|
| 899 |
+
restore the image's ``ENV`` while converting it to a template.
|
| 900 |
+
For example, the tbench2 image requires:
|
| 901 |
+
|
| 902 |
+
``cmd="cd /app/env && PYTHONPATH=/app/env "
|
| 903 |
+
"/app/.venv/bin/python -m uvicorn server.app:app "
|
| 904 |
+
"--host 0.0.0.0 --port 8000"``
|
| 905 |
+
|
| 906 |
+
and any additional values can be supplied through
|
| 907 |
+
``env_vars``. The provider runs this command as ``root`` for a
|
| 908 |
+
registry image; a ``template:<id>`` keeps the template's
|
| 909 |
+
configured user. Prefer
|
| 910 |
+
``NovitaSandboxProvider.image_from_dockerfile(...)`` when a
|
| 911 |
+
local Dockerfile is available; its build preserves the
|
| 912 |
+
environment and startup configuration in the generated
|
| 913 |
+
template. Unknown options raise `ValueError` so typos cannot
|
| 914 |
+
silently change sandbox behavior.
|
| 915 |
+
|
| 916 |
+
Returns:
|
| 917 |
+
`str`: HTTPS sandbox URL for the exposed port (base_url).
|
| 918 |
+
|
| 919 |
+
Raises:
|
| 920 |
+
RuntimeError: If a sandbox is already active on this provider.
|
| 921 |
+
ValueError: If no image is available, the port is unsupported, or an
|
| 922 |
+
unknown option is passed.
|
| 923 |
+
"""
|
| 924 |
+
if self._sandbox is not None:
|
| 925 |
+
raise RuntimeError(
|
| 926 |
+
"NovitaSandboxProvider already has an active sandbox. Call "
|
| 927 |
+
"stop_container() (or close()) before starting another — a "
|
| 928 |
+
"second start would orphan the running sandbox."
|
| 929 |
+
)
|
| 930 |
+
|
| 931 |
+
if port is not None and port != _DEFAULT_NOVITA_PORT:
|
| 932 |
+
raise ValueError(
|
| 933 |
+
f"NovitaSandboxProvider only supports port {_DEFAULT_NOVITA_PORT} "
|
| 934 |
+
f"(got {port}). The sandbox host routes to port "
|
| 935 |
+
f"{_DEFAULT_NOVITA_PORT} inside the sandbox."
|
| 936 |
+
)
|
| 937 |
+
|
| 938 |
+
effective_image = image if image is not None else self._image
|
| 939 |
+
if effective_image is None:
|
| 940 |
+
raise ValueError(
|
| 941 |
+
"NovitaSandboxProvider requires an image. Pass it to the "
|
| 942 |
+
"constructor or start_container()."
|
| 943 |
+
)
|
| 944 |
+
effective_env_vars = self._env_vars if env_vars is None else env_vars
|
| 945 |
+
|
| 946 |
+
cmd = kwargs.pop("cmd", None) or self._cmd
|
| 947 |
+
# `AutoEnv.from_env()` forwards this unconditionally (default 30.0) via
|
| 948 |
+
# `from_docker_image` -> `_bootstrap_container`, so rejecting it would
|
| 949 |
+
# make the entire AutoEnv path unusable for this provider. It is
|
| 950 |
+
# meaningless here: `_bootstrap_container` calls `wait_for_ready(base_url)`
|
| 951 |
+
# without a timeout, so no provider ever sees the value. Accept and drop
|
| 952 |
+
# it rather than fail; unknown options still raise, so a typo cannot
|
| 953 |
+
# silently change sandbox behavior.
|
| 954 |
+
kwargs.pop("wait_timeout", None)
|
| 955 |
+
if kwargs:
|
| 956 |
+
unknown = ", ".join(sorted(kwargs))
|
| 957 |
+
raise ValueError(
|
| 958 |
+
f"Unsupported NovitaSandboxProvider start options: {unknown}"
|
| 959 |
+
)
|
| 960 |
+
|
| 961 |
+
# A "template:<path>" reference builds a Novita template from a local
|
| 962 |
+
# Dockerfile; anything else is a registry image the SDK resolves itself.
|
| 963 |
+
# The build runs before `create_sandbox` so a build failure never leaves
|
| 964 |
+
# a sandbox behind, and outside the `_redact_values` window below so its
|
| 965 |
+
# recorded secrets do not outlive a failed build.
|
| 966 |
+
if effective_image.startswith("template:"):
|
| 967 |
+
template_id: Optional[str] = self._resolve_template_ref(effective_image)
|
| 968 |
+
sandbox_image: Optional[str] = None
|
| 969 |
+
else:
|
| 970 |
+
template_id = None
|
| 971 |
+
sandbox_image = effective_image
|
| 972 |
+
|
| 973 |
+
# Record injected secret values so captured server output can be scrubbed
|
| 974 |
+
# before it is ever surfaced in an error (security invariant S4).
|
| 975 |
+
self._redact_values = {
|
| 976 |
+
value for value in (effective_env_vars or {}).values() if value
|
| 977 |
+
}
|
| 978 |
+
|
| 979 |
+
# A create failure created nothing, so just drop the recorded secrets and
|
| 980 |
+
# re-raise (the single-sandbox guard above guarantees there is no
|
| 981 |
+
# pre-existing sandbox to delete).
|
| 982 |
+
try:
|
| 983 |
+
self._sandbox = self._adapter.create_sandbox(
|
| 984 |
+
image=sandbox_image,
|
| 985 |
+
template=template_id,
|
| 986 |
+
env_vars=effective_env_vars,
|
| 987 |
+
timeout=self._timeout,
|
| 988 |
+
metadata=self._metadata,
|
| 989 |
+
secure=self._secure,
|
| 990 |
+
)
|
| 991 |
+
except Exception:
|
| 992 |
+
self._redact_values = set()
|
| 993 |
+
raise
|
| 994 |
+
|
| 995 |
+
try:
|
| 996 |
+
# Discovery runs after creation, when the filesystem can be read.
|
| 997 |
+
if cmd is None:
|
| 998 |
+
cmd = self._discover_server_cmd()
|
| 999 |
+
|
| 1000 |
+
# Registry images can lose their Dockerfile USER/ownership settings
|
| 1001 |
+
# while Novita converts them to a cached OCI template. Run the
|
| 1002 |
+
# provider-managed server command as root for that path. A template
|
| 1003 |
+
# keeps its configured user, including an explicit non-root USER.
|
| 1004 |
+
self._server_user = _SANDBOX_USER if sandbox_image is not None else None
|
| 1005 |
+
self._launch_server(
|
| 1006 |
+
cmd,
|
| 1007 |
+
user=self._server_user,
|
| 1008 |
+
)
|
| 1009 |
+
host = _require_bare_host(
|
| 1010 |
+
self._adapter.host(self._sandbox, _DEFAULT_NOVITA_PORT)
|
| 1011 |
+
)
|
| 1012 |
+
self._base_url = _require_secure_url(f"https://{host}")
|
| 1013 |
+
except Exception:
|
| 1014 |
+
# A cleanup failure here must not mask the original error: swallow
|
| 1015 |
+
# any exception from stop_container() so the root cause propagates.
|
| 1016 |
+
try:
|
| 1017 |
+
self.stop_container()
|
| 1018 |
+
except Exception:
|
| 1019 |
+
pass
|
| 1020 |
+
raise
|
| 1021 |
+
|
| 1022 |
+
return self._base_url
|
| 1023 |
+
|
| 1024 |
+
def _launch_server(self, cmd: str, *, user: Optional[str] = None) -> None:
|
| 1025 |
+
"""Start the OpenEnv server inside the sandbox as a background process.
|
| 1026 |
+
|
| 1027 |
+
This is the one place the provider assumes something about the image, so
|
| 1028 |
+
it is kept to a single method.
|
| 1029 |
+
|
| 1030 |
+
The server is launched through the SDK's background command support and
|
| 1031 |
+
its PID is written to ``/tmp/openenv-server.pid``, so ``wait_for_ready``
|
| 1032 |
+
can tell "still booting" apart from "crashed" instead of waiting out
|
| 1033 |
+
the full timeout.
|
| 1034 |
+
The sandbox was created with a ``sleep infinity`` start command rather
|
| 1035 |
+
than the image's own CMD (see ``_DefaultNovitaAdapter.create_sandbox``),
|
| 1036 |
+
which is what leaves port 8000 free for this process.
|
| 1037 |
+
|
| 1038 |
+
An alternative is to let the platform own it: pass the server command as
|
| 1039 |
+
the template's start command via the SDK's ``build={"cmd": ...}`` and
|
| 1040 |
+
drop this method. That trades the PID-based crash detection
|
| 1041 |
+
below for platform-managed lifecycle and readiness. It is not the
|
| 1042 |
+
default because a start command can only be set when the image is first
|
| 1043 |
+
resolved into a template, so it would not apply to an already-cached
|
| 1044 |
+
image.
|
| 1045 |
+
|
| 1046 |
+
``cmd`` is trusted orchestrator configuration, not agent or environment
|
| 1047 |
+
input — callers must not pass agent-controlled text, and any dynamic
|
| 1048 |
+
value interpolated into it must be quoted by the caller (S5). The
|
| 1049 |
+
sandbox SDK runs commands as ``/bin/bash -l -c <command>``; the command
|
| 1050 |
+
itself owns the PID file and log redirection while ``background=True``
|
| 1051 |
+
keeps the remote process alive after this call.
|
| 1052 |
+
"""
|
| 1053 |
+
if self._working_directory:
|
| 1054 |
+
command = f"cd {shlex.quote(self._working_directory)} && {cmd}"
|
| 1055 |
+
else:
|
| 1056 |
+
command = cmd
|
| 1057 |
+
|
| 1058 |
+
launch_command = (
|
| 1059 |
+
"echo $$ > /tmp/openenv-server.pid; "
|
| 1060 |
+
f"exec sh -c {shlex.quote(command)} "
|
| 1061 |
+
"> /tmp/openenv-server.log 2>&1"
|
| 1062 |
+
)
|
| 1063 |
+
escaped = shlex.quote(launch_command)
|
| 1064 |
+
self._adapter.exec(
|
| 1065 |
+
self._sandbox,
|
| 1066 |
+
f"sh -c {escaped}",
|
| 1067 |
+
timeout=0,
|
| 1068 |
+
background=True,
|
| 1069 |
+
user=user,
|
| 1070 |
+
)
|
| 1071 |
+
|
| 1072 |
+
def stop_container(self) -> None:
|
| 1073 |
+
"""Kill the active Novita sandbox.
|
| 1074 |
+
|
| 1075 |
+
`kill()` permanently removes the sandbox: it cannot be resumed or
|
| 1076 |
+
reconnected, and any snapshot must have been taken beforehand.
|
| 1077 |
+
"""
|
| 1078 |
+
if self._sandbox is None:
|
| 1079 |
+
# Still drop any injected secret values recorded by a failed start.
|
| 1080 |
+
self._redact_values = set()
|
| 1081 |
+
return
|
| 1082 |
+
|
| 1083 |
+
sandbox = self._sandbox
|
| 1084 |
+
try:
|
| 1085 |
+
self._adapter.kill(sandbox)
|
| 1086 |
+
finally:
|
| 1087 |
+
self._sandbox = None
|
| 1088 |
+
self._base_url = None
|
| 1089 |
+
self._server_user = None
|
| 1090 |
+
self._redact_values = set()
|
| 1091 |
+
|
| 1092 |
+
def close(self) -> None:
|
| 1093 |
+
"""Stop the active sandbox and release provider-held resources.
|
| 1094 |
+
|
| 1095 |
+
Overrides the base no-op so a caller holding a bare `ContainerProvider`
|
| 1096 |
+
reference can release the sandbox polymorphically (also invoked on
|
| 1097 |
+
context-manager exit). The Novita client is stateless HTTP per call, so
|
| 1098 |
+
this is equivalent to `stop_container()`.
|
| 1099 |
+
"""
|
| 1100 |
+
self.stop_container()
|
| 1101 |
+
|
| 1102 |
+
@property
|
| 1103 |
+
def base_url(self) -> str:
|
| 1104 |
+
"""URL returned by the last `start_container`."""
|
| 1105 |
+
if self._base_url is None:
|
| 1106 |
+
raise RuntimeError(
|
| 1107 |
+
"NovitaSandboxProvider has no active base_url. Start the "
|
| 1108 |
+
"provider before reading base_url."
|
| 1109 |
+
)
|
| 1110 |
+
return self._base_url
|
| 1111 |
+
|
| 1112 |
+
def _redact(self, text: str, *, max_chars: int = 2000) -> str:
|
| 1113 |
+
"""Scrub injected secret values and bound length before surfacing output.
|
| 1114 |
+
|
| 1115 |
+
Replaces any injected env-var value with `***` and keeps only the tail.
|
| 1116 |
+
This is best-effort (exact-match only), which is why server output is
|
| 1117 |
+
withheld entirely unless `surface_server_logs=True` (RFC 002 S4).
|
| 1118 |
+
"""
|
| 1119 |
+
redacted = text or ""
|
| 1120 |
+
for value in self._redact_values:
|
| 1121 |
+
redacted = redacted.replace(value, "***")
|
| 1122 |
+
if len(redacted) > max_chars:
|
| 1123 |
+
redacted = "...(truncated)...\n" + redacted[-max_chars:]
|
| 1124 |
+
return redacted
|
| 1125 |
+
|
| 1126 |
+
def _server_died_message(self) -> str:
|
| 1127 |
+
"""Build the startup-crash error, secure by default (RFC 002 S4).
|
| 1128 |
+
|
| 1129 |
+
Untrusted code can print secrets then force a crash to exfiltrate them
|
| 1130 |
+
through the exception (which lands in orchestrator/CI logs), so sandbox
|
| 1131 |
+
output is excluded unless `surface_server_logs=True`, in which case a
|
| 1132 |
+
best-effort redacted, bounded excerpt is included.
|
| 1133 |
+
"""
|
| 1134 |
+
base = (
|
| 1135 |
+
"Novita sandbox server process died during startup. Server output is "
|
| 1136 |
+
"not surfaced to avoid leaking secrets injected into the sandbox; "
|
| 1137 |
+
"retrieve /tmp/openenv-server.log from the sandbox out of band, or "
|
| 1138 |
+
"construct the provider with surface_server_logs=True to include a "
|
| 1139 |
+
"redacted excerpt."
|
| 1140 |
+
)
|
| 1141 |
+
if not self.surface_server_logs:
|
| 1142 |
+
return base
|
| 1143 |
+
if self._sandbox is None:
|
| 1144 |
+
return base
|
| 1145 |
+
|
| 1146 |
+
log = self._redact(
|
| 1147 |
+
self._adapter.exec(
|
| 1148 |
+
self._sandbox,
|
| 1149 |
+
"cat /tmp/openenv-server.log 2>/dev/null",
|
| 1150 |
+
user=self._server_user,
|
| 1151 |
+
)
|
| 1152 |
+
)
|
| 1153 |
+
return (
|
| 1154 |
+
"Novita sandbox server process died during startup. The excerpt below "
|
| 1155 |
+
"is the sandbox server output with injected secret values redacted "
|
| 1156 |
+
"(best-effort); it may still contain secrets the workload printed "
|
| 1157 |
+
f"by other means.\nLog (redacted):\n{log}"
|
| 1158 |
+
)
|
| 1159 |
+
|
| 1160 |
+
def wait_for_ready(self, base_url: str, timeout_s: float = 120.0) -> None:
|
| 1161 |
+
"""
|
| 1162 |
+
Poll the /health endpoint until the sandbox is ready.
|
| 1163 |
+
|
| 1164 |
+
Uses a longer default timeout (120s) than local Docker providers because
|
| 1165 |
+
a Novita sandbox is created from an image that may need to be resolved
|
| 1166 |
+
and built on first use.
|
| 1167 |
+
|
| 1168 |
+
A `200` on `/health` proves HTTP reachability but **not** that the
|
| 1169 |
+
exposed sandbox host proxies the `/ws` WebSocket upgrade `EnvClient`
|
| 1170 |
+
needs; that requires a real `wss://` round-trip (RFC 002 invariant 2).
|
| 1171 |
+
|
| 1172 |
+
Args:
|
| 1173 |
+
base_url (`str`):
|
| 1174 |
+
Sandbox URL returned by `start_container()`.
|
| 1175 |
+
timeout_s (`float`, *optional*, defaults to `120.0`):
|
| 1176 |
+
Maximum seconds to wait.
|
| 1177 |
+
|
| 1178 |
+
Raises:
|
| 1179 |
+
TimeoutError: If the sandbox doesn't become ready in time.
|
| 1180 |
+
RuntimeError: If the server process died (detected via PID check).
|
| 1181 |
+
"""
|
| 1182 |
+
# Imported lazily inside the method that needs it, matching the other
|
| 1183 |
+
# providers in this package (e.g. DaytonaProvider).
|
| 1184 |
+
import requests
|
| 1185 |
+
|
| 1186 |
+
deadline = time.time() + timeout_s
|
| 1187 |
+
health_url = f"{base_url}/health"
|
| 1188 |
+
|
| 1189 |
+
while time.time() < deadline:
|
| 1190 |
+
try:
|
| 1191 |
+
response = requests.get(health_url, timeout=5.0)
|
| 1192 |
+
if response.status_code == 200:
|
| 1193 |
+
return
|
| 1194 |
+
except requests.RequestException:
|
| 1195 |
+
pass
|
| 1196 |
+
|
| 1197 |
+
# Early exit: if the server process died, raise immediately instead
|
| 1198 |
+
# of waiting for the full health-check timeout.
|
| 1199 |
+
if self._sandbox is not None:
|
| 1200 |
+
out = self._adapter.exec(
|
| 1201 |
+
self._sandbox,
|
| 1202 |
+
"if test -s /tmp/openenv-server.pid; then "
|
| 1203 |
+
"kill -0 $(cat /tmp/openenv-server.pid) 2>/dev/null "
|
| 1204 |
+
"&& echo RUNNING || echo DEAD; "
|
| 1205 |
+
"else echo STARTING; fi",
|
| 1206 |
+
user=self._server_user,
|
| 1207 |
+
)
|
| 1208 |
+
if "DEAD" in (out or ""):
|
| 1209 |
+
raise RuntimeError(self._server_died_message())
|
| 1210 |
+
|
| 1211 |
+
time.sleep(1.0)
|
| 1212 |
+
|
| 1213 |
+
raise TimeoutError(f"Novita sandbox did not become ready within {timeout_s}s.")
|
| 1214 |
+
|
| 1215 |
+
|
| 1216 |
+
__all__ = ["NovitaSandboxProvider"]
|
src/core/containers/runtime/providers.py
CHANGED
|
@@ -1,8 +1,4 @@
|
|
| 1 |
-
#
|
| 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.
|
|
@@ -14,7 +10,9 @@ This module provides a pluggable architecture for different container providers
|
|
| 14 |
from __future__ import annotations
|
| 15 |
|
| 16 |
from abc import ABC, abstractmethod
|
| 17 |
-
from typing import Any, Dict, Optional, Sequence
|
|
|
|
|
|
|
| 18 |
|
| 19 |
|
| 20 |
class ContainerProvider(ABC):
|
|
@@ -30,12 +28,15 @@ class ContainerProvider(ABC):
|
|
| 30 |
The provider manages a single container lifecycle and provides the base URL
|
| 31 |
for connecting to it.
|
| 32 |
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
|
|
|
|
|
|
|
|
|
| 39 |
"""
|
| 40 |
|
| 41 |
@abstractmethod
|
|
@@ -50,16 +51,23 @@ class ContainerProvider(ABC):
|
|
| 50 |
Start a container from the specified image.
|
| 51 |
|
| 52 |
Args:
|
| 53 |
-
image
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
|
|
@@ -80,14 +88,34 @@ class ContainerProvider(ABC):
|
|
| 80 |
This typically polls the /health endpoint until it returns 200.
|
| 81 |
|
| 82 |
Args:
|
| 83 |
-
base_url
|
| 84 |
-
|
|
|
|
|
|
|
| 85 |
|
| 86 |
Raises:
|
| 87 |
-
TimeoutError: If container doesn't become ready in time
|
| 88 |
"""
|
| 89 |
pass
|
| 90 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
|
| 92 |
class LocalDockerProvider(ContainerProvider):
|
| 93 |
"""
|
|
@@ -96,11 +124,14 @@ class LocalDockerProvider(ContainerProvider):
|
|
| 96 |
This provider runs containers on the local machine using Docker.
|
| 97 |
Useful for development and testing.
|
| 98 |
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
|
|
|
|
|
|
|
|
|
| 104 |
"""
|
| 105 |
|
| 106 |
def __init__(self):
|
|
@@ -138,13 +169,17 @@ class LocalDockerProvider(ContainerProvider):
|
|
| 138 |
Start a Docker container locally.
|
| 139 |
|
| 140 |
Args:
|
| 141 |
-
image
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 145 |
|
| 146 |
Returns:
|
| 147 |
-
Base URL to connect to the container
|
| 148 |
"""
|
| 149 |
import subprocess
|
| 150 |
import time
|
|
@@ -226,11 +261,13 @@ class LocalDockerProvider(ContainerProvider):
|
|
| 226 |
Wait for container to be ready by polling /health endpoint.
|
| 227 |
|
| 228 |
Args:
|
| 229 |
-
base_url
|
| 230 |
-
|
|
|
|
|
|
|
| 231 |
|
| 232 |
Raises:
|
| 233 |
-
TimeoutError: If container doesn't become ready
|
| 234 |
"""
|
| 235 |
import time
|
| 236 |
|
|
@@ -261,7 +298,7 @@ class LocalDockerProvider(ContainerProvider):
|
|
| 261 |
Find an available port on localhost.
|
| 262 |
|
| 263 |
Returns:
|
| 264 |
-
An available port number
|
| 265 |
"""
|
| 266 |
import socket
|
| 267 |
|
|
@@ -276,10 +313,11 @@ class LocalDockerProvider(ContainerProvider):
|
|
| 276 |
Generate a unique container name based on image name and timestamp.
|
| 277 |
|
| 278 |
Args:
|
| 279 |
-
image
|
|
|
|
| 280 |
|
| 281 |
Returns:
|
| 282 |
-
A unique container name
|
| 283 |
"""
|
| 284 |
import time
|
| 285 |
|
|
@@ -306,12 +344,13 @@ class DockerSwarmProvider(ContainerProvider):
|
|
| 306 |
):
|
| 307 |
"""
|
| 308 |
Args:
|
| 309 |
-
auto_init_swarm
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
|
|
|
| 315 |
"""
|
| 316 |
self._service_name: Optional[str] = None
|
| 317 |
self._service_id: Optional[str] = None
|
|
@@ -334,13 +373,28 @@ class DockerSwarmProvider(ContainerProvider):
|
|
| 334 |
"""
|
| 335 |
Start (or scale) a Swarm service for the given image.
|
| 336 |
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 344 |
"""
|
| 345 |
import shlex
|
| 346 |
import subprocess
|
|
@@ -464,7 +518,7 @@ class DockerSwarmProvider(ContainerProvider):
|
|
| 464 |
"""
|
| 465 |
Wait for at least one replica to become healthy by polling /health.
|
| 466 |
|
| 467 |
-
|
| 468 |
so this only verifies that at least one replica is responding. Some
|
| 469 |
replicas may still be starting when this returns.
|
| 470 |
"""
|
|
@@ -592,16 +646,12 @@ class DockerSwarmProvider(ContainerProvider):
|
|
| 592 |
|
| 593 |
class KubernetesProvider(ContainerProvider):
|
| 594 |
"""
|
| 595 |
-
|
| 596 |
|
| 597 |
-
|
| 598 |
-
|
| 599 |
-
|
| 600 |
-
|
| 601 |
-
>>> provider = KubernetesProvider(namespace="envtorch-dev")
|
| 602 |
-
>>> base_url = provider.start_container("echo-env:latest")
|
| 603 |
-
>>> # Pod running in k8s, accessible via service or port-forward
|
| 604 |
-
>>> provider.stop_container()
|
| 605 |
"""
|
| 606 |
|
| 607 |
pass
|
|
@@ -616,11 +666,14 @@ class RuntimeProvider(ABC):
|
|
| 616 |
The provider manages a single runtime lifecycle and provides the base URL
|
| 617 |
for connecting to it.
|
| 618 |
|
| 619 |
-
|
| 620 |
-
|
| 621 |
-
|
| 622 |
-
|
| 623 |
-
|
|
|
|
|
|
|
|
|
|
| 624 |
"""
|
| 625 |
|
| 626 |
@abstractmethod
|
|
@@ -631,13 +684,18 @@ class RuntimeProvider(ABC):
|
|
| 631 |
**kwargs: Any,
|
| 632 |
) -> str:
|
| 633 |
"""
|
| 634 |
-
Start
|
| 635 |
|
| 636 |
Args:
|
| 637 |
-
|
| 638 |
-
|
| 639 |
-
env_vars
|
| 640 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 641 |
"""
|
| 642 |
|
| 643 |
@abstractmethod
|
|
|
|
| 1 |
+
# SPDX-License-Identifier: BSD-3-Clause
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
"""
|
| 4 |
Container provider abstractions for running environment servers.
|
|
|
|
| 10 |
from __future__ import annotations
|
| 11 |
|
| 12 |
from abc import ABC, abstractmethod
|
| 13 |
+
from typing import Any, Dict, Optional, Sequence, TypeVar
|
| 14 |
+
|
| 15 |
+
_ContainerProviderT = TypeVar("_ContainerProviderT", bound="ContainerProvider")
|
| 16 |
|
| 17 |
|
| 18 |
class ContainerProvider(ABC):
|
|
|
|
| 28 |
The provider manages a single container lifecycle and provides the base URL
|
| 29 |
for connecting to it.
|
| 30 |
|
| 31 |
+
Examples:
|
| 32 |
+
|
| 33 |
+
```python
|
| 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 |
|
| 42 |
@abstractmethod
|
|
|
|
| 51 |
Start a container from the specified image.
|
| 52 |
|
| 53 |
Args:
|
| 54 |
+
image (`str`):
|
| 55 |
+
Provider-specific container *source* identifier. For
|
| 56 |
+
container-based providers this is a registry image name (e.g.
|
| 57 |
+
`"echo-env:latest"`); other providers may map it to a
|
| 58 |
+
provider-specific source (see the provider's documentation).
|
| 59 |
+
port (`int`, *optional*):
|
| 60 |
+
Port to expose. If `None`, the provider chooses.
|
| 61 |
+
env_vars (`dict`, *optional*):
|
| 62 |
+
Environment variables to pass to container.
|
| 63 |
+
**kwargs:
|
| 64 |
+
Provider-specific options.
|
| 65 |
|
| 66 |
Returns:
|
| 67 |
+
`str`: Base URL to connect to the container (e.g., `"http://localhost:8000"`).
|
| 68 |
|
| 69 |
Raises:
|
| 70 |
+
RuntimeError: If container fails to start.
|
| 71 |
"""
|
| 72 |
pass
|
| 73 |
|
|
|
|
| 88 |
This typically polls the /health endpoint until it returns 200.
|
| 89 |
|
| 90 |
Args:
|
| 91 |
+
base_url (`str`):
|
| 92 |
+
Base URL of the container.
|
| 93 |
+
timeout_s (`float`, *optional*, defaults to `30.0`):
|
| 94 |
+
Maximum time to wait in seconds.
|
| 95 |
|
| 96 |
Raises:
|
| 97 |
+
TimeoutError: If container doesn't become ready in time.
|
| 98 |
"""
|
| 99 |
pass
|
| 100 |
|
| 101 |
+
def close(self) -> None:
|
| 102 |
+
"""
|
| 103 |
+
Release provider-held resources (e.g. SDK clients, connections).
|
| 104 |
+
|
| 105 |
+
Defaults to a no-op so existing providers are unaffected. Providers that
|
| 106 |
+
hold external resources beyond the container itself (such as a cloud SDK
|
| 107 |
+
client) override this to release them; it is also invoked on context-
|
| 108 |
+
manager exit. Lightweight providers need not override it.
|
| 109 |
+
"""
|
| 110 |
+
pass
|
| 111 |
+
|
| 112 |
+
def __enter__(self: _ContainerProviderT) -> _ContainerProviderT:
|
| 113 |
+
return self
|
| 114 |
+
|
| 115 |
+
def __exit__(self, exc_type, exc, tb) -> Optional[bool]:
|
| 116 |
+
self.close()
|
| 117 |
+
return None
|
| 118 |
+
|
| 119 |
|
| 120 |
class LocalDockerProvider(ContainerProvider):
|
| 121 |
"""
|
|
|
|
| 124 |
This provider runs containers on the local machine using Docker.
|
| 125 |
Useful for development and testing.
|
| 126 |
|
| 127 |
+
Examples:
|
| 128 |
+
|
| 129 |
+
```python
|
| 130 |
+
provider = LocalDockerProvider()
|
| 131 |
+
base_url = provider.start_container("echo-env:latest")
|
| 132 |
+
# Container running on http://localhost:<random-port>
|
| 133 |
+
provider.stop_container()
|
| 134 |
+
```
|
| 135 |
"""
|
| 136 |
|
| 137 |
def __init__(self):
|
|
|
|
| 169 |
Start a Docker container locally.
|
| 170 |
|
| 171 |
Args:
|
| 172 |
+
image (`str`):
|
| 173 |
+
Docker image name.
|
| 174 |
+
port (`int`, *optional*):
|
| 175 |
+
Port to expose. If `None`, finds an available port.
|
| 176 |
+
env_vars (`dict`, *optional*):
|
| 177 |
+
Environment variables for the container.
|
| 178 |
+
**kwargs:
|
| 179 |
+
Additional Docker run options.
|
| 180 |
|
| 181 |
Returns:
|
| 182 |
+
`str`: Base URL to connect to the container.
|
| 183 |
"""
|
| 184 |
import subprocess
|
| 185 |
import time
|
|
|
|
| 261 |
Wait for container to be ready by polling /health endpoint.
|
| 262 |
|
| 263 |
Args:
|
| 264 |
+
base_url (`str`):
|
| 265 |
+
Base URL of the container.
|
| 266 |
+
timeout_s (`float`, *optional*, defaults to `30.0`):
|
| 267 |
+
Maximum time to wait in seconds.
|
| 268 |
|
| 269 |
Raises:
|
| 270 |
+
TimeoutError: If container doesn't become ready.
|
| 271 |
"""
|
| 272 |
import time
|
| 273 |
|
|
|
|
| 298 |
Find an available port on localhost.
|
| 299 |
|
| 300 |
Returns:
|
| 301 |
+
`int`: An available port number.
|
| 302 |
"""
|
| 303 |
import socket
|
| 304 |
|
|
|
|
| 313 |
Generate a unique container name based on image name and timestamp.
|
| 314 |
|
| 315 |
Args:
|
| 316 |
+
image (`str`):
|
| 317 |
+
Docker image name.
|
| 318 |
|
| 319 |
Returns:
|
| 320 |
+
`str`: A unique container name.
|
| 321 |
"""
|
| 322 |
import time
|
| 323 |
|
|
|
|
| 344 |
):
|
| 345 |
"""
|
| 346 |
Args:
|
| 347 |
+
auto_init_swarm (`bool`, *optional*, defaults to `True`):
|
| 348 |
+
Whether to call `docker swarm init` when Swarm is not active.
|
| 349 |
+
Otherwise, the user must manually initialize Swarm.
|
| 350 |
+
overlay_network (`str`, *optional*):
|
| 351 |
+
Overlay network name for the service. When provided, the network
|
| 352 |
+
is created with `docker network create --driver overlay --attachable`
|
| 353 |
+
if it does not already exist.
|
| 354 |
"""
|
| 355 |
self._service_name: Optional[str] = None
|
| 356 |
self._service_id: Optional[str] = None
|
|
|
|
| 373 |
"""
|
| 374 |
Start (or scale) a Swarm service for the given image.
|
| 375 |
|
| 376 |
+
Args:
|
| 377 |
+
image (`str`):
|
| 378 |
+
Docker image name.
|
| 379 |
+
port (`int`, *optional*):
|
| 380 |
+
Port to expose. If `None`, finds an available port.
|
| 381 |
+
env_vars (`dict`, *optional*):
|
| 382 |
+
Environment variables for the container.
|
| 383 |
+
replicas (`int`, *optional*, defaults to `2`):
|
| 384 |
+
Number of container replicas.
|
| 385 |
+
cpu_limit (`float` or `str`, *optional*):
|
| 386 |
+
CPU limit passed to `--limit-cpu`.
|
| 387 |
+
memory_limit (`str`, *optional*):
|
| 388 |
+
Memory limit passed to `--limit-memory`.
|
| 389 |
+
constraints (`Sequence[str]`, *optional*):
|
| 390 |
+
Placement constraints.
|
| 391 |
+
labels (`dict`, *optional*):
|
| 392 |
+
Service labels.
|
| 393 |
+
command (`Sequence[str]` or `str`, *optional*):
|
| 394 |
+
Override container command.
|
| 395 |
+
|
| 396 |
+
Returns:
|
| 397 |
+
`str`: Base URL to connect to the service.
|
| 398 |
"""
|
| 399 |
import shlex
|
| 400 |
import subprocess
|
|
|
|
| 518 |
"""
|
| 519 |
Wait for at least one replica to become healthy by polling /health.
|
| 520 |
|
| 521 |
+
With Swarm's load balancer, requests round-robin across replicas,
|
| 522 |
so this only verifies that at least one replica is responding. Some
|
| 523 |
replicas may still be starting when this returns.
|
| 524 |
"""
|
|
|
|
| 646 |
|
| 647 |
class KubernetesProvider(ContainerProvider):
|
| 648 |
"""
|
| 649 |
+
Planned container provider for Kubernetes clusters.
|
| 650 |
|
| 651 |
+
Not yet implemented: this is a placeholder for the planned Kubernetes
|
| 652 |
+
backend and does not implement the abstract `ContainerProvider` methods, so
|
| 653 |
+
it cannot be instantiated. Use `LocalDockerProvider`, `DockerSwarmProvider`,
|
| 654 |
+
`DaytonaProvider`, or `ACASandboxProvider` instead.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 655 |
"""
|
| 656 |
|
| 657 |
pass
|
|
|
|
| 666 |
The provider manages a single runtime lifecycle and provides the base URL
|
| 667 |
for connecting to it.
|
| 668 |
|
| 669 |
+
Examples:
|
| 670 |
+
|
| 671 |
+
```python
|
| 672 |
+
provider = UVProvider(project_path="/path/to/env")
|
| 673 |
+
base_url = provider.start()
|
| 674 |
+
print(base_url) # http://localhost:8000
|
| 675 |
+
provider.stop()
|
| 676 |
+
```
|
| 677 |
"""
|
| 678 |
|
| 679 |
@abstractmethod
|
|
|
|
| 684 |
**kwargs: Any,
|
| 685 |
) -> str:
|
| 686 |
"""
|
| 687 |
+
Start the runtime.
|
| 688 |
|
| 689 |
Args:
|
| 690 |
+
port (`int`, *optional*):
|
| 691 |
+
Port to expose. If `None`, the provider chooses.
|
| 692 |
+
env_vars (`dict`, *optional*):
|
| 693 |
+
Environment variables for the runtime.
|
| 694 |
+
**kwargs:
|
| 695 |
+
Additional runtime options.
|
| 696 |
+
|
| 697 |
+
Returns:
|
| 698 |
+
`str`: Base URL to connect to the runtime.
|
| 699 |
"""
|
| 700 |
|
| 701 |
@abstractmethod
|
src/core/containers/runtime/uv_provider.py
CHANGED
|
@@ -3,8 +3,10 @@
|
|
| 3 |
from __future__ import annotations
|
| 4 |
|
| 5 |
import os
|
|
|
|
| 6 |
import socket
|
| 7 |
import subprocess
|
|
|
|
| 8 |
import time
|
| 9 |
from typing import Dict, Optional
|
| 10 |
|
|
@@ -12,6 +14,8 @@ import requests
|
|
| 12 |
|
| 13 |
from .providers import RuntimeProvider
|
| 14 |
|
|
|
|
|
|
|
| 15 |
|
| 16 |
def _check_uv_installed() -> None:
|
| 17 |
try:
|
|
@@ -22,6 +26,42 @@ def _check_uv_installed() -> None:
|
|
| 22 |
) from exc
|
| 23 |
|
| 24 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
def _find_free_port() -> int:
|
| 26 |
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
| 27 |
sock.bind(("", 0))
|
|
@@ -71,7 +111,11 @@ def _poll_health(health_url: str, timeout_s: float) -> None:
|
|
| 71 |
if response.status_code == 200:
|
| 72 |
return
|
| 73 |
except requests.RequestException:
|
| 74 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
|
| 76 |
time.sleep(0.5)
|
| 77 |
|
|
@@ -83,19 +127,29 @@ class UVProvider(RuntimeProvider):
|
|
| 83 |
RuntimeProvider implementation backed by ``uv run``.
|
| 84 |
|
| 85 |
Args:
|
| 86 |
-
project_path
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
"""
|
| 100 |
|
| 101 |
def __init__(
|
|
@@ -109,7 +163,14 @@ class UVProvider(RuntimeProvider):
|
|
| 109 |
context_timeout_s: float = 60.0,
|
| 110 |
):
|
| 111 |
"""Initialize the UVProvider."""
|
| 112 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
self.app = app
|
| 114 |
self.host = host
|
| 115 |
self.reload = reload
|
|
@@ -118,6 +179,7 @@ class UVProvider(RuntimeProvider):
|
|
| 118 |
_check_uv_installed()
|
| 119 |
self._process = None
|
| 120 |
self._base_url = None
|
|
|
|
| 121 |
|
| 122 |
def start(
|
| 123 |
self,
|
|
@@ -130,28 +192,44 @@ class UVProvider(RuntimeProvider):
|
|
| 130 |
Start the environment via `uv run`.
|
| 131 |
|
| 132 |
Args:
|
| 133 |
-
port
|
| 134 |
-
|
| 135 |
-
|
|
|
|
|
|
|
|
|
|
| 136 |
|
| 137 |
Returns:
|
| 138 |
-
|
| 139 |
|
| 140 |
Raises:
|
| 141 |
-
RuntimeError: If the environment is already running
|
| 142 |
"""
|
| 143 |
if self._process is not None and self._process.poll() is None:
|
| 144 |
raise RuntimeError("UVProvider is already running")
|
| 145 |
|
| 146 |
bind_port = port or _find_free_port()
|
| 147 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 148 |
command = _create_uv_command(
|
| 149 |
host=self.host,
|
| 150 |
port=bind_port,
|
| 151 |
reload=self.reload,
|
| 152 |
workers=workers,
|
| 153 |
app=self.app,
|
| 154 |
-
project_path=
|
| 155 |
)
|
| 156 |
|
| 157 |
env = os.environ.copy()
|
|
@@ -164,23 +242,31 @@ class UVProvider(RuntimeProvider):
|
|
| 164 |
try:
|
| 165 |
self._process = subprocess.Popen(command, env=env)
|
| 166 |
except OSError as exc:
|
|
|
|
|
|
|
|
|
|
| 167 |
raise RuntimeError(f"Failed to launch `uv run`: {exc}") from exc
|
| 168 |
|
| 169 |
client_host = "127.0.0.1" if self.host in {"0.0.0.0", "::"} else self.host
|
| 170 |
self._base_url = f"http://{client_host}:{bind_port}"
|
| 171 |
return self._base_url
|
| 172 |
|
| 173 |
-
def wait_for_ready(self, timeout_s: float =
|
| 174 |
"""
|
| 175 |
Wait for the environment to become ready.
|
| 176 |
|
| 177 |
Args:
|
| 178 |
-
timeout_s
|
|
|
|
|
|
|
| 179 |
|
| 180 |
Raises:
|
| 181 |
-
RuntimeError: If the environment is not running
|
| 182 |
-
TimeoutError: If the environment does not become ready within the timeout
|
| 183 |
"""
|
|
|
|
|
|
|
|
|
|
| 184 |
if self._process and self._process.poll() is not None:
|
| 185 |
code = self._process.returncode
|
| 186 |
raise RuntimeError(f"uv process exited prematurely with code {code}")
|
|
@@ -190,23 +276,22 @@ class UVProvider(RuntimeProvider):
|
|
| 190 |
def stop(self) -> None:
|
| 191 |
"""
|
| 192 |
Stop the environment.
|
| 193 |
-
|
| 194 |
-
Raises:
|
| 195 |
-
RuntimeError: If the environment is not running
|
| 196 |
"""
|
| 197 |
-
if self._process is None:
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
self.
|
|
|
|
|
|
|
| 210 |
|
| 211 |
@property
|
| 212 |
def base_url(self) -> str:
|
|
@@ -214,10 +299,10 @@ class UVProvider(RuntimeProvider):
|
|
| 214 |
The base URL of the environment.
|
| 215 |
|
| 216 |
Returns:
|
| 217 |
-
|
| 218 |
|
| 219 |
Raises:
|
| 220 |
-
RuntimeError: If the environment
|
| 221 |
"""
|
| 222 |
if self._base_url is None:
|
| 223 |
raise RuntimeError("UVProvider has not been started")
|
|
|
|
| 3 |
from __future__ import annotations
|
| 4 |
|
| 5 |
import os
|
| 6 |
+
import shutil
|
| 7 |
import socket
|
| 8 |
import subprocess
|
| 9 |
+
import tempfile
|
| 10 |
import time
|
| 11 |
from typing import Dict, Optional
|
| 12 |
|
|
|
|
| 14 |
|
| 15 |
from .providers import RuntimeProvider
|
| 16 |
|
| 17 |
+
GIT_URL_PREFIX = "git+"
|
| 18 |
+
|
| 19 |
|
| 20 |
def _check_uv_installed() -> None:
|
| 21 |
try:
|
|
|
|
| 26 |
) from exc
|
| 27 |
|
| 28 |
|
| 29 |
+
def _clone_git_project(git_url: str, timeout_s: float) -> str:
|
| 30 |
+
"""Clone a `git+<url>` spec to a temp dir and return its local path.
|
| 31 |
+
|
| 32 |
+
`uv run --project` only discovers a project in a local directory (per
|
| 33 |
+
`uv run --help`) -- it has no support for remote git sources. Callers
|
| 34 |
+
that accept a `git+...` `project_path` (e.g. `EnvClient.from_env`)
|
| 35 |
+
must therefore clone it themselves before handing a path to `uv run`.
|
| 36 |
+
"""
|
| 37 |
+
repo_url = git_url[len(GIT_URL_PREFIX) :]
|
| 38 |
+
clone_dir = tempfile.mkdtemp(prefix="openenv-uv-clone-")
|
| 39 |
+
try:
|
| 40 |
+
subprocess.run(
|
| 41 |
+
["git", "clone", "--depth", "1", repo_url, clone_dir],
|
| 42 |
+
check=True,
|
| 43 |
+
capture_output=True,
|
| 44 |
+
text=True,
|
| 45 |
+
# A hung/slow remote would otherwise block start() (and the
|
| 46 |
+
# caller's own readiness timeout) indefinitely.
|
| 47 |
+
timeout=timeout_s,
|
| 48 |
+
)
|
| 49 |
+
except FileNotFoundError as exc:
|
| 50 |
+
shutil.rmtree(clone_dir, ignore_errors=True)
|
| 51 |
+
raise RuntimeError(
|
| 52 |
+
f"`git` executable not found; required to clone project_path {git_url!r}."
|
| 53 |
+
) from exc
|
| 54 |
+
except subprocess.CalledProcessError as exc:
|
| 55 |
+
shutil.rmtree(clone_dir, ignore_errors=True)
|
| 56 |
+
raise RuntimeError(f"Failed to clone {repo_url!r}: {exc.stderr}") from exc
|
| 57 |
+
except subprocess.TimeoutExpired as exc:
|
| 58 |
+
shutil.rmtree(clone_dir, ignore_errors=True)
|
| 59 |
+
raise RuntimeError(
|
| 60 |
+
f"Timed out cloning {repo_url!r} after {timeout_s:.1f}s"
|
| 61 |
+
) from exc
|
| 62 |
+
return clone_dir
|
| 63 |
+
|
| 64 |
+
|
| 65 |
def _find_free_port() -> int:
|
| 66 |
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
| 67 |
sock.bind(("", 0))
|
|
|
|
| 111 |
if response.status_code == 200:
|
| 112 |
return
|
| 113 |
except requests.RequestException:
|
| 114 |
+
# Server not accepting connections yet. Fall through to the sleep
|
| 115 |
+
# below rather than `continue`-ing: a refused connection returns
|
| 116 |
+
# immediately, so retrying without a pause busy-spins a CPU core
|
| 117 |
+
# for the whole timeout window while the server boots.
|
| 118 |
+
pass
|
| 119 |
|
| 120 |
time.sleep(0.5)
|
| 121 |
|
|
|
|
| 127 |
RuntimeProvider implementation backed by ``uv run``.
|
| 128 |
|
| 129 |
Args:
|
| 130 |
+
project_path (`str`):
|
| 131 |
+
Local path to a uv project (passed to `uv run --project`), or a
|
| 132 |
+
`git+<url>` spec that is cloned to a temp directory on `start()`.
|
| 133 |
+
app (`str`, *optional*, defaults to `"server.app:app"`):
|
| 134 |
+
ASGI application path for uvicorn.
|
| 135 |
+
host (`str`, *optional*, defaults to `"0.0.0.0"`):
|
| 136 |
+
Host interface to bind to.
|
| 137 |
+
reload (`bool`, *optional*, defaults to `False`):
|
| 138 |
+
Whether to enable uvicorn's reload mode.
|
| 139 |
+
env_vars (`dict`, *optional*):
|
| 140 |
+
Environment variables to pass through to the spawned process.
|
| 141 |
+
context_timeout_s (`float`, *optional*, defaults to `60.0`):
|
| 142 |
+
How long to wait for the environment to become ready.
|
| 143 |
+
|
| 144 |
+
Examples:
|
| 145 |
+
|
| 146 |
+
```python
|
| 147 |
+
provider = UVProvider(project_path="/path/to/env")
|
| 148 |
+
base_url = provider.start()
|
| 149 |
+
print(base_url) # http://localhost:8000
|
| 150 |
+
# Use the environment via base_url
|
| 151 |
+
provider.stop()
|
| 152 |
+
```
|
| 153 |
"""
|
| 154 |
|
| 155 |
def __init__(
|
|
|
|
| 163 |
context_timeout_s: float = 60.0,
|
| 164 |
):
|
| 165 |
"""Initialize the UVProvider."""
|
| 166 |
+
# `os.path.abspath` would mangle a `git+<url>` spec (e.g. collapsing
|
| 167 |
+
# `https://` to `https:/`) and is meaningless for one anyway -- only
|
| 168 |
+
# resolve it for genuine local paths. Git specs are cloned in `start()`.
|
| 169 |
+
self.project_path = (
|
| 170 |
+
project_path
|
| 171 |
+
if project_path.startswith(GIT_URL_PREFIX)
|
| 172 |
+
else os.path.abspath(project_path)
|
| 173 |
+
)
|
| 174 |
self.app = app
|
| 175 |
self.host = host
|
| 176 |
self.reload = reload
|
|
|
|
| 179 |
_check_uv_installed()
|
| 180 |
self._process = None
|
| 181 |
self._base_url = None
|
| 182 |
+
self._clone_dir: Optional[str] = None
|
| 183 |
|
| 184 |
def start(
|
| 185 |
self,
|
|
|
|
| 192 |
Start the environment via `uv run`.
|
| 193 |
|
| 194 |
Args:
|
| 195 |
+
port (`int`, *optional*):
|
| 196 |
+
The port to bind the environment to.
|
| 197 |
+
env_vars (`dict`, *optional*):
|
| 198 |
+
Environment variables to pass to the environment.
|
| 199 |
+
workers (`int`, *optional*, defaults to `1`):
|
| 200 |
+
The number of workers to use.
|
| 201 |
|
| 202 |
Returns:
|
| 203 |
+
`str`: Base URL of the environment.
|
| 204 |
|
| 205 |
Raises:
|
| 206 |
+
RuntimeError: If the environment is already running.
|
| 207 |
"""
|
| 208 |
if self._process is not None and self._process.poll() is None:
|
| 209 |
raise RuntimeError("UVProvider is already running")
|
| 210 |
|
| 211 |
bind_port = port or _find_free_port()
|
| 212 |
|
| 213 |
+
# A previous start() may have left a clone dir behind if the spawned
|
| 214 |
+
# process later died on its own -- the "already running" guard above
|
| 215 |
+
# only checks liveness, so a restart in that state would otherwise
|
| 216 |
+
# overwrite self._clone_dir and leak the old directory.
|
| 217 |
+
if self._clone_dir is not None:
|
| 218 |
+
shutil.rmtree(self._clone_dir, ignore_errors=True)
|
| 219 |
+
self._clone_dir = None
|
| 220 |
+
|
| 221 |
+
project_path = self.project_path
|
| 222 |
+
if project_path.startswith(GIT_URL_PREFIX):
|
| 223 |
+
self._clone_dir = _clone_git_project(project_path, self.context_timeout_s)
|
| 224 |
+
project_path = self._clone_dir
|
| 225 |
+
|
| 226 |
command = _create_uv_command(
|
| 227 |
host=self.host,
|
| 228 |
port=bind_port,
|
| 229 |
reload=self.reload,
|
| 230 |
workers=workers,
|
| 231 |
app=self.app,
|
| 232 |
+
project_path=project_path,
|
| 233 |
)
|
| 234 |
|
| 235 |
env = os.environ.copy()
|
|
|
|
| 242 |
try:
|
| 243 |
self._process = subprocess.Popen(command, env=env)
|
| 244 |
except OSError as exc:
|
| 245 |
+
if self._clone_dir is not None:
|
| 246 |
+
shutil.rmtree(self._clone_dir, ignore_errors=True)
|
| 247 |
+
self._clone_dir = None
|
| 248 |
raise RuntimeError(f"Failed to launch `uv run`: {exc}") from exc
|
| 249 |
|
| 250 |
client_host = "127.0.0.1" if self.host in {"0.0.0.0", "::"} else self.host
|
| 251 |
self._base_url = f"http://{client_host}:{bind_port}"
|
| 252 |
return self._base_url
|
| 253 |
|
| 254 |
+
def wait_for_ready(self, timeout_s: float | None = None) -> None:
|
| 255 |
"""
|
| 256 |
Wait for the environment to become ready.
|
| 257 |
|
| 258 |
Args:
|
| 259 |
+
timeout_s (`float`, *optional*):
|
| 260 |
+
Maximum time in seconds to wait for the environment to become
|
| 261 |
+
ready. Defaults to the provider's `context_timeout_s`.
|
| 262 |
|
| 263 |
Raises:
|
| 264 |
+
RuntimeError: If the environment is not running.
|
| 265 |
+
TimeoutError: If the environment does not become ready within the timeout.
|
| 266 |
"""
|
| 267 |
+
if timeout_s is None:
|
| 268 |
+
timeout_s = self.context_timeout_s
|
| 269 |
+
|
| 270 |
if self._process and self._process.poll() is not None:
|
| 271 |
code = self._process.returncode
|
| 272 |
raise RuntimeError(f"uv process exited prematurely with code {code}")
|
|
|
|
| 276 |
def stop(self) -> None:
|
| 277 |
"""
|
| 278 |
Stop the environment.
|
|
|
|
|
|
|
|
|
|
| 279 |
"""
|
| 280 |
+
if self._process is not None:
|
| 281 |
+
if self._process.poll() is None:
|
| 282 |
+
self._process.terminate()
|
| 283 |
+
try:
|
| 284 |
+
self._process.wait(timeout=10.0)
|
| 285 |
+
except subprocess.TimeoutExpired:
|
| 286 |
+
self._process.kill()
|
| 287 |
+
self._process.wait(timeout=5.0)
|
| 288 |
+
|
| 289 |
+
self._process = None
|
| 290 |
+
self._base_url = None
|
| 291 |
+
|
| 292 |
+
if self._clone_dir is not None:
|
| 293 |
+
shutil.rmtree(self._clone_dir, ignore_errors=True)
|
| 294 |
+
self._clone_dir = None
|
| 295 |
|
| 296 |
@property
|
| 297 |
def base_url(self) -> str:
|
|
|
|
| 299 |
The base URL of the environment.
|
| 300 |
|
| 301 |
Returns:
|
| 302 |
+
`str`: Base URL of the running environment.
|
| 303 |
|
| 304 |
Raises:
|
| 305 |
+
RuntimeError: If the environment has not been started.
|
| 306 |
"""
|
| 307 |
if self._base_url is None:
|
| 308 |
raise RuntimeError("UVProvider has not been started")
|
src/core/containers/test_local_docker_provider.py
CHANGED
|
@@ -19,7 +19,7 @@ import requests
|
|
| 19 |
from openenv.core.containers.runtime import LocalDockerProvider
|
| 20 |
|
| 21 |
|
| 22 |
-
# TODO: Remove this test or make it a functional test
|
| 23 |
def test_local_docker_provider():
|
| 24 |
"""Test LocalDockerProvider end-to-end."""
|
| 25 |
print("=" * 60)
|
|
|
|
| 19 |
from openenv.core.containers.runtime import LocalDockerProvider
|
| 20 |
|
| 21 |
|
| 22 |
+
# TODO: Remove this test or make it a functional test since 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)
|
src/core/env_client.py
CHANGED
|
@@ -1,8 +1,4 @@
|
|
| 1 |
-
#
|
| 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 |
Environment client for persistent sessions.
|
|
@@ -14,29 +10,43 @@ the overhead of HTTP request/response cycles.
|
|
| 14 |
The client is async by default. For synchronous usage, use the `.sync()` method
|
| 15 |
to get a `SyncEnvClient` wrapper.
|
| 16 |
|
| 17 |
-
|
| 18 |
-
>>> async with GenericEnvClient(base_url="ws://localhost:8000") as env:
|
| 19 |
-
... result = await env.reset()
|
| 20 |
-
... result = await env.step({"code": "print('hello')"})
|
| 21 |
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
"""
|
| 28 |
|
| 29 |
from __future__ import annotations
|
| 30 |
|
| 31 |
import asyncio
|
|
|
|
|
|
|
| 32 |
import json
|
| 33 |
import os
|
|
|
|
| 34 |
from abc import ABC, abstractmethod
|
| 35 |
-
from
|
|
|
|
|
|
|
|
|
|
| 36 |
|
| 37 |
from .client_types import StateT, StepResult
|
| 38 |
from .containers.runtime import LocalDockerProvider, UVProvider
|
| 39 |
-
from .utils import convert_to_ws_url
|
| 40 |
|
| 41 |
if TYPE_CHECKING:
|
| 42 |
from websockets.asyncio.client import ClientConnection
|
|
@@ -45,10 +55,224 @@ if TYPE_CHECKING:
|
|
| 45 |
from .sync_client import SyncEnvClient
|
| 46 |
|
| 47 |
from websockets.asyncio.client import connect as ws_connect
|
|
|
|
|
|
|
| 48 |
|
| 49 |
ActT = TypeVar("ActT")
|
| 50 |
ObsT = TypeVar("ObsT")
|
| 51 |
EnvClientT = TypeVar("EnvClientT", bound="EnvClient")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
|
| 53 |
|
| 54 |
class EnvClient(ABC, Generic[ActT, ObsT, StateT]):
|
|
@@ -68,75 +292,209 @@ class EnvClient(ABC, Generic[ActT, ObsT, StateT]):
|
|
| 68 |
- Better suited for long-running episodes
|
| 69 |
- Async by default for modern Python async/await patterns
|
| 70 |
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
..
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
"""
|
| 87 |
|
| 88 |
def __init__(
|
| 89 |
self,
|
| 90 |
-
base_url: str,
|
| 91 |
connect_timeout_s: float = 10.0,
|
| 92 |
message_timeout_s: float = 60.0,
|
| 93 |
max_message_size_mb: float = 100.0,
|
|
|
|
|
|
|
| 94 |
provider: Optional["ContainerProvider | RuntimeProvider"] = None,
|
| 95 |
mode: Optional[str] = None,
|
|
|
|
| 96 |
):
|
| 97 |
"""
|
| 98 |
Initialize environment client.
|
| 99 |
|
| 100 |
Args:
|
| 101 |
-
base_url
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
"""
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
mode = os.environ.get("OPENENV_CLIENT_MODE", "simulation")
|
| 117 |
-
|
| 118 |
-
# Normalize and validate mode
|
| 119 |
-
mode = mode.lower()
|
| 120 |
-
if mode not in ("simulation", "production"):
|
| 121 |
-
raise ValueError(
|
| 122 |
-
f"Invalid mode: '{mode}'. Must be 'simulation' or 'production'. "
|
| 123 |
-
f"Set via constructor parameter or OPENENV_CLIENT_MODE environment variable."
|
| 124 |
-
)
|
| 125 |
|
| 126 |
# Store mode (use object.__setattr__ to bypass immutability)
|
| 127 |
-
object.__setattr__(self, "_mode", mode)
|
| 128 |
-
|
| 129 |
-
# Convert HTTP URL to WebSocket URL
|
| 130 |
-
ws_url = convert_to_ws_url(base_url)
|
| 131 |
|
| 132 |
-
self.
|
|
|
|
| 133 |
self._connect_timeout = connect_timeout_s
|
| 134 |
self._message_timeout = message_timeout_s
|
| 135 |
self._max_message_size = int(
|
| 136 |
max_message_size_mb * 1024 * 1024
|
| 137 |
) # Convert MB to bytes
|
|
|
|
|
|
|
|
|
|
| 138 |
self._provider = provider
|
|
|
|
|
|
|
|
|
|
|
|
|
| 139 |
self._ws: Optional[ClientConnection] = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 140 |
|
| 141 |
def __setattr__(self, name: str, value: Any) -> None:
|
| 142 |
"""Prevent modification of _mode after initialization."""
|
|
@@ -144,7 +502,56 @@ class EnvClient(ABC, Generic[ActT, ObsT, StateT]):
|
|
| 144 |
raise AttributeError("Cannot modify mode after initialization")
|
| 145 |
super().__setattr__(name, value)
|
| 146 |
|
| 147 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 148 |
"""
|
| 149 |
Establish WebSocket connection to the server.
|
| 150 |
|
|
@@ -155,59 +562,132 @@ class EnvClient(ABC, Generic[ActT, ObsT, StateT]):
|
|
| 155 |
ConnectionError: If connection cannot be established
|
| 156 |
"""
|
| 157 |
if self._ws is not None:
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 174 |
|
| 175 |
try:
|
| 176 |
self._ws = await ws_connect(
|
| 177 |
self._ws_url,
|
| 178 |
open_timeout=self._connect_timeout,
|
| 179 |
max_size=self._max_message_size,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 180 |
)
|
|
|
|
| 181 |
except Exception as e:
|
|
|
|
| 182 |
raise ConnectionError(f"Failed to connect to {self._ws_url}: {e}") from e
|
| 183 |
-
finally:
|
| 184 |
-
# Restore original NO_PROXY value
|
| 185 |
-
if is_localhost:
|
| 186 |
-
if old_no_proxy is None:
|
| 187 |
-
os.environ.pop("NO_PROXY", None)
|
| 188 |
-
else:
|
| 189 |
-
os.environ["NO_PROXY"] = old_no_proxy
|
| 190 |
|
| 191 |
return self
|
| 192 |
|
| 193 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 194 |
"""Close the WebSocket connection."""
|
| 195 |
if self._ws is not None:
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
pass # Best effort
|
| 201 |
-
try:
|
| 202 |
-
await self._ws.close()
|
| 203 |
-
except Exception:
|
| 204 |
-
pass
|
| 205 |
self._ws = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 206 |
|
| 207 |
async def _ensure_connected(self) -> None:
|
| 208 |
-
"""Ensure WebSocket connection is established.
|
| 209 |
-
|
| 210 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 211 |
|
| 212 |
async def _send(self, message: Dict[str, Any]) -> None:
|
| 213 |
"""Send a message over the WebSocket."""
|
|
@@ -216,14 +696,49 @@ class EnvClient(ABC, Generic[ActT, ObsT, StateT]):
|
|
| 216 |
await self._ws.send(json.dumps(message))
|
| 217 |
|
| 218 |
async def _receive(self) -> Dict[str, Any]:
|
| 219 |
-
"""Receive
|
|
|
|
|
|
|
|
|
|
|
|
|
| 220 |
assert self._ws is not None
|
| 221 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 222 |
return json.loads(raw)
|
| 223 |
|
| 224 |
async def _send_and_receive(self, message: Dict[str, Any]) -> Dict[str, Any]:
|
| 225 |
"""Send a message and wait for response."""
|
| 226 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 227 |
response = await self._receive()
|
| 228 |
|
| 229 |
# Check for error response
|
|
@@ -237,40 +752,78 @@ class EnvClient(ABC, Generic[ActT, ObsT, StateT]):
|
|
| 237 |
return response
|
| 238 |
|
| 239 |
@classmethod
|
| 240 |
-
|
| 241 |
cls: Type[EnvClientT],
|
| 242 |
image: str,
|
| 243 |
provider: Optional["ContainerProvider"] = None,
|
| 244 |
**kwargs: Any,
|
| 245 |
) -> EnvClientT:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 246 |
"""
|
| 247 |
Create an environment client by spinning up a Docker container.
|
| 248 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 249 |
Args:
|
| 250 |
-
image
|
| 251 |
-
|
| 252 |
-
|
|
|
|
|
|
|
|
|
|
| 253 |
|
| 254 |
Returns:
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
if provider is None:
|
| 258 |
-
provider = LocalDockerProvider()
|
| 259 |
|
| 260 |
-
|
| 261 |
-
base_url = provider.start_container(image, **kwargs)
|
| 262 |
|
| 263 |
-
|
| 264 |
-
|
|
|
|
| 265 |
|
| 266 |
-
#
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
|
|
|
|
|
|
|
|
|
| 271 |
|
| 272 |
@classmethod
|
| 273 |
-
|
| 274 |
cls: Type[EnvClientT],
|
| 275 |
repo_id: str,
|
| 276 |
*,
|
|
@@ -278,40 +831,13 @@ class EnvClient(ABC, Generic[ActT, ObsT, StateT]):
|
|
| 278 |
provider: Optional["ContainerProvider | RuntimeProvider"] = None,
|
| 279 |
**provider_kwargs: Any,
|
| 280 |
) -> EnvClientT:
|
| 281 |
-
"""
|
| 282 |
-
Create a client from a Hugging Face Space.
|
| 283 |
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
provider: Optional provider instance to reuse. Must be a
|
| 290 |
-
:class:`ContainerProvider` when ``use_docker=True`` and a
|
| 291 |
-
:class:`RuntimeProvider` otherwise.
|
| 292 |
-
provider_kwargs: Additional keyword arguments forwarded to
|
| 293 |
-
either the container provider's ``start_container`` (docker)
|
| 294 |
-
or to the ``UVProvider`` constructor/start (uv). When
|
| 295 |
-
``use_docker=False``, the ``project_path`` argument can be
|
| 296 |
-
used to override the default git URL
|
| 297 |
-
(``git+https://huggingface.co/spaces/{repo_id}``).
|
| 298 |
-
|
| 299 |
-
Returns:
|
| 300 |
-
Connected client instance
|
| 301 |
-
|
| 302 |
-
Examples:
|
| 303 |
-
>>> # Pull and run from HF Docker registry
|
| 304 |
-
>>> env = await MyEnv.from_env("openenv/echo-env")
|
| 305 |
-
>>>
|
| 306 |
-
>>> # Run locally with UV (clones the space)
|
| 307 |
-
>>> env = await MyEnv.from_env("openenv/echo-env", use_docker=False)
|
| 308 |
-
>>>
|
| 309 |
-
>>> # Run from a local checkout
|
| 310 |
-
>>> env = await MyEnv.from_env(
|
| 311 |
-
... "openenv/echo-env",
|
| 312 |
-
... use_docker=False,
|
| 313 |
-
... project_path="/path/to/local/checkout"
|
| 314 |
-
... )
|
| 315 |
"""
|
| 316 |
# Extract start args that apply to both providers
|
| 317 |
start_args = {}
|
|
@@ -323,15 +849,18 @@ class EnvClient(ABC, Generic[ActT, ObsT, StateT]):
|
|
| 323 |
# Docker mode: pull from HF registry
|
| 324 |
docker_provider = provider or LocalDockerProvider()
|
| 325 |
tag = provider_kwargs.pop("tag", "latest")
|
| 326 |
-
image = f"registry.hf.space/{
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
|
|
|
|
|
|
|
|
|
| 335 |
else:
|
| 336 |
# UV mode: clone and run with uv
|
| 337 |
if provider is None:
|
|
@@ -347,12 +876,92 @@ class EnvClient(ABC, Generic[ActT, ObsT, StateT]):
|
|
| 347 |
"provider_kwargs cannot be used when supplying a provider instance"
|
| 348 |
)
|
| 349 |
|
| 350 |
-
|
| 351 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 352 |
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 356 |
|
| 357 |
@abstractmethod
|
| 358 |
def _step_payload(self, action: ActT) -> Dict[str, Any]:
|
|
@@ -369,15 +978,16 @@ class EnvClient(ABC, Generic[ActT, ObsT, StateT]):
|
|
| 369 |
"""Convert a JSON response from the state endpoint to a State object."""
|
| 370 |
raise NotImplementedError
|
| 371 |
|
| 372 |
-
|
|
|
|
|
|
|
|
|
|
| 373 |
"""
|
| 374 |
Reset the environment with optional parameters.
|
| 375 |
|
| 376 |
Args:
|
| 377 |
-
**kwargs:
|
| 378 |
-
|
| 379 |
-
- seed: Random seed for reproducibility
|
| 380 |
-
- episode_id: Custom episode identifier
|
| 381 |
|
| 382 |
Returns:
|
| 383 |
StepResult containing initial observation
|
|
@@ -389,13 +999,18 @@ class EnvClient(ABC, Generic[ActT, ObsT, StateT]):
|
|
| 389 |
response = await self._send_and_receive(message)
|
| 390 |
return self._parse_result(response.get("data", {}))
|
| 391 |
|
| 392 |
-
|
|
|
|
|
|
|
|
|
|
| 393 |
"""
|
| 394 |
Execute an action in the environment.
|
| 395 |
|
| 396 |
Args:
|
| 397 |
-
action:
|
| 398 |
-
|
|
|
|
|
|
|
| 399 |
|
| 400 |
Returns:
|
| 401 |
StepResult containing observation, reward, and done status
|
|
@@ -407,7 +1022,10 @@ class EnvClient(ABC, Generic[ActT, ObsT, StateT]):
|
|
| 407 |
response = await self._send_and_receive(message)
|
| 408 |
return self._parse_result(response.get("data", {}))
|
| 409 |
|
| 410 |
-
|
|
|
|
|
|
|
|
|
|
| 411 |
"""
|
| 412 |
Get the current environment state from the server.
|
| 413 |
|
|
@@ -418,24 +1036,120 @@ class EnvClient(ABC, Generic[ActT, ObsT, StateT]):
|
|
| 418 |
response = await self._send_and_receive(message)
|
| 419 |
return self._parse_state(response.get("data", {}))
|
| 420 |
|
| 421 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 422 |
"""
|
| 423 |
Close the WebSocket connection and clean up resources.
|
| 424 |
|
| 425 |
If this client was created via from_docker_image() or from_env(),
|
| 426 |
this will also stop and remove the associated container/process.
|
| 427 |
"""
|
| 428 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 429 |
|
| 430 |
-
|
| 431 |
-
|
| 432 |
-
|
| 433 |
-
|
| 434 |
-
|
| 435 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 436 |
|
| 437 |
async def __aenter__(self) -> "EnvClient":
|
| 438 |
"""Enter async context manager, ensuring connection is established."""
|
|
|
|
| 439 |
await self.connect()
|
| 440 |
return self
|
| 441 |
|
|
@@ -444,17 +1158,13 @@ class EnvClient(ABC, Generic[ActT, ObsT, StateT]):
|
|
| 444 |
await self.close()
|
| 445 |
|
| 446 |
def __enter__(self) -> "EnvClient":
|
| 447 |
-
"""
|
| 448 |
-
|
| 449 |
-
|
| 450 |
-
"or call .sync() to get a synchronous wrapper:\n"
|
| 451 |
-
" async with client: # async usage\n"
|
| 452 |
-
" with client.sync(): # sync wrapper"
|
| 453 |
-
)
|
| 454 |
|
| 455 |
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
|
| 456 |
-
"""
|
| 457 |
-
|
| 458 |
|
| 459 |
def sync(self) -> "SyncEnvClient":
|
| 460 |
"""
|
|
@@ -469,16 +1179,19 @@ class EnvClient(ABC, Generic[ActT, ObsT, StateT]):
|
|
| 469 |
Returns:
|
| 470 |
SyncEnvClient wrapper that provides synchronous methods
|
| 471 |
|
| 472 |
-
|
| 473 |
-
|
| 474 |
-
|
| 475 |
-
|
| 476 |
-
|
| 477 |
-
|
| 478 |
-
|
| 479 |
-
|
| 480 |
-
|
|
|
|
| 481 |
"""
|
| 482 |
from .sync_client import SyncEnvClient
|
| 483 |
|
| 484 |
-
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-License-Identifier: BSD-3-Clause
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
"""
|
| 4 |
Environment client for persistent sessions.
|
|
|
|
| 10 |
The client is async by default. For synchronous usage, use the `.sync()` method
|
| 11 |
to get a `SyncEnvClient` wrapper.
|
| 12 |
|
| 13 |
+
Examples:
|
|
|
|
|
|
|
|
|
|
| 14 |
|
| 15 |
+
Async usage:
|
| 16 |
+
|
| 17 |
+
```python
|
| 18 |
+
async with GenericEnvClient(base_url="ws://localhost:8000") as env:
|
| 19 |
+
result = await env.reset()
|
| 20 |
+
result = await env.step({"code": "print('hello')"})
|
| 21 |
+
```
|
| 22 |
+
|
| 23 |
+
Sync usage via `.sync()` wrapper:
|
| 24 |
+
|
| 25 |
+
```python
|
| 26 |
+
env = GenericEnvClient(base_url="ws://localhost:8000").sync()
|
| 27 |
+
with env:
|
| 28 |
+
result = env.reset()
|
| 29 |
+
result = env.step({"code": "print('hello')"})
|
| 30 |
+
```
|
| 31 |
"""
|
| 32 |
|
| 33 |
from __future__ import annotations
|
| 34 |
|
| 35 |
import asyncio
|
| 36 |
+
import inspect
|
| 37 |
+
import ipaddress
|
| 38 |
import json
|
| 39 |
import os
|
| 40 |
+
import time
|
| 41 |
from abc import ABC, abstractmethod
|
| 42 |
+
from collections.abc import Coroutine
|
| 43 |
+
from contextlib import suppress
|
| 44 |
+
from typing import Any, Callable, Dict, Generic, Optional, Type, TYPE_CHECKING, TypeVar
|
| 45 |
+
from urllib.parse import urlsplit
|
| 46 |
|
| 47 |
from .client_types import StateT, StepResult
|
| 48 |
from .containers.runtime import LocalDockerProvider, UVProvider
|
| 49 |
+
from .utils import convert_to_ws_url, hf_space_subdomain
|
| 50 |
|
| 51 |
if TYPE_CHECKING:
|
| 52 |
from websockets.asyncio.client import ClientConnection
|
|
|
|
| 55 |
from .sync_client import SyncEnvClient
|
| 56 |
|
| 57 |
from websockets.asyncio.client import connect as ws_connect
|
| 58 |
+
from websockets.exceptions import ConnectionClosed
|
| 59 |
+
from websockets.protocol import State
|
| 60 |
|
| 61 |
ActT = TypeVar("ActT")
|
| 62 |
ObsT = TypeVar("ObsT")
|
| 63 |
EnvClientT = TypeVar("EnvClientT", bound="EnvClient")
|
| 64 |
+
ResultT = TypeVar("ResultT")
|
| 65 |
+
|
| 66 |
+
_VALID_CLIENT_MODES = ("simulation", "production")
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
class _AutoAsyncResult(Generic[ResultT]):
|
| 70 |
+
"""Awaitable result that can also be resolved by synchronous access."""
|
| 71 |
+
|
| 72 |
+
def __init__(
|
| 73 |
+
self,
|
| 74 |
+
client: "EnvClient[Any, Any, Any]",
|
| 75 |
+
coro_factory: Callable[[], Any],
|
| 76 |
+
):
|
| 77 |
+
self._client = client
|
| 78 |
+
self._coro_factory = coro_factory
|
| 79 |
+
self._resolved = False
|
| 80 |
+
self._result: ResultT | None = None
|
| 81 |
+
|
| 82 |
+
def __await__(self):
|
| 83 |
+
async def _await_result():
|
| 84 |
+
self._client._claim_execution_mode("async")
|
| 85 |
+
return await self._coro_factory()
|
| 86 |
+
|
| 87 |
+
return _await_result().__await__()
|
| 88 |
+
|
| 89 |
+
def _sync_result(self) -> ResultT:
|
| 90 |
+
if not self._resolved:
|
| 91 |
+
self._result = self._client._run_sync(self._coro_factory)
|
| 92 |
+
self._resolved = True
|
| 93 |
+
return self._result
|
| 94 |
+
|
| 95 |
+
def __getattr__(self, name: str) -> Any:
|
| 96 |
+
return getattr(self._sync_result(), name)
|
| 97 |
+
|
| 98 |
+
def __bool__(self) -> bool:
|
| 99 |
+
return bool(self._sync_result())
|
| 100 |
+
|
| 101 |
+
def __repr__(self) -> str:
|
| 102 |
+
if self._resolved:
|
| 103 |
+
return repr(self._result)
|
| 104 |
+
return f"<{type(self).__name__} pending>"
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
class _BootstrapResult(Coroutine, Generic[EnvClientT]):
|
| 108 |
+
"""Bootstrap handle returned by the client factory methods.
|
| 109 |
+
|
| 110 |
+
Returned by [`~openenv.core.EnvClient.from_docker_image`] and
|
| 111 |
+
[`~openenv.core.EnvClient.from_env`]. Resolving the handle is what actually
|
| 112 |
+
starts the container / Space and connects the WebSocket, so a single factory
|
| 113 |
+
call serves both execution modes:
|
| 114 |
+
|
| 115 |
+
- `await handle` connects on the running event loop and returns the connected
|
| 116 |
+
async client (unchanged async behavior).
|
| 117 |
+
- `handle.sync()` connects on the sync background loop and returns a
|
| 118 |
+
`SyncEnvClient`, mirroring the instance-level [`~openenv.core.EnvClient.sync`].
|
| 119 |
+
|
| 120 |
+
The handle is a full coroutine (it implements `send` / `throw` / `close`), so
|
| 121 |
+
it is a drop-in for the previous `async def` factories: `asyncio.run(...)`,
|
| 122 |
+
`run_async_safely(...)`, and bare `await` all accept it, in addition to the
|
| 123 |
+
new `.sync()` chain. Bootstrap is lazy — the underlying coroutine (and the
|
| 124 |
+
container/Space start) is created only when the handle is driven or `.sync()`
|
| 125 |
+
is called.
|
| 126 |
+
"""
|
| 127 |
+
|
| 128 |
+
def __init__(self, bootstrap: Callable[[], EnvClientT]):
|
| 129 |
+
self._bootstrap = bootstrap
|
| 130 |
+
self._coro: Optional[Coroutine[Any, Any, EnvClientT]] = None
|
| 131 |
+
self._used = False
|
| 132 |
+
|
| 133 |
+
def _consume(self) -> EnvClientT:
|
| 134 |
+
"""Run the bootstrap exactly once; a second resolve is a programming error.
|
| 135 |
+
|
| 136 |
+
Mirrors native coroutine semantics (a coroutine cannot be awaited twice)
|
| 137 |
+
so that a handle re-driven via a second `.sync()`, or `await` followed by
|
| 138 |
+
`.sync()`, raises instead of silently starting a second container/Space.
|
| 139 |
+
"""
|
| 140 |
+
if self._used:
|
| 141 |
+
raise RuntimeError(
|
| 142 |
+
"This bootstrap handle has already been resolved; call the "
|
| 143 |
+
"factory again to start a new environment."
|
| 144 |
+
)
|
| 145 |
+
self._used = True
|
| 146 |
+
return self._bootstrap()
|
| 147 |
+
|
| 148 |
+
async def _resolve_async(self) -> EnvClientT:
|
| 149 |
+
client = self._consume()
|
| 150 |
+
try:
|
| 151 |
+
await client.connect()
|
| 152 |
+
except asyncio.CancelledError:
|
| 153 |
+
# _connect_async releases the provider on ordinary failures, but
|
| 154 |
+
# cancellation bypasses its `except Exception`. No client reaches
|
| 155 |
+
# the caller, so release the provider here before propagating the
|
| 156 |
+
# original cancellation. A repeated cancellation or a stop failure
|
| 157 |
+
# during cleanup must not replace it.
|
| 158 |
+
with suppress(Exception, asyncio.CancelledError):
|
| 159 |
+
await client.close()
|
| 160 |
+
raise
|
| 161 |
+
return client
|
| 162 |
+
|
| 163 |
+
def _ensure_coro(self) -> "Coroutine[Any, Any, EnvClientT]":
|
| 164 |
+
if self._coro is None:
|
| 165 |
+
self._coro = self._resolve_async()
|
| 166 |
+
return self._coro
|
| 167 |
+
|
| 168 |
+
def __await__(self):
|
| 169 |
+
return self._ensure_coro().__await__()
|
| 170 |
+
|
| 171 |
+
def send(self, value: Any) -> Any:
|
| 172 |
+
return self._ensure_coro().send(value)
|
| 173 |
+
|
| 174 |
+
def throw(self, *args: Any, **kwargs: Any) -> Any:
|
| 175 |
+
return self._ensure_coro().throw(*args, **kwargs)
|
| 176 |
+
|
| 177 |
+
def close(self) -> None:
|
| 178 |
+
if self._coro is not None:
|
| 179 |
+
self._coro.close()
|
| 180 |
+
|
| 181 |
+
def sync(self) -> "SyncEnvClient":
|
| 182 |
+
client = self._consume()
|
| 183 |
+
try:
|
| 184 |
+
client._run_sync(client._connect_async)
|
| 185 |
+
except Exception:
|
| 186 |
+
# _consume() already started the provider. On the async path
|
| 187 |
+
# _connect_async releases it, but here the failure may be the sync
|
| 188 |
+
# loop setup itself (before _connect_async runs), so stop the
|
| 189 |
+
# provider directly rather than routing through the broken loop.
|
| 190 |
+
client._stop_provider_best_effort()
|
| 191 |
+
raise
|
| 192 |
+
return client.sync()
|
| 193 |
+
|
| 194 |
+
def __repr__(self) -> str:
|
| 195 |
+
return f"<{type(self).__name__} pending (await, run, or call .sync())>"
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
def _normalize_mode(mode: Optional[str]) -> str:
|
| 199 |
+
"""Resolve and validate the client communication mode."""
|
| 200 |
+
raw_mode = (
|
| 201 |
+
os.environ.get("OPENENV_CLIENT_MODE", "simulation") if mode is None else mode
|
| 202 |
+
)
|
| 203 |
+
normalized_mode = raw_mode.lower()
|
| 204 |
+
if normalized_mode not in _VALID_CLIENT_MODES:
|
| 205 |
+
raise ValueError(
|
| 206 |
+
f"Invalid mode: '{normalized_mode}'. Must be 'simulation' or 'production'. "
|
| 207 |
+
f"Set via constructor parameter or OPENENV_CLIENT_MODE environment variable."
|
| 208 |
+
)
|
| 209 |
+
return normalized_mode
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
def _is_localhost_ws_url(ws_url: str) -> bool:
|
| 213 |
+
"""Return True when the WebSocket URL targets the local loopback interface.
|
| 214 |
+
|
| 215 |
+
The hostname is parsed from the URL so that only the actual host is matched.
|
| 216 |
+
Substring matching is avoided because remote hosts such as
|
| 217 |
+
``my-localhost-proxy.example.com`` or ``127.0.0.1.example.com`` must not be
|
| 218 |
+
treated as local.
|
| 219 |
+
"""
|
| 220 |
+
hostname = urlsplit(ws_url).hostname
|
| 221 |
+
if hostname is None:
|
| 222 |
+
return False
|
| 223 |
+
if hostname == "localhost":
|
| 224 |
+
return True
|
| 225 |
+
try:
|
| 226 |
+
return ipaddress.ip_address(hostname).is_loopback
|
| 227 |
+
except ValueError:
|
| 228 |
+
return False
|
| 229 |
+
|
| 230 |
+
|
| 231 |
+
def _required_start_container_parameters(provider: Any) -> list[str]:
|
| 232 |
+
"""Return required arguments for a bound provider.start_container()."""
|
| 233 |
+
try:
|
| 234 |
+
signature = inspect.signature(provider.start_container)
|
| 235 |
+
except (TypeError, ValueError):
|
| 236 |
+
return []
|
| 237 |
+
return [
|
| 238 |
+
name
|
| 239 |
+
for name, parameter in signature.parameters.items()
|
| 240 |
+
if parameter.default is inspect.Parameter.empty
|
| 241 |
+
and parameter.kind
|
| 242 |
+
in (
|
| 243 |
+
inspect.Parameter.POSITIONAL_ONLY,
|
| 244 |
+
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
| 245 |
+
inspect.Parameter.KEYWORD_ONLY,
|
| 246 |
+
)
|
| 247 |
+
]
|
| 248 |
+
|
| 249 |
+
|
| 250 |
+
async def _best_effort_close(ws: ClientConnection) -> None:
|
| 251 |
+
"""Close a socket without letting a slow handshake or failure propagate.
|
| 252 |
+
|
| 253 |
+
Scheduled as a background task (never awaited directly) so a dropped
|
| 254 |
+
socket's close handshake can't hold up the caller that triggered the
|
| 255 |
+
drop -- see `EnvClient._receive()`. `EnvClient._close_async()` awaits
|
| 256 |
+
any still-running instance of this task before it returns, so a real
|
| 257 |
+
`close()` call does wait for the handshake; only the caller that
|
| 258 |
+
happened to trigger the drop is spared. `CancelledError` is caught
|
| 259 |
+
alongside `Exception` because that awaiting is exactly what would
|
| 260 |
+
otherwise cancel this mid-handshake if a caller called `close()`
|
| 261 |
+
concurrently.
|
| 262 |
+
"""
|
| 263 |
+
try:
|
| 264 |
+
await ws.close()
|
| 265 |
+
except (Exception, asyncio.CancelledError):
|
| 266 |
+
pass # Best effort
|
| 267 |
+
|
| 268 |
+
|
| 269 |
+
async def _best_effort_graceful_close(ws: ClientConnection) -> None:
|
| 270 |
+
"""Notify the server, then close a socket without propagating failures."""
|
| 271 |
+
try:
|
| 272 |
+
await ws.send(json.dumps({"type": "close"}))
|
| 273 |
+
except (Exception, asyncio.CancelledError):
|
| 274 |
+
pass # Best effort
|
| 275 |
+
await _best_effort_close(ws)
|
| 276 |
|
| 277 |
|
| 278 |
class EnvClient(ABC, Generic[ActT, ObsT, StateT]):
|
|
|
|
| 292 |
- Better suited for long-running episodes
|
| 293 |
- Async by default for modern Python async/await patterns
|
| 294 |
|
| 295 |
+
Examples:
|
| 296 |
+
|
| 297 |
+
Async usage:
|
| 298 |
+
|
| 299 |
+
```python
|
| 300 |
+
from envs.coding_env.client import CodingEnv
|
| 301 |
+
|
| 302 |
+
async with CodingEnv(base_url="ws://localhost:8000") as env:
|
| 303 |
+
result = await env.reset(seed=42)
|
| 304 |
+
while not result.done:
|
| 305 |
+
action = agent.predict(result.observation)
|
| 306 |
+
result = await env.step(action)
|
| 307 |
+
```
|
| 308 |
+
|
| 309 |
+
Sync usage via `.sync()` wrapper:
|
| 310 |
+
|
| 311 |
+
```python
|
| 312 |
+
env = CodingEnv(base_url="ws://localhost:8000").sync()
|
| 313 |
+
with env:
|
| 314 |
+
result = env.reset(seed=42)
|
| 315 |
+
result = env.step(action)
|
| 316 |
+
```
|
| 317 |
"""
|
| 318 |
|
| 319 |
def __init__(
|
| 320 |
self,
|
| 321 |
+
base_url: Optional[str] = None,
|
| 322 |
connect_timeout_s: float = 10.0,
|
| 323 |
message_timeout_s: float = 60.0,
|
| 324 |
max_message_size_mb: float = 100.0,
|
| 325 |
+
websocket_ping_interval_s: Optional[float] = 20.0,
|
| 326 |
+
websocket_ping_timeout_s: Optional[float] = 20.0,
|
| 327 |
provider: Optional["ContainerProvider | RuntimeProvider"] = None,
|
| 328 |
mode: Optional[str] = None,
|
| 329 |
+
headers: Optional[Dict[str, str]] = None,
|
| 330 |
):
|
| 331 |
"""
|
| 332 |
Initialize environment client.
|
| 333 |
|
| 334 |
Args:
|
| 335 |
+
base_url (`str`, *optional*):
|
| 336 |
+
Base URL of the environment server (http:// or ws://). Will be converted to
|
| 337 |
+
ws:// if http:// is provided. May be omitted when the provider
|
| 338 |
+
has enough constructor state to start itself.
|
| 339 |
+
connect_timeout_s (`float`, *optional*, defaults to `10.0`):
|
| 340 |
+
Timeout for establishing WebSocket connection.
|
| 341 |
+
message_timeout_s (`float`, *optional*, defaults to `60.0`):
|
| 342 |
+
Timeout for receiving responses to messages.
|
| 343 |
+
max_message_size_mb (`float`, *optional*, defaults to `100.0`):
|
| 344 |
+
Maximum WebSocket message size in megabytes. Default 100MB to handle large
|
| 345 |
+
observations (screenshots, DOM, etc.).
|
| 346 |
+
websocket_ping_interval_s (`float` or `None`, *optional*, defaults to `20.0`):
|
| 347 |
+
WebSocket keepalive ping interval. Pass `None` to disable.
|
| 348 |
+
websocket_ping_timeout_s (`float` or `None`, *optional*, defaults to `20.0`):
|
| 349 |
+
WebSocket keepalive pong timeout. Pass `None` to disable.
|
| 350 |
+
provider (`ContainerProvider` or `RuntimeProvider`, *optional*):
|
| 351 |
+
Container/runtime provider for lifecycle management.
|
| 352 |
+
mode (`str`, *optional*):
|
| 353 |
+
Communication mode: `'simulation'` for Gym-style API (default) or
|
| 354 |
+
`'production'` for MCP JSON-RPC protocol. Can also be set via the
|
| 355 |
+
`OPENENV_CLIENT_MODE` environment variable. Constructor parameter takes
|
| 356 |
+
precedence over environment variable. Case-insensitive.
|
| 357 |
+
headers (`dict[str, str]`, *optional*):
|
| 358 |
+
Extra HTTP headers sent when opening the connection, e.g.
|
| 359 |
+
`{"Authorization": f"Bearer {hf_token}"}` for a private Hugging Face Space.
|
| 360 |
"""
|
| 361 |
+
if base_url is None and provider is None:
|
| 362 |
+
raise ValueError("EnvClient requires either base_url or provider.")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 363 |
|
| 364 |
# Store mode (use object.__setattr__ to bypass immutability)
|
| 365 |
+
object.__setattr__(self, "_mode", _normalize_mode(mode))
|
|
|
|
|
|
|
|
|
|
| 366 |
|
| 367 |
+
self._base_url: Optional[str] = None
|
| 368 |
+
self._ws_url: Optional[str] = None
|
| 369 |
self._connect_timeout = connect_timeout_s
|
| 370 |
self._message_timeout = message_timeout_s
|
| 371 |
self._max_message_size = int(
|
| 372 |
max_message_size_mb * 1024 * 1024
|
| 373 |
) # Convert MB to bytes
|
| 374 |
+
self._websocket_ping_interval_s = websocket_ping_interval_s
|
| 375 |
+
self._websocket_ping_timeout_s = websocket_ping_timeout_s
|
| 376 |
+
self._headers = headers
|
| 377 |
self._provider = provider
|
| 378 |
+
self._provider_stopped = False
|
| 379 |
+
self._provider_cleanup_pending = False
|
| 380 |
+
self._start_provider_on_connect = base_url is None
|
| 381 |
+
self._child_clients: list[EnvClient[Any, Any, Any]] = []
|
| 382 |
self._ws: Optional[ClientConnection] = None
|
| 383 |
+
self._execution_mode: Optional[str] = None
|
| 384 |
+
self._sync_client: Optional["SyncEnvClient[ActT, ObsT, StateT]"] = None
|
| 385 |
+
self._ws_loop: Optional[asyncio.AbstractEventLoop] = None
|
| 386 |
+
# Strong references for fire-and-forget socket closes (see _receive),
|
| 387 |
+
# so the task isn't garbage-collected mid-close. Discarded on done.
|
| 388 |
+
self._pending_close_tasks: set[asyncio.Task] = set()
|
| 389 |
+
if base_url is not None:
|
| 390 |
+
self._set_base_url(base_url)
|
| 391 |
+
|
| 392 |
+
@property
|
| 393 |
+
def base_url(self) -> Optional[str]:
|
| 394 |
+
"""Public read-only URL of the environment server this client targets.
|
| 395 |
+
|
| 396 |
+
Returns the normalized `http(s)`/`ws(s)` base URL (no trailing slash),
|
| 397 |
+
or `None` when the client was created with a provider but has not yet
|
| 398 |
+
started its container (the URL is assigned lazily on `connect()`).
|
| 399 |
+
|
| 400 |
+
This is the public counterpart to the private `self._base_url`; the
|
| 401 |
+
previously-public `base_url` attribute was dropped in the `core`
|
| 402 |
+
refactor, leaving sync consumers with no way to read the URL back.
|
| 403 |
+
"""
|
| 404 |
+
return self._base_url
|
| 405 |
+
|
| 406 |
+
def _set_base_url(self, base_url: str) -> None:
|
| 407 |
+
self._base_url = base_url.rstrip("/")
|
| 408 |
+
ws_url = convert_to_ws_url(base_url)
|
| 409 |
+
self._ws_url = f"{ws_url}/ws"
|
| 410 |
+
|
| 411 |
+
def _start_provider_if_needed(self) -> None:
|
| 412 |
+
# A missing URL does not mean the previous resource was released.
|
| 413 |
+
# Retry its cleanup before start can overwrite the provider's handle.
|
| 414 |
+
if self._provider_cleanup_pending:
|
| 415 |
+
if not self._start_provider_on_connect:
|
| 416 |
+
raise RuntimeError(
|
| 417 |
+
"Provider cleanup is pending for this client with an existing "
|
| 418 |
+
"base URL. Retry close() to finish cleanup, then create a new "
|
| 419 |
+
"client instead of reconnecting to the old URL."
|
| 420 |
+
)
|
| 421 |
+
self._stop_provider()
|
| 422 |
+
if self._ws_url is not None:
|
| 423 |
+
return
|
| 424 |
+
if self._provider is None:
|
| 425 |
+
raise RuntimeError("EnvClient has no base URL or provider.")
|
| 426 |
+
if hasattr(self._provider, "start_container"):
|
| 427 |
+
required_parameters = _required_start_container_parameters(self._provider)
|
| 428 |
+
if required_parameters:
|
| 429 |
+
required = ", ".join(required_parameters)
|
| 430 |
+
raise ValueError(
|
| 431 |
+
f"{type(self._provider).__name__} does not support "
|
| 432 |
+
"provider-owned startup because start_container() requires "
|
| 433 |
+
f"{required}. Start the provider manually and pass base_url, "
|
| 434 |
+
"or configure a provider with a constructor-owned image/source."
|
| 435 |
+
)
|
| 436 |
+
self._provider_stopped = False
|
| 437 |
+
base_url = self._provider.start_container()
|
| 438 |
+
self._provider.wait_for_ready(base_url)
|
| 439 |
+
elif hasattr(self._provider, "start"):
|
| 440 |
+
self._provider_stopped = False
|
| 441 |
+
base_url = self._provider.start()
|
| 442 |
+
self._provider.wait_for_ready()
|
| 443 |
+
else:
|
| 444 |
+
raise TypeError("provider must define start_container() or start().")
|
| 445 |
+
self._set_base_url(base_url)
|
| 446 |
+
|
| 447 |
+
def _create_session_client(self) -> "EnvClient[Any, Any, Any]":
|
| 448 |
+
# Match _start_provider_if_needed's startup condition. A provider with
|
| 449 |
+
# an existing URL may already be serving the parent or other children.
|
| 450 |
+
starts_provider_here = self._provider is not None and self._ws_url is None
|
| 451 |
+
try:
|
| 452 |
+
self._start_provider_if_needed()
|
| 453 |
+
if self._base_url is None:
|
| 454 |
+
raise RuntimeError("EnvClient has no base URL.")
|
| 455 |
+
|
| 456 |
+
signature = inspect.signature(type(self))
|
| 457 |
+
accepts_kwargs = any(
|
| 458 |
+
parameter.kind == inspect.Parameter.VAR_KEYWORD
|
| 459 |
+
for parameter in signature.parameters.values()
|
| 460 |
+
)
|
| 461 |
+
candidate_kwargs = {
|
| 462 |
+
"base_url": self._base_url,
|
| 463 |
+
"connect_timeout_s": self._connect_timeout,
|
| 464 |
+
"message_timeout_s": self._message_timeout,
|
| 465 |
+
"max_message_size_mb": self._max_message_size / (1024 * 1024),
|
| 466 |
+
"websocket_ping_interval_s": self._websocket_ping_interval_s,
|
| 467 |
+
"websocket_ping_timeout_s": self._websocket_ping_timeout_s,
|
| 468 |
+
"mode": self._mode,
|
| 469 |
+
"headers": self._headers,
|
| 470 |
+
}
|
| 471 |
+
constructor_kwargs = {}
|
| 472 |
+
for name, value in candidate_kwargs.items():
|
| 473 |
+
if accepts_kwargs or name in signature.parameters:
|
| 474 |
+
constructor_kwargs[name] = value
|
| 475 |
+
|
| 476 |
+
return type(self)(**constructor_kwargs)
|
| 477 |
+
except Exception:
|
| 478 |
+
if starts_provider_here and not self._provider_cleanup_pending:
|
| 479 |
+
self._stop_provider_best_effort()
|
| 480 |
+
raise
|
| 481 |
+
|
| 482 |
+
async def new_session(self) -> "EnvClient[Any, Any, Any]":
|
| 483 |
+
"""
|
| 484 |
+
Create and connect a new session against the same environment server.
|
| 485 |
+
|
| 486 |
+
Returns:
|
| 487 |
+
`EnvClient`: A connected child client of the same concrete type.
|
| 488 |
+
|
| 489 |
+
The child session is tracked by this parent and closed when the parent
|
| 490 |
+
is closed. Server-side capacity still applies: when the server is at
|
| 491 |
+
`MAX_CONCURRENT_ENVS`, opening the child WebSocket can fail and is
|
| 492 |
+
surfaced as a connection error.
|
| 493 |
+
"""
|
| 494 |
+
client = self._create_session_client()
|
| 495 |
+
await client.connect()
|
| 496 |
+
self._child_clients.append(client)
|
| 497 |
+
return client
|
| 498 |
|
| 499 |
def __setattr__(self, name: str, value: Any) -> None:
|
| 500 |
"""Prevent modification of _mode after initialization."""
|
|
|
|
| 502 |
raise AttributeError("Cannot modify mode after initialization")
|
| 503 |
super().__setattr__(name, value)
|
| 504 |
|
| 505 |
+
def _claim_execution_mode(self, mode: str) -> None:
|
| 506 |
+
"""Lock the client to sync or async execution on first use."""
|
| 507 |
+
if self._execution_mode is None:
|
| 508 |
+
self._execution_mode = mode
|
| 509 |
+
elif self._execution_mode != mode:
|
| 510 |
+
raise RuntimeError(
|
| 511 |
+
f"EnvClient is already being used in {self._execution_mode} mode. "
|
| 512 |
+
"Create a separate client instance when mixing sync and async code."
|
| 513 |
+
)
|
| 514 |
+
|
| 515 |
+
def _run_sync(
|
| 516 |
+
self,
|
| 517 |
+
coro_factory: Callable[[], Any],
|
| 518 |
+
*,
|
| 519 |
+
allow_async_handoff: bool = False,
|
| 520 |
+
) -> Any:
|
| 521 |
+
"""Run an async operation through the sync wrapper."""
|
| 522 |
+
if allow_async_handoff and self._execution_mode == "async":
|
| 523 |
+
self._execution_mode = "sync"
|
| 524 |
+
else:
|
| 525 |
+
self._claim_execution_mode("sync")
|
| 526 |
+
if self._sync_client is None:
|
| 527 |
+
self._sync_client = self.sync()
|
| 528 |
+
return self._sync_client._run(coro_factory())
|
| 529 |
+
|
| 530 |
+
def _dispatch(self, coro_factory: Callable[[], Any]) -> Any:
|
| 531 |
+
"""Return an awaitable in async code and a concrete result in sync code."""
|
| 532 |
+
try:
|
| 533 |
+
running_loop = asyncio.get_running_loop()
|
| 534 |
+
except RuntimeError:
|
| 535 |
+
running_loop = None
|
| 536 |
+
|
| 537 |
+
if self._execution_mode == "sync":
|
| 538 |
+
sync_loop = (
|
| 539 |
+
self._sync_client._loop if self._sync_client is not None else None
|
| 540 |
+
)
|
| 541 |
+
if running_loop is sync_loop:
|
| 542 |
+
return coro_factory()
|
| 543 |
+
return self._run_sync(coro_factory)
|
| 544 |
+
if running_loop is not None:
|
| 545 |
+
if self._execution_mode == "async":
|
| 546 |
+
return coro_factory()
|
| 547 |
+
return _AutoAsyncResult(self, coro_factory)
|
| 548 |
+
|
| 549 |
+
return self._run_sync(coro_factory, allow_async_handoff=True)
|
| 550 |
+
|
| 551 |
+
def connect(self) -> Any:
|
| 552 |
+
return self._dispatch(self._connect_async)
|
| 553 |
+
|
| 554 |
+
async def _connect_async(self) -> "EnvClient":
|
| 555 |
"""
|
| 556 |
Establish WebSocket connection to the server.
|
| 557 |
|
|
|
|
| 562 |
ConnectionError: If connection cannot be established
|
| 563 |
"""
|
| 564 |
if self._ws is not None:
|
| 565 |
+
if self._ws.state in (State.CLOSING, State.CLOSED):
|
| 566 |
+
# Closed by the far end: a keepalive timeout, a tunnel dropping
|
| 567 |
+
# the socket, the server restarting. The object is still not
|
| 568 |
+
# None, so without this check it stays cached and every later
|
| 569 |
+
# call raises `ConnectionClosed` for the rest of the process's
|
| 570 |
+
# life. Only a demonstrably closed socket is dropped, so one
|
| 571 |
+
# still CONNECTING is left alone.
|
| 572 |
+
self._ws = None
|
| 573 |
+
self._ws_loop = None
|
| 574 |
+
elif self._ws_loop is asyncio.get_running_loop():
|
| 575 |
+
return self
|
| 576 |
+
# Connected from a different event loop than the one running
|
| 577 |
+
# now -- e.g. `client = await Client.from_env(...)` inside
|
| 578 |
+
# `asyncio.run(...)`, then `client.sync()` drives every later
|
| 579 |
+
# call on `SyncEnvClient`'s own dedicated background loop. The
|
| 580 |
+
# websocket object is bound to internals of the original loop,
|
| 581 |
+
# which is typically already closed by the time we get here, so
|
| 582 |
+
# it cannot be reused (or even cleanly closed) from this loop.
|
| 583 |
+
# Drop the stale reference and reconnect fresh below rather than
|
| 584 |
+
# silently no-op-ing onto a dead connection.
|
| 585 |
+
self._ws = None
|
| 586 |
+
self._ws_loop = None
|
| 587 |
+
|
| 588 |
+
# A timed-out request drops its socket immediately but closes it in the
|
| 589 |
+
# background so the timeout itself remains prompt. Wait for that close
|
| 590 |
+
# before opening a replacement: the old server-side session continues
|
| 591 |
+
# occupying a capacity slot until the close handshake finishes, and
|
| 592 |
+
# many environments allow only one session.
|
| 593 |
+
await self._drain_pending_close_tasks()
|
| 594 |
+
|
| 595 |
+
try:
|
| 596 |
+
self._start_provider_if_needed()
|
| 597 |
+
except Exception:
|
| 598 |
+
# A failed cleanup retry must propagate without attempting stop
|
| 599 |
+
# again. For a new startup failure, preserve its original error.
|
| 600 |
+
if not self._provider_cleanup_pending:
|
| 601 |
+
with suppress(Exception):
|
| 602 |
+
await self.close()
|
| 603 |
+
raise
|
| 604 |
+
|
| 605 |
+
assert self._ws_url is not None
|
| 606 |
+
|
| 607 |
+
# Disable the proxy for localhost connections via the per-connection
|
| 608 |
+
# `proxy` argument rather than mutating the process-global NO_PROXY
|
| 609 |
+
# env var: concurrent connect() calls (e.g. asyncio.gather over many
|
| 610 |
+
# env clients) would otherwise race on os.environ and leak state.
|
| 611 |
+
connect_kwargs: Dict[str, Any] = {}
|
| 612 |
+
if _is_localhost_ws_url(self._ws_url):
|
| 613 |
+
connect_kwargs["proxy"] = None
|
| 614 |
|
| 615 |
try:
|
| 616 |
self._ws = await ws_connect(
|
| 617 |
self._ws_url,
|
| 618 |
open_timeout=self._connect_timeout,
|
| 619 |
max_size=self._max_message_size,
|
| 620 |
+
ping_interval=self._websocket_ping_interval_s,
|
| 621 |
+
ping_timeout=self._websocket_ping_timeout_s,
|
| 622 |
+
additional_headers=self._headers,
|
| 623 |
+
**connect_kwargs,
|
| 624 |
)
|
| 625 |
+
self._ws_loop = asyncio.get_running_loop()
|
| 626 |
except Exception as e:
|
| 627 |
+
await self.close()
|
| 628 |
raise ConnectionError(f"Failed to connect to {self._ws_url}: {e}") from e
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 629 |
|
| 630 |
return self
|
| 631 |
|
| 632 |
+
def disconnect(self) -> Any:
|
| 633 |
+
return self._dispatch(self._disconnect_async)
|
| 634 |
+
|
| 635 |
+
def _schedule_socket_close(
|
| 636 |
+
self, ws: ClientConnection, *, notify_server: bool = False
|
| 637 |
+
) -> asyncio.Task[None]:
|
| 638 |
+
"""Schedule and track a socket close until its handshake finishes."""
|
| 639 |
+
close_coro = (
|
| 640 |
+
_best_effort_graceful_close(ws) if notify_server else _best_effort_close(ws)
|
| 641 |
+
)
|
| 642 |
+
close_task = asyncio.create_task(close_coro)
|
| 643 |
+
self._pending_close_tasks.add(close_task)
|
| 644 |
+
close_task.add_done_callback(self._pending_close_tasks.discard)
|
| 645 |
+
return close_task
|
| 646 |
+
|
| 647 |
+
async def _disconnect_async(self) -> None:
|
| 648 |
"""Close the WebSocket connection."""
|
| 649 |
if self._ws is not None:
|
| 650 |
+
ws = self._ws
|
| 651 |
+
ws_loop = self._ws_loop
|
| 652 |
+
# Detach first so cancellation during the close handshake cannot
|
| 653 |
+
# leave a stale socket cached for a later operation.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 654 |
self._ws = None
|
| 655 |
+
self._ws_loop = None
|
| 656 |
+
same_loop = ws_loop is asyncio.get_running_loop()
|
| 657 |
+
if same_loop:
|
| 658 |
+
# Keep ownership of the handshake if this caller is cancelled.
|
| 659 |
+
# A later close/reconnect drains the task before tearing down
|
| 660 |
+
# the loop or opening a replacement server session.
|
| 661 |
+
close_task = self._schedule_socket_close(ws, notify_server=True)
|
| 662 |
+
await asyncio.shield(close_task)
|
| 663 |
+
|
| 664 |
+
async def _drain_pending_close_tasks(self) -> None:
|
| 665 |
+
"""Wait for background socket closes owned by the current event loop.
|
| 666 |
+
|
| 667 |
+
Shielding keeps cancellation of the caller from cancelling the close
|
| 668 |
+
tasks themselves. This matters both before reconnecting, when the old
|
| 669 |
+
server session must release its capacity slot, and during explicit
|
| 670 |
+
client shutdown.
|
| 671 |
+
"""
|
| 672 |
+
loop = asyncio.get_running_loop()
|
| 673 |
+
tasks = [
|
| 674 |
+
task
|
| 675 |
+
for task in tuple(self._pending_close_tasks)
|
| 676 |
+
if not task.done() and task.get_loop() is loop
|
| 677 |
+
]
|
| 678 |
+
if tasks:
|
| 679 |
+
await asyncio.shield(asyncio.gather(*tasks, return_exceptions=True))
|
| 680 |
|
| 681 |
async def _ensure_connected(self) -> None:
|
| 682 |
+
"""Ensure WebSocket connection is established on the current loop.
|
| 683 |
+
|
| 684 |
+
Always delegates to `_connect_async()` rather than pre-checking
|
| 685 |
+
`self._ws is None`: `_connect_async()` itself is the one that knows
|
| 686 |
+
whether an existing `_ws` is reusable (same event loop) or stale (a
|
| 687 |
+
different one, e.g. from a prior `from_env()` call now being driven
|
| 688 |
+
through `.sync()`'s own loop).
|
| 689 |
+
"""
|
| 690 |
+
await self._connect_async()
|
| 691 |
|
| 692 |
async def _send(self, message: Dict[str, Any]) -> None:
|
| 693 |
"""Send a message over the WebSocket."""
|
|
|
|
| 696 |
await self._ws.send(json.dumps(message))
|
| 697 |
|
| 698 |
async def _receive(self) -> Dict[str, Any]:
|
| 699 |
+
"""Receive the response on the same WebSocket that carried the request.
|
| 700 |
+
|
| 701 |
+
Reconnecting here would wait on a fresh socket where the request was
|
| 702 |
+
never sent. The next complete operation may reconnect in `_send()`.
|
| 703 |
+
"""
|
| 704 |
assert self._ws is not None
|
| 705 |
+
try:
|
| 706 |
+
raw = await asyncio.wait_for(self._ws.recv(), timeout=self._message_timeout)
|
| 707 |
+
except (asyncio.TimeoutError, asyncio.CancelledError):
|
| 708 |
+
# The server may still write the response for this request to the
|
| 709 |
+
# socket after we give up waiting on it. If we left the socket
|
| 710 |
+
# open, the next call's `_receive()` would read that stale frame
|
| 711 |
+
# and silently pair it with an unrelated request -- and every
|
| 712 |
+
# response after that would be shifted by one for the life of
|
| 713 |
+
# the connection. Drop the socket so the next `_send()` opens a
|
| 714 |
+
# fresh one instead of reusing a desynced one.
|
| 715 |
+
#
|
| 716 |
+
# An outer `asyncio.wait_for` around the whole call (e.g. a
|
| 717 |
+
# caller-imposed deadline on step()) cancels this await with
|
| 718 |
+
# CancelledError, not our own message_timeout's TimeoutError --
|
| 719 |
+
# same desync, different exception, so both are caught here.
|
| 720 |
+
ws = self._ws
|
| 721 |
+
self._ws = None
|
| 722 |
+
self._ws_loop = None
|
| 723 |
+
# Fire-and-forget: `close()` waits out the library's close
|
| 724 |
+
# handshake (`close_timeout`, 10s by default) if the server is
|
| 725 |
+
# slow to ack, and *awaiting* that here would hold up this
|
| 726 |
+
# exception -- so an outer `asyncio.wait_for(..., timeout=50ms)`
|
| 727 |
+
# would actually block for up to 10s before its deadline was
|
| 728 |
+
# honored. Scheduling it lets the exception propagate
|
| 729 |
+
# immediately while the close still happens in the background.
|
| 730 |
+
self._schedule_socket_close(ws)
|
| 731 |
+
raise
|
| 732 |
return json.loads(raw)
|
| 733 |
|
| 734 |
async def _send_and_receive(self, message: Dict[str, Any]) -> Dict[str, Any]:
|
| 735 |
"""Send a message and wait for response."""
|
| 736 |
+
try:
|
| 737 |
+
await self._send(message)
|
| 738 |
+
except ConnectionClosed:
|
| 739 |
+
# A refused session (e.g. at capacity) sends an error frame then closes. If
|
| 740 |
+
# the close beats our send, read the queued frame (recv() re-raises if none).
|
| 741 |
+
pass
|
| 742 |
response = await self._receive()
|
| 743 |
|
| 744 |
# Check for error response
|
|
|
|
| 752 |
return response
|
| 753 |
|
| 754 |
@classmethod
|
| 755 |
+
def _bootstrap_container(
|
| 756 |
cls: Type[EnvClientT],
|
| 757 |
image: str,
|
| 758 |
provider: Optional["ContainerProvider"] = None,
|
| 759 |
**kwargs: Any,
|
| 760 |
) -> EnvClientT:
|
| 761 |
+
"""Start a Docker container and build an *unconnected* client for it.
|
| 762 |
+
|
| 763 |
+
Invoked lazily by the `from_docker_image` bootstrap handle when it is
|
| 764 |
+
awaited or `.sync()`'d: the container start / readiness wait is plain
|
| 765 |
+
blocking code, so the only thing that differs between the async and sync
|
| 766 |
+
resolution paths is how the WebSocket is connected (awaited vs. run sync).
|
| 767 |
+
"""
|
| 768 |
+
if provider is None:
|
| 769 |
+
provider = LocalDockerProvider()
|
| 770 |
+
|
| 771 |
+
try:
|
| 772 |
+
# Start container, wait for readiness, then build the client.
|
| 773 |
+
base_url = provider.start_container(image, **kwargs)
|
| 774 |
+
provider.wait_for_ready(base_url)
|
| 775 |
+
return cls(base_url=base_url, provider=provider)
|
| 776 |
+
except Exception:
|
| 777 |
+
# No EnvClient exists yet for the caller to close(), so release the
|
| 778 |
+
# container here if start / readiness / construction fails.
|
| 779 |
+
provider.stop_container()
|
| 780 |
+
raise
|
| 781 |
+
|
| 782 |
+
@classmethod
|
| 783 |
+
def from_docker_image(
|
| 784 |
+
cls: Type[EnvClientT],
|
| 785 |
+
image: str,
|
| 786 |
+
provider: Optional["ContainerProvider"] = None,
|
| 787 |
+
**kwargs: Any,
|
| 788 |
+
) -> "_BootstrapResult[EnvClientT]":
|
| 789 |
"""
|
| 790 |
Create an environment client by spinning up a Docker container.
|
| 791 |
|
| 792 |
+
Returns a bootstrap handle so the same call works from both async and
|
| 793 |
+
synchronous code: `await` it for the connected async client, or chain
|
| 794 |
+
`.sync()` for a connected `SyncEnvClient` (e.g. a TRL GRPO rollout loop
|
| 795 |
+
that cannot `await`). Bootstrap is lazy — the container starts when the
|
| 796 |
+
handle is resolved.
|
| 797 |
+
|
| 798 |
Args:
|
| 799 |
+
image (`str`):
|
| 800 |
+
Docker image name to run (e.g., `"coding-env:latest"`).
|
| 801 |
+
provider (`ContainerProvider`, *optional*):
|
| 802 |
+
Container provider to use. Defaults to `LocalDockerProvider`.
|
| 803 |
+
**kwargs:
|
| 804 |
+
Additional arguments to pass to `provider.start_container()`.
|
| 805 |
|
| 806 |
Returns:
|
| 807 |
+
`_BootstrapResult`: `await` for a connected async client, or call
|
| 808 |
+
`.sync()` for a connected `SyncEnvClient`.
|
|
|
|
|
|
|
| 809 |
|
| 810 |
+
Examples:
|
|
|
|
| 811 |
|
| 812 |
+
```python
|
| 813 |
+
# Async
|
| 814 |
+
env = await MyEnv.from_docker_image("coding-env:latest")
|
| 815 |
|
| 816 |
+
# Sync
|
| 817 |
+
env = MyEnv.from_docker_image("coding-env:latest").sync()
|
| 818 |
+
result = env.reset()
|
| 819 |
+
```
|
| 820 |
+
"""
|
| 821 |
+
return _BootstrapResult(
|
| 822 |
+
lambda: cls._bootstrap_container(image, provider, **kwargs)
|
| 823 |
+
)
|
| 824 |
|
| 825 |
@classmethod
|
| 826 |
+
def _bootstrap_env(
|
| 827 |
cls: Type[EnvClientT],
|
| 828 |
repo_id: str,
|
| 829 |
*,
|
|
|
|
| 831 |
provider: Optional["ContainerProvider | RuntimeProvider"] = None,
|
| 832 |
**provider_kwargs: Any,
|
| 833 |
) -> EnvClientT:
|
| 834 |
+
"""Start a HF Space (docker or uv) and build an *unconnected* client.
|
|
|
|
| 835 |
|
| 836 |
+
Invoked lazily by the `from_env` bootstrap handle; see
|
| 837 |
+
`_bootstrap_container` for the same split rationale. On a startup
|
| 838 |
+
failure the spawned process (and, for a git+ `project_path`, the temp
|
| 839 |
+
clone directory) is released before re-raising; a later *connection*
|
| 840 |
+
failure is cleaned up by `_connect_async` via `close()`.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 841 |
"""
|
| 842 |
# Extract start args that apply to both providers
|
| 843 |
start_args = {}
|
|
|
|
| 849 |
# Docker mode: pull from HF registry
|
| 850 |
docker_provider = provider or LocalDockerProvider()
|
| 851 |
tag = provider_kwargs.pop("tag", "latest")
|
| 852 |
+
image = f"registry.hf.space/{hf_space_subdomain(repo_id)}:{tag}"
|
| 853 |
+
try:
|
| 854 |
+
base_url = docker_provider.start_container(
|
| 855 |
+
image, **start_args, **provider_kwargs
|
| 856 |
+
)
|
| 857 |
+
docker_provider.wait_for_ready(base_url)
|
| 858 |
+
return cls(base_url=base_url, provider=docker_provider)
|
| 859 |
+
except Exception:
|
| 860 |
+
# No EnvClient exists yet for the caller to close(), so release
|
| 861 |
+
# the container here if start / readiness / construction fails.
|
| 862 |
+
docker_provider.stop_container()
|
| 863 |
+
raise
|
| 864 |
else:
|
| 865 |
# UV mode: clone and run with uv
|
| 866 |
if provider is None:
|
|
|
|
| 876 |
"provider_kwargs cannot be used when supplying a provider instance"
|
| 877 |
)
|
| 878 |
|
| 879 |
+
try:
|
| 880 |
+
context_timeout_s = getattr(provider, "context_timeout_s", None)
|
| 881 |
+
deadline = (
|
| 882 |
+
time.monotonic() + context_timeout_s
|
| 883 |
+
if context_timeout_s is not None
|
| 884 |
+
else None
|
| 885 |
+
)
|
| 886 |
+
base_url = provider.start(**start_args)
|
| 887 |
+
if deadline is None:
|
| 888 |
+
provider.wait_for_ready()
|
| 889 |
+
else:
|
| 890 |
+
provider.wait_for_ready(
|
| 891 |
+
timeout_s=max(0.0, deadline - time.monotonic())
|
| 892 |
+
)
|
| 893 |
+
return cls(base_url=base_url, provider=provider)
|
| 894 |
+
except Exception:
|
| 895 |
+
# No EnvClient exists yet for the caller to close(), so this is
|
| 896 |
+
# the only chance to release the spawned process and (for a
|
| 897 |
+
# git+ project_path) the temp clone directory. Covers start,
|
| 898 |
+
# readiness, and client construction (e.g. an invalid mode).
|
| 899 |
+
provider.stop()
|
| 900 |
+
raise
|
| 901 |
|
| 902 |
+
@classmethod
|
| 903 |
+
def from_env(
|
| 904 |
+
cls: Type[EnvClientT],
|
| 905 |
+
repo_id: str,
|
| 906 |
+
*,
|
| 907 |
+
use_docker: bool = True,
|
| 908 |
+
provider: Optional["ContainerProvider | RuntimeProvider"] = None,
|
| 909 |
+
**provider_kwargs: Any,
|
| 910 |
+
) -> "_BootstrapResult[EnvClientT]":
|
| 911 |
+
"""
|
| 912 |
+
Create a client from a Hugging Face Space.
|
| 913 |
+
|
| 914 |
+
Returns a bootstrap handle: `await` it for the connected async client, or
|
| 915 |
+
chain `.sync()` for a connected `SyncEnvClient`. Bootstrap is lazy — the
|
| 916 |
+
Space starts when the handle is resolved.
|
| 917 |
+
|
| 918 |
+
Args:
|
| 919 |
+
repo_id (`str`):
|
| 920 |
+
Hugging Face space identifier `{org}/{space}`.
|
| 921 |
+
use_docker (`bool`, *optional*, defaults to `True`):
|
| 922 |
+
When `True`, pull from the HF registry and launch via `LocalDockerProvider`.
|
| 923 |
+
When `False`, run the space locally with `UVProvider`.
|
| 924 |
+
provider (`ContainerProvider` or `RuntimeProvider`, *optional*):
|
| 925 |
+
Provider instance to reuse. Must be a `ContainerProvider` when
|
| 926 |
+
`use_docker=True` and a `RuntimeProvider` otherwise.
|
| 927 |
+
**provider_kwargs:
|
| 928 |
+
Additional keyword arguments forwarded to either the container provider's
|
| 929 |
+
`start_container` (docker) or to the `UVProvider` constructor/start (uv).
|
| 930 |
+
When `use_docker=False`, the `project_path` argument can be used to override
|
| 931 |
+
the default git URL (`git+https://huggingface.co/spaces/{repo_id}`).
|
| 932 |
+
|
| 933 |
+
Returns:
|
| 934 |
+
`_BootstrapResult`: `await` for a connected async client, or call
|
| 935 |
+
`.sync()` for a connected `SyncEnvClient`.
|
| 936 |
+
|
| 937 |
+
Examples:
|
| 938 |
+
|
| 939 |
+
```python
|
| 940 |
+
# Async: pull and run from HF Docker registry
|
| 941 |
+
env = await MyEnv.from_env("openenv/echo-env")
|
| 942 |
+
|
| 943 |
+
# Sync: chain .sync()
|
| 944 |
+
env = MyEnv.from_env("openenv/echo-env").sync()
|
| 945 |
+
|
| 946 |
+
# Run locally with UV (clones the space)
|
| 947 |
+
env = await MyEnv.from_env("openenv/echo-env", use_docker=False)
|
| 948 |
+
|
| 949 |
+
# Run from a local checkout
|
| 950 |
+
env = await MyEnv.from_env(
|
| 951 |
+
"openenv/echo-env",
|
| 952 |
+
use_docker=False,
|
| 953 |
+
project_path="/path/to/local/checkout"
|
| 954 |
+
)
|
| 955 |
+
```
|
| 956 |
+
"""
|
| 957 |
+
return _BootstrapResult(
|
| 958 |
+
lambda: cls._bootstrap_env(
|
| 959 |
+
repo_id,
|
| 960 |
+
use_docker=use_docker,
|
| 961 |
+
provider=provider,
|
| 962 |
+
**provider_kwargs,
|
| 963 |
+
)
|
| 964 |
+
)
|
| 965 |
|
| 966 |
@abstractmethod
|
| 967 |
def _step_payload(self, action: ActT) -> Dict[str, Any]:
|
|
|
|
| 978 |
"""Convert a JSON response from the state endpoint to a State object."""
|
| 979 |
raise NotImplementedError
|
| 980 |
|
| 981 |
+
def reset(self, **kwargs: Any) -> Any:
|
| 982 |
+
return self._dispatch(lambda: self._reset_async(**kwargs))
|
| 983 |
+
|
| 984 |
+
async def _reset_async(self, **kwargs: Any) -> StepResult[ObsT]:
|
| 985 |
"""
|
| 986 |
Reset the environment with optional parameters.
|
| 987 |
|
| 988 |
Args:
|
| 989 |
+
**kwargs:
|
| 990 |
+
Optional parameters passed to the environment's reset method.
|
|
|
|
|
|
|
| 991 |
|
| 992 |
Returns:
|
| 993 |
StepResult containing initial observation
|
|
|
|
| 999 |
response = await self._send_and_receive(message)
|
| 1000 |
return self._parse_result(response.get("data", {}))
|
| 1001 |
|
| 1002 |
+
def step(self, action: ActT, **kwargs: Any) -> Any:
|
| 1003 |
+
return self._dispatch(lambda: self._step_async(action, **kwargs))
|
| 1004 |
+
|
| 1005 |
+
async def _step_async(self, action: ActT, **kwargs: Any) -> StepResult[ObsT]:
|
| 1006 |
"""
|
| 1007 |
Execute an action in the environment.
|
| 1008 |
|
| 1009 |
Args:
|
| 1010 |
+
action:
|
| 1011 |
+
The action to execute.
|
| 1012 |
+
**kwargs:
|
| 1013 |
+
Optional parameters (currently ignored).
|
| 1014 |
|
| 1015 |
Returns:
|
| 1016 |
StepResult containing observation, reward, and done status
|
|
|
|
| 1022 |
response = await self._send_and_receive(message)
|
| 1023 |
return self._parse_result(response.get("data", {}))
|
| 1024 |
|
| 1025 |
+
def state(self) -> Any:
|
| 1026 |
+
return self._dispatch(self._state_async)
|
| 1027 |
+
|
| 1028 |
+
async def _state_async(self) -> StateT:
|
| 1029 |
"""
|
| 1030 |
Get the current environment state from the server.
|
| 1031 |
|
|
|
|
| 1036 |
response = await self._send_and_receive(message)
|
| 1037 |
return self._parse_state(response.get("data", {}))
|
| 1038 |
|
| 1039 |
+
def close(self) -> Any:
|
| 1040 |
+
return self._dispatch(self._close_async)
|
| 1041 |
+
|
| 1042 |
+
async def _close_child_clients(self) -> asyncio.CancelledError | None:
|
| 1043 |
+
"""Close every captured child while deferring caller cancellation."""
|
| 1044 |
+
children = list(self._child_clients)
|
| 1045 |
+
self._child_clients.clear()
|
| 1046 |
+
close_tasks = []
|
| 1047 |
+
deferred_cancellation: asyncio.CancelledError | None = None
|
| 1048 |
+
|
| 1049 |
+
for child in children:
|
| 1050 |
+
try:
|
| 1051 |
+
close_tasks.append(asyncio.ensure_future(child.close()))
|
| 1052 |
+
except asyncio.CancelledError as exc:
|
| 1053 |
+
if deferred_cancellation is None:
|
| 1054 |
+
deferred_cancellation = exc
|
| 1055 |
+
except Exception:
|
| 1056 |
+
pass # Best effort
|
| 1057 |
+
|
| 1058 |
+
if close_tasks:
|
| 1059 |
+
close_group = asyncio.gather(*close_tasks, return_exceptions=True)
|
| 1060 |
+
while not close_group.done():
|
| 1061 |
+
try:
|
| 1062 |
+
await asyncio.shield(close_group)
|
| 1063 |
+
except asyncio.CancelledError as exc:
|
| 1064 |
+
if deferred_cancellation is None:
|
| 1065 |
+
deferred_cancellation = exc
|
| 1066 |
+
|
| 1067 |
+
for result in close_group.result():
|
| 1068 |
+
if (
|
| 1069 |
+
isinstance(result, asyncio.CancelledError)
|
| 1070 |
+
and deferred_cancellation is None
|
| 1071 |
+
):
|
| 1072 |
+
deferred_cancellation = result
|
| 1073 |
+
|
| 1074 |
+
return deferred_cancellation
|
| 1075 |
+
|
| 1076 |
+
async def _close_async(self) -> None:
|
| 1077 |
"""
|
| 1078 |
Close the WebSocket connection and clean up resources.
|
| 1079 |
|
| 1080 |
If this client was created via from_docker_image() or from_env(),
|
| 1081 |
this will also stop and remove the associated container/process.
|
| 1082 |
"""
|
| 1083 |
+
deferred_cancellation: asyncio.CancelledError | None = None
|
| 1084 |
+
try:
|
| 1085 |
+
try:
|
| 1086 |
+
try:
|
| 1087 |
+
child_cancellation = await self._close_child_clients()
|
| 1088 |
+
except asyncio.CancelledError as exc:
|
| 1089 |
+
child_cancellation = exc
|
| 1090 |
+
if child_cancellation is not None:
|
| 1091 |
+
deferred_cancellation = child_cancellation
|
| 1092 |
+
|
| 1093 |
+
# A real close waits out backgrounded closes, while shielding
|
| 1094 |
+
# their socket handshakes from cancellation.
|
| 1095 |
+
try:
|
| 1096 |
+
await self._drain_pending_close_tasks()
|
| 1097 |
+
except asyncio.CancelledError as exc:
|
| 1098 |
+
if deferred_cancellation is None:
|
| 1099 |
+
deferred_cancellation = exc
|
| 1100 |
+
finally:
|
| 1101 |
+
# Run even when child or pending-close cleanup is cancelled. A
|
| 1102 |
+
# client may already have reconnected, and that current socket
|
| 1103 |
+
# must not remain cached or open during teardown.
|
| 1104 |
+
try:
|
| 1105 |
+
await self._disconnect_async()
|
| 1106 |
+
except asyncio.CancelledError as exc:
|
| 1107 |
+
if deferred_cancellation is None:
|
| 1108 |
+
deferred_cancellation = exc
|
| 1109 |
+
finally:
|
| 1110 |
+
self._stop_provider()
|
| 1111 |
+
|
| 1112 |
+
if deferred_cancellation is not None:
|
| 1113 |
+
raise deferred_cancellation
|
| 1114 |
|
| 1115 |
+
def _stop_provider(self) -> None:
|
| 1116 |
+
"""Stop the provider once, retaining it for retries and later startup."""
|
| 1117 |
+
try:
|
| 1118 |
+
provider = self._provider
|
| 1119 |
+
if provider is None or self._provider_stopped:
|
| 1120 |
+
return
|
| 1121 |
+
|
| 1122 |
+
if hasattr(provider, "stop_container"):
|
| 1123 |
+
stop = provider.stop_container
|
| 1124 |
+
elif hasattr(provider, "stop"):
|
| 1125 |
+
stop = provider.stop
|
| 1126 |
+
else:
|
| 1127 |
+
return
|
| 1128 |
+
|
| 1129 |
+
self._provider_cleanup_pending = True
|
| 1130 |
+
stop()
|
| 1131 |
+
# Only a successful stop discharges our cleanup responsibility.
|
| 1132 |
+
self._provider_cleanup_pending = False
|
| 1133 |
+
self._provider_stopped = True
|
| 1134 |
+
finally:
|
| 1135 |
+
if self._start_provider_on_connect:
|
| 1136 |
+
self._base_url = None
|
| 1137 |
+
self._ws_url = None
|
| 1138 |
+
|
| 1139 |
+
def _stop_provider_best_effort(self) -> None:
|
| 1140 |
+
"""Stop the underlying provider directly, ignoring any errors.
|
| 1141 |
+
|
| 1142 |
+
Releases a started container/process when there is no connected client
|
| 1143 |
+
to `close()` through the normal path — e.g. sync bootstrap setup fails
|
| 1144 |
+
after the provider started but before the connection is established, so
|
| 1145 |
+
routing cleanup through the (possibly broken) sync loop is not an option.
|
| 1146 |
+
"""
|
| 1147 |
+
with suppress(Exception):
|
| 1148 |
+
self._stop_provider()
|
| 1149 |
|
| 1150 |
async def __aenter__(self) -> "EnvClient":
|
| 1151 |
"""Enter async context manager, ensuring connection is established."""
|
| 1152 |
+
self._claim_execution_mode("async")
|
| 1153 |
await self.connect()
|
| 1154 |
return self
|
| 1155 |
|
|
|
|
| 1158 |
await self.close()
|
| 1159 |
|
| 1160 |
def __enter__(self) -> "EnvClient":
|
| 1161 |
+
"""Enter sync context manager, ensuring connection is established."""
|
| 1162 |
+
self._run_sync(self._connect_async)
|
| 1163 |
+
return self
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1164 |
|
| 1165 |
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
|
| 1166 |
+
"""Exit sync context manager, closing connection."""
|
| 1167 |
+
self._run_sync(self._close_async)
|
| 1168 |
|
| 1169 |
def sync(self) -> "SyncEnvClient":
|
| 1170 |
"""
|
|
|
|
| 1179 |
Returns:
|
| 1180 |
SyncEnvClient wrapper that provides synchronous methods
|
| 1181 |
|
| 1182 |
+
Examples:
|
| 1183 |
+
|
| 1184 |
+
```python
|
| 1185 |
+
async_client = GenericEnvClient(base_url="http://localhost:8000")
|
| 1186 |
+
sync_client = async_client.sync()
|
| 1187 |
+
|
| 1188 |
+
with sync_client:
|
| 1189 |
+
result = sync_client.reset()
|
| 1190 |
+
result = sync_client.step({"code": "print('hello')"})
|
| 1191 |
+
```
|
| 1192 |
"""
|
| 1193 |
from .sync_client import SyncEnvClient
|
| 1194 |
|
| 1195 |
+
if self._sync_client is None:
|
| 1196 |
+
self._sync_client = SyncEnvClient(self)
|
| 1197 |
+
return self._sync_client
|
src/core/env_server/__init__.py
CHANGED
|
@@ -1,8 +1,4 @@
|
|
| 1 |
-
#
|
| 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 |
|
|
@@ -16,7 +12,7 @@ from .exceptions import (
|
|
| 16 |
SessionNotFoundError,
|
| 17 |
)
|
| 18 |
from .http_server import create_app, create_fastapi_app, HTTPEnvServer
|
| 19 |
-
from .interfaces import Environment, Message, ModelTokenizer, Transform
|
| 20 |
|
| 21 |
try:
|
| 22 |
from .mcp_environment import MCPEnvironment
|
|
@@ -51,8 +47,12 @@ from .types import (
|
|
| 51 |
Action,
|
| 52 |
BaseMessage,
|
| 53 |
ConcurrencyConfig,
|
|
|
|
|
|
|
| 54 |
HealthResponse,
|
| 55 |
HealthStatus,
|
|
|
|
|
|
|
| 56 |
Observation,
|
| 57 |
SchemaResponse,
|
| 58 |
ServerCapacityStatus,
|
|
@@ -80,6 +80,7 @@ __all__ = [
|
|
| 80 |
# Core interfaces
|
| 81 |
"Environment",
|
| 82 |
"Transform",
|
|
|
|
| 83 |
"Message",
|
| 84 |
"ModelTokenizer",
|
| 85 |
# Types
|
|
@@ -88,6 +89,10 @@ __all__ = [
|
|
| 88 |
"State",
|
| 89 |
"SchemaResponse",
|
| 90 |
"HealthResponse",
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
# Enums
|
| 92 |
"HealthStatus",
|
| 93 |
"ServerMode",
|
|
|
|
| 1 |
+
# SPDX-License-Identifier: BSD-3-Clause
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
"""Core environment interfaces and types."""
|
| 4 |
|
|
|
|
| 12 |
SessionNotFoundError,
|
| 13 |
)
|
| 14 |
from .http_server import create_app, create_fastapi_app, HTTPEnvServer
|
| 15 |
+
from .interfaces import Environment, Message, ModelTokenizer, TaskProvider, Transform
|
| 16 |
|
| 17 |
try:
|
| 18 |
from .mcp_environment import MCPEnvironment
|
|
|
|
| 47 |
Action,
|
| 48 |
BaseMessage,
|
| 49 |
ConcurrencyConfig,
|
| 50 |
+
GetTaskRangeRequest,
|
| 51 |
+
GetTaskRequest,
|
| 52 |
HealthResponse,
|
| 53 |
HealthStatus,
|
| 54 |
+
ListTasksRequest,
|
| 55 |
+
NumTasksRequest,
|
| 56 |
Observation,
|
| 57 |
SchemaResponse,
|
| 58 |
ServerCapacityStatus,
|
|
|
|
| 80 |
# Core interfaces
|
| 81 |
"Environment",
|
| 82 |
"Transform",
|
| 83 |
+
"TaskProvider",
|
| 84 |
"Message",
|
| 85 |
"ModelTokenizer",
|
| 86 |
# Types
|
|
|
|
| 89 |
"State",
|
| 90 |
"SchemaResponse",
|
| 91 |
"HealthResponse",
|
| 92 |
+
"ListTasksRequest",
|
| 93 |
+
"NumTasksRequest",
|
| 94 |
+
"GetTaskRequest",
|
| 95 |
+
"GetTaskRangeRequest",
|
| 96 |
# Enums
|
| 97 |
"HealthStatus",
|
| 98 |
"ServerMode",
|
src/core/env_server/_utils.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Shared env server implementation helpers."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from typing import Any
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def overrides_method(method: Any, base_method: Any) -> bool:
|
| 9 |
+
"""Return whether a bound method differs from the base implementation."""
|
| 10 |
+
return getattr(method, "__func__", method) is not base_method
|
src/core/env_server/base_transforms.py
CHANGED
|
@@ -1,8 +1,4 @@
|
|
| 1 |
-
#
|
| 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 |
|
|
|
|
| 1 |
+
# SPDX-License-Identifier: BSD-3-Clause
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
"""Base transform implementations for composing environment-specific transforms."""
|
| 4 |
|
src/core/env_server/exceptions.py
CHANGED
|
@@ -1,8 +1,4 @@
|
|
| 1 |
-
#
|
| 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 |
"""Custom exceptions for environment server operations."""
|
| 8 |
|
|
|
|
| 1 |
+
# SPDX-License-Identifier: BSD-3-Clause
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
"""Custom exceptions for environment server operations."""
|
| 4 |
|
src/core/env_server/gradio_theme.py
CHANGED
|
@@ -1,42 +1,11 @@
|
|
| 1 |
-
#
|
| 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 |
|
| 9 |
from __future__ import annotations
|
| 10 |
|
| 11 |
import gradio as gr
|
| 12 |
|
| 13 |
-
_MONO_FONTS = (
|
| 14 |
-
"JetBrains Mono",
|
| 15 |
-
"Fira Code",
|
| 16 |
-
"Cascadia Code",
|
| 17 |
-
"Consolas",
|
| 18 |
-
"ui-monospace",
|
| 19 |
-
"monospace",
|
| 20 |
-
)
|
| 21 |
-
|
| 22 |
-
_CORE_FONT = (
|
| 23 |
-
"Lato",
|
| 24 |
-
"Inter",
|
| 25 |
-
"Arial",
|
| 26 |
-
"Helvetica",
|
| 27 |
-
"sans-serif",
|
| 28 |
-
)
|
| 29 |
-
|
| 30 |
-
_ZERO_RADIUS = gr.themes.Size(
|
| 31 |
-
xxs="0px",
|
| 32 |
-
xs="0px",
|
| 33 |
-
sm="0px",
|
| 34 |
-
md="0px",
|
| 35 |
-
lg="0px",
|
| 36 |
-
xl="0px",
|
| 37 |
-
xxl="0px",
|
| 38 |
-
)
|
| 39 |
-
|
| 40 |
_GREEN_HUE = gr.themes.Color(
|
| 41 |
c50="#e6f4ea",
|
| 42 |
c100="#ceead6",
|
|
@@ -52,77 +21,122 @@ _GREEN_HUE = gr.themes.Color(
|
|
| 52 |
)
|
| 53 |
|
| 54 |
_NEUTRAL_HUE = gr.themes.Color(
|
| 55 |
-
c50="#
|
| 56 |
-
c100="#
|
| 57 |
-
c200="#
|
| 58 |
-
c300="#
|
| 59 |
-
c400="#
|
| 60 |
-
c500="#
|
| 61 |
-
c600="#
|
| 62 |
-
c700="#
|
| 63 |
-
c800="#
|
| 64 |
-
c900="#
|
| 65 |
-
c950="#
|
| 66 |
)
|
| 67 |
|
| 68 |
OPENENV_GRADIO_THEME = gr.themes.Base(
|
| 69 |
primary_hue=_GREEN_HUE,
|
| 70 |
secondary_hue=_NEUTRAL_HUE,
|
| 71 |
neutral_hue=_NEUTRAL_HUE,
|
| 72 |
-
font=
|
| 73 |
-
font_mono=
|
| 74 |
-
radius_size=
|
| 75 |
).set(
|
| 76 |
body_background_fill="#ffffff",
|
|
|
|
| 77 |
background_fill_primary="#ffffff",
|
| 78 |
-
background_fill_secondary="#
|
| 79 |
block_background_fill="#ffffff",
|
| 80 |
-
|
| 81 |
-
block_label_text_color="#
|
| 82 |
-
|
| 83 |
-
border_color_primary="#
|
| 84 |
input_background_fill="#ffffff",
|
| 85 |
-
input_border_color="#
|
| 86 |
-
|
| 87 |
-
|
|
|
|
| 88 |
button_primary_text_color="#ffffff",
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 110 |
)
|
| 111 |
|
| 112 |
OPENENV_GRADIO_CSS = """
|
| 113 |
-
|
| 114 |
-
.
|
| 115 |
-
.
|
| 116 |
-
.
|
| 117 |
-
.
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
}
|
| 122 |
-
.
|
| 123 |
-
|
| 124 |
-
}
|
| 125 |
-
.
|
| 126 |
-
|
| 127 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
"""
|
|
|
|
| 1 |
+
# SPDX-License-Identifier: BSD-3-Clause
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
+
"""Theme for the OpenEnv Gradio UI: white, hairline borders, one green accent."""
|
| 4 |
|
| 5 |
from __future__ import annotations
|
| 6 |
|
| 7 |
import gradio as gr
|
| 8 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
_GREEN_HUE = gr.themes.Color(
|
| 10 |
c50="#e6f4ea",
|
| 11 |
c100="#ceead6",
|
|
|
|
| 21 |
)
|
| 22 |
|
| 23 |
_NEUTRAL_HUE = gr.themes.Color(
|
| 24 |
+
c50="#fafafa",
|
| 25 |
+
c100="#f4f4f5",
|
| 26 |
+
c200="#e4e4e7",
|
| 27 |
+
c300="#d4d4d8",
|
| 28 |
+
c400="#a1a1aa",
|
| 29 |
+
c500="#71717a",
|
| 30 |
+
c600="#52525b",
|
| 31 |
+
c700="#3f3f46",
|
| 32 |
+
c800="#27272a",
|
| 33 |
+
c900="#18181b",
|
| 34 |
+
c950="#0a0a0a",
|
| 35 |
)
|
| 36 |
|
| 37 |
OPENENV_GRADIO_THEME = gr.themes.Base(
|
| 38 |
primary_hue=_GREEN_HUE,
|
| 39 |
secondary_hue=_NEUTRAL_HUE,
|
| 40 |
neutral_hue=_NEUTRAL_HUE,
|
| 41 |
+
font=(gr.themes.GoogleFont("Geist"), "system-ui", "sans-serif"),
|
| 42 |
+
font_mono=(gr.themes.GoogleFont("Geist Mono"), "ui-monospace", "monospace"),
|
| 43 |
+
radius_size=gr.themes.sizes.radius_md,
|
| 44 |
).set(
|
| 45 |
body_background_fill="#ffffff",
|
| 46 |
+
body_text_color="#0a0a0a",
|
| 47 |
background_fill_primary="#ffffff",
|
| 48 |
+
background_fill_secondary="#fafafa",
|
| 49 |
block_background_fill="#ffffff",
|
| 50 |
+
block_border_width="0px",
|
| 51 |
+
block_label_text_color="#52525b",
|
| 52 |
+
block_label_background_fill="transparent",
|
| 53 |
+
border_color_primary="#ecedef",
|
| 54 |
input_background_fill="#ffffff",
|
| 55 |
+
input_border_color="#e4e4e7",
|
| 56 |
+
input_border_color_focus="#1a7f37",
|
| 57 |
+
button_primary_background_fill="#0a0a0a",
|
| 58 |
+
button_primary_background_fill_hover="#27272a",
|
| 59 |
button_primary_text_color="#ffffff",
|
| 60 |
+
button_primary_border_color="#0a0a0a",
|
| 61 |
+
button_secondary_background_fill="#ffffff",
|
| 62 |
+
button_secondary_background_fill_hover="#fafafa",
|
| 63 |
+
button_secondary_text_color="#0a0a0a",
|
| 64 |
+
button_secondary_border_color="#e4e4e7",
|
| 65 |
+
button_border_width="1px",
|
| 66 |
+
checkbox_background_color_selected="#1a7f37",
|
| 67 |
+
checkbox_border_color_selected="#1a7f37",
|
| 68 |
+
color_accent="#1a7f37",
|
| 69 |
+
color_accent_soft="#f0f7f2",
|
| 70 |
+
color_accent_soft_dark="#0d2416",
|
| 71 |
+
body_text_color_subdued="#52525b",
|
| 72 |
+
body_text_color_subdued_dark="#a1a1aa",
|
| 73 |
+
body_background_fill_dark="#0a0a0a",
|
| 74 |
+
body_text_color_dark="#fafafa",
|
| 75 |
+
background_fill_primary_dark="#0a0a0a",
|
| 76 |
+
background_fill_secondary_dark="#141414",
|
| 77 |
+
block_background_fill_dark="#0a0a0a",
|
| 78 |
+
block_label_text_color_dark="#a1a1aa",
|
| 79 |
+
border_color_primary_dark="#27272a",
|
| 80 |
+
input_background_fill_dark="#0a0a0a",
|
| 81 |
+
input_border_color_dark="#3f3f46",
|
| 82 |
+
button_primary_background_fill_dark="#fafafa",
|
| 83 |
+
button_primary_background_fill_hover_dark="#e4e4e7",
|
| 84 |
+
button_primary_text_color_dark="#0a0a0a",
|
| 85 |
+
button_primary_border_color_dark="#fafafa",
|
| 86 |
+
button_secondary_background_fill_dark="#0a0a0a",
|
| 87 |
+
button_secondary_background_fill_hover_dark="#18181b",
|
| 88 |
+
button_secondary_text_color_dark="#fafafa",
|
| 89 |
+
button_secondary_border_color_dark="#3f3f46",
|
| 90 |
)
|
| 91 |
|
| 92 |
OPENENV_GRADIO_CSS = """
|
| 93 |
+
.gradio-container { max-width: 1120px !important; margin: 0 auto !important; }
|
| 94 |
+
.oe-header { padding: 24px 0 12px; }
|
| 95 |
+
.oe-header h1 { margin: 6px 0 4px; font-size: 40px; line-height: 1.1; font-weight: 700; letter-spacing: -.03em; }
|
| 96 |
+
.oe-header p, .oe-step p, .oe-episode small, .oe-muted { color: var(--body-text-color-subdued); }
|
| 97 |
+
.oe-header p { margin: 0; font-size: 16px; max-width: 640px; }
|
| 98 |
+
.oe-eyebrow { font-family: var(--font-mono); font-size: 13px; font-weight: 500; color: var(--color-accent); }
|
| 99 |
+
.oe-card { border: 1px solid var(--border-color-primary) !important; border-radius: 12px !important; padding: 18px 20px !important; background: var(--background-fill-primary) !important; gap: 12px !important; }
|
| 100 |
+
.oe-row { align-items: center !important; }
|
| 101 |
+
.oe-step h2, .oe-episode h2 { margin: 0; font-size: 17px; font-weight: 600; }
|
| 102 |
+
.oe-step p { margin: 2px 0 0; font-size: 14px; }
|
| 103 |
+
.oe-step code, .oe-episode code, .oe-result code { font-family: var(--font-mono); background: none; padding: 0; }
|
| 104 |
+
.oe-run { flex: none !important; align-self: flex-start !important; width: auto !important; min-width: 140px !important; padding: 0 22px !important; margin-left: var(--block-padding, 10px) !important; }
|
| 105 |
+
.oe-card textarea, .oe-card input[type=number], .oe-card input[type=password], .oe-card input[type=text] { border: 1px solid var(--input-border-color) !important; border-radius: 8px !important; padding: 10px 12px !important; font-family: var(--font-mono) !important; }
|
| 106 |
+
.oe-tools .wrap, .oe-actions .wrap { gap: 8px; }
|
| 107 |
+
.oe-tools .wrap { display: grid !important; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); }
|
| 108 |
+
.oe-actions .wrap { display: flex !important; flex-wrap: wrap; }
|
| 109 |
+
.oe-tools .wrap label, .oe-actions .wrap label { border: 1px solid var(--input-border-color) !important; border-radius: 10px !important; background: var(--background-fill-primary) !important; font-family: var(--font-mono); cursor: pointer; }
|
| 110 |
+
.oe-tools .wrap label { padding: 12px 14px !important; font-size: 13px; }
|
| 111 |
+
.oe-actions .wrap label { padding: 10px 16px !important; font-size: 14px; }
|
| 112 |
+
.oe-tools .wrap label.selected { border: 1.5px solid var(--color-accent) !important; background: var(--color-accent-soft) !important; }
|
| 113 |
+
.oe-actions input[type=radio] { display: none; }
|
| 114 |
+
.oe-result { border: 1px solid var(--border-color-primary); border-radius: 10px; overflow: hidden; }
|
| 115 |
+
.oe-output, .oe-fields { background: var(--background-fill-secondary); border-bottom: 1px solid var(--border-color-primary); }
|
| 116 |
+
.oe-output { margin: 0; padding: 14px 16px; font-family: var(--font-mono); font-size: 15px; white-space: pre-wrap; word-break: break-word; }
|
| 117 |
+
.oe-fields { padding: 10px 16px; }
|
| 118 |
+
.oe-fields div { display: grid; grid-template-columns: minmax(96px, 28%) minmax(0, 1fr); gap: 12px; padding: 3px 0; font-size: 13px; }
|
| 119 |
+
.oe-fields code { white-space: pre-wrap; word-break: break-word; }
|
| 120 |
+
.oe-fields span, .oe-stats span, .oe-episode li > span { font-family: var(--font-mono); color: var(--body-text-color-subdued); }
|
| 121 |
+
.oe-fields span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
| 122 |
+
.oe-stats { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
| 123 |
+
.oe-stats div { padding: 8px 16px; border-right: 1px solid var(--border-color-primary); }
|
| 124 |
+
.oe-stats div:last-child { border-right: none; }
|
| 125 |
+
.oe-stats span { display: block; font-size: 12px; }
|
| 126 |
+
.oe-stats code { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
| 127 |
+
.oe-error { color: #b42318; }
|
| 128 |
+
.dark .oe-error { color: #ff8a80; }
|
| 129 |
+
.oe-episode ol { list-style: none; margin: 8px 0 0; padding: 0; }
|
| 130 |
+
.oe-episode li { display: flex; gap: 12px; padding: 8px 0; border-top: 1px solid var(--border-color-primary); }
|
| 131 |
+
.oe-episode li > span { flex: none; width: 20px; font-size: 12px; }
|
| 132 |
+
.oe-episode code { display: block; font-size: 13px; word-break: break-word; }
|
| 133 |
+
.oe-episode small { display: block; font-size: 13px; word-break: break-word; }
|
| 134 |
+
.oe-muted { margin: 8px 0 0; font-size: 14px; }
|
| 135 |
+
.oe-code pre code { white-space: pre-wrap !important; word-break: break-word; }
|
| 136 |
+
.oe-json .code_wrap, .oe-json .cm-editor { max-height: 360px; overflow: auto; }
|
| 137 |
+
.oe-code pre { background: var(--background-fill-secondary) !important; border: 1px solid var(--border-color-primary); border-radius: 8px; }
|
| 138 |
+
.oe-raw { border: none !important; }
|
| 139 |
+
.oe-visual { flex: none !important; width: auto !important; min-width: 0 !important; }
|
| 140 |
+
.dark { --color-accent: #3fb950; }
|
| 141 |
+
footer { display: none !important; }
|
| 142 |
"""
|
src/core/env_server/gradio_ui.py
CHANGED
|
@@ -1,62 +1,37 @@
|
|
| 1 |
-
#
|
| 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 |
Gradio-based web UI for OpenEnv environments.
|
| 9 |
|
| 10 |
-
|
| 11 |
-
|
|
|
|
| 12 |
"""
|
| 13 |
|
| 14 |
from __future__ import annotations
|
| 15 |
|
|
|
|
| 16 |
import json
|
|
|
|
| 17 |
import re
|
| 18 |
-
from typing import Any, Dict, List, Optional
|
| 19 |
|
| 20 |
import gradio as gr
|
| 21 |
|
|
|
|
|
|
|
| 22 |
from .types import EnvironmentMetadata
|
| 23 |
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
""
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
""
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
if isinstance(obs, dict):
|
| 35 |
-
if obs.get("prompt"):
|
| 36 |
-
lines.append(f"**Prompt:**\n\n{_escape_md(obs['prompt'])}\n")
|
| 37 |
-
messages = obs.get("messages", [])
|
| 38 |
-
if messages:
|
| 39 |
-
lines.append("**Messages:**\n")
|
| 40 |
-
for msg in messages:
|
| 41 |
-
sender = _escape_md(str(msg.get("sender_id", "?")))
|
| 42 |
-
content = _escape_md(str(msg.get("content", "")))
|
| 43 |
-
cat = _escape_md(str(msg.get("category", "")))
|
| 44 |
-
lines.append(f"- `[{cat}]` Player {sender}: {content}")
|
| 45 |
-
lines.append("")
|
| 46 |
-
reward = data.get("reward")
|
| 47 |
-
done = data.get("done")
|
| 48 |
-
if reward is not None:
|
| 49 |
-
lines.append(f"**Reward:** `{reward}`")
|
| 50 |
-
if done is not None:
|
| 51 |
-
lines.append(f"**Done:** `{done}`")
|
| 52 |
-
return "\n".join(lines) if lines else "*No observation data*"
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
def _readme_section(metadata: Optional[EnvironmentMetadata]) -> str:
|
| 56 |
-
"""README content for the left panel."""
|
| 57 |
-
if not metadata or not metadata.readme_content:
|
| 58 |
-
return "*No README available.*"
|
| 59 |
-
return metadata.readme_content
|
| 60 |
|
| 61 |
|
| 62 |
def get_gradio_display_title(
|
|
@@ -68,6 +43,314 @@ def get_gradio_display_title(
|
|
| 68 |
return f"OpenEnv Agentic Environment: {name}"
|
| 69 |
|
| 70 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
def build_gradio_app(
|
| 72 |
web_manager: Any,
|
| 73 |
action_fields: List[Dict[str, Any]],
|
|
@@ -81,160 +364,347 @@ def build_gradio_app(
|
|
| 81 |
|
| 82 |
Args:
|
| 83 |
web_manager: WebInterfaceManager (reset/step_environment, get_state).
|
| 84 |
-
action_fields: Field dicts from _extract_action_fields(action_cls).
|
|
|
|
| 85 |
metadata: Environment metadata for README/name.
|
| 86 |
-
is_chat_env: If True, single message
|
| 87 |
title: App title (overridden by metadata.name when present; see get_gradio_display_title).
|
| 88 |
quick_start_md: Optional Quick Start markdown (class names already replaced).
|
| 89 |
|
| 90 |
Returns:
|
| 91 |
gr.Blocks to mount with gr.mount_gradio_app(app, blocks, path="/web").
|
| 92 |
"""
|
| 93 |
-
|
| 94 |
-
|
|
|
|
|
|
|
| 95 |
|
| 96 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
try:
|
| 98 |
data = await web_manager.reset_environment()
|
| 99 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
return (
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
)
|
| 105 |
-
|
| 106 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
|
| 108 |
-
def
|
| 109 |
-
|
| 110 |
try:
|
| 111 |
-
|
| 112 |
-
obs_md = _format_observation(data)
|
| 113 |
-
return (
|
| 114 |
-
obs_md,
|
| 115 |
-
json.dumps(data, indent=2),
|
| 116 |
-
"Step complete.",
|
| 117 |
-
)
|
| 118 |
except Exception as e:
|
| 119 |
-
return (
|
| 120 |
-
|
| 121 |
-
return _run
|
| 122 |
-
|
| 123 |
-
async def step_chat(message: str):
|
| 124 |
-
if not (message or str(message).strip()):
|
| 125 |
-
return ("", "", "Please enter an action message.")
|
| 126 |
-
action = {"message": str(message).strip()}
|
| 127 |
-
return await _step_with_action(action)()
|
| 128 |
-
|
| 129 |
-
def get_state_sync():
|
| 130 |
try:
|
| 131 |
-
data = web_manager.
|
| 132 |
-
return json.dumps(data, indent=2)
|
| 133 |
except Exception as e:
|
| 134 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 135 |
|
| 136 |
-
with gr.Blocks(title=
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 154 |
)
|
| 155 |
-
step_inputs = [action_input]
|
| 156 |
-
step_fn = step_chat
|
| 157 |
else:
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
choices
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 174 |
)
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 180 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 181 |
else:
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 213 |
)
|
| 214 |
-
raw_json = gr.Code(
|
| 215 |
-
label="Raw JSON response",
|
| 216 |
-
language="json",
|
| 217 |
-
interactive=False,
|
| 218 |
-
)
|
| 219 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 220 |
reset_btn.click(
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
|
|
|
|
|
|
| 226 |
inputs=step_inputs,
|
| 227 |
-
outputs=
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
fn=get_state_sync,
|
| 237 |
-
outputs=[raw_json],
|
| 238 |
-
)
|
| 239 |
|
| 240 |
return demo
|
|
|
|
| 1 |
+
# SPDX-License-Identifier: BSD-3-Clause
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
"""
|
| 4 |
Gradio-based web UI for OpenEnv environments.
|
| 5 |
|
| 6 |
+
Mounted at /web via gr.mount_gradio_app() from create_web_interface_app() when
|
| 7 |
+
ENABLE_WEB_INTERFACE is set. The page follows the loop an agent runs: reset,
|
| 8 |
+
take an action, read the result, with the same call in Python next to it.
|
| 9 |
"""
|
| 10 |
|
| 11 |
from __future__ import annotations
|
| 12 |
|
| 13 |
+
import html
|
| 14 |
import json
|
| 15 |
+
import math
|
| 16 |
import re
|
| 17 |
+
from typing import Any, Callable, Dict, List, Optional, Tuple
|
| 18 |
|
| 19 |
import gradio as gr
|
| 20 |
|
| 21 |
+
from .mcp_environment import MCPEnvironment
|
| 22 |
+
from .mcp_types import ListToolsAction
|
| 23 |
from .types import EnvironmentMetadata
|
| 24 |
|
| 25 |
+
_SECRET_NAMES = ("api_key", "token", "secret", "password")
|
| 26 |
+
_LONG_TEXT_NAMES = (
|
| 27 |
+
"code",
|
| 28 |
+
"message",
|
| 29 |
+
"response",
|
| 30 |
+
"text",
|
| 31 |
+
"content",
|
| 32 |
+
"answer",
|
| 33 |
+
"prompt",
|
| 34 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
|
| 36 |
|
| 37 |
def get_gradio_display_title(
|
|
|
|
| 43 |
return f"OpenEnv Agentic Environment: {name}"
|
| 44 |
|
| 45 |
|
| 46 |
+
def _readme_body(readme: str) -> str:
|
| 47 |
+
"""The README without the Hugging Face Space front matter."""
|
| 48 |
+
return re.sub(r"\A---\n.*?\n---\n", "", readme, flags=re.S)
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def _description(metadata: Optional[EnvironmentMetadata]) -> str:
|
| 52 |
+
"""The metadata description, or the README's first paragraph when it's the default."""
|
| 53 |
+
if not metadata:
|
| 54 |
+
return ""
|
| 55 |
+
if (
|
| 56 |
+
not re.fullmatch(r"\S+ environment", metadata.description or "")
|
| 57 |
+
or not metadata.readme_content
|
| 58 |
+
):
|
| 59 |
+
return metadata.description
|
| 60 |
+
for paragraph in re.split(r"\n\s*\n", _readme_body(metadata.readme_content)):
|
| 61 |
+
text = paragraph.strip()
|
| 62 |
+
if text and not text.startswith(("#", "<", "[!", "!", "|", "```", ">")):
|
| 63 |
+
return re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", " ".join(text.split()))
|
| 64 |
+
return metadata.description
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def _header_html(metadata: Optional[EnvironmentMetadata], kind: str) -> str:
|
| 68 |
+
name = metadata.name if metadata else "environment"
|
| 69 |
+
title = name.removesuffix("_env").replace("_", " ").title()
|
| 70 |
+
return (
|
| 71 |
+
f'<div class="oe-header"><span class="oe-eyebrow">{html.escape(name)} · {kind}</span>'
|
| 72 |
+
f"<h1>{html.escape(title)}</h1><p>{html.escape(_description(metadata))}</p></div>"
|
| 73 |
+
)
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def _step_title(number: int, title: str, text: str) -> str:
|
| 77 |
+
return f'<div class="oe-step"><h2>{number}. {title}</h2><p>{text}</p></div>'
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def _rounded(value: Any) -> Any:
|
| 81 |
+
if isinstance(value, float):
|
| 82 |
+
return round(value, 4)
|
| 83 |
+
if isinstance(value, list):
|
| 84 |
+
return [_rounded(v) for v in value]
|
| 85 |
+
if isinstance(value, dict):
|
| 86 |
+
return {k: _rounded(v) for k, v in value.items()}
|
| 87 |
+
return value
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def _folded(value: Any) -> Any:
|
| 91 |
+
"""
|
| 92 |
+
The value with each array of more than 64 numbers (a grid, a screen, a screenshot)
|
| 93 |
+
shown as its shape, so the page stays light. Smaller boards and lists keep their values.
|
| 94 |
+
"""
|
| 95 |
+
if isinstance(value, dict):
|
| 96 |
+
return {k: _folded(v) for k, v in value.items()}
|
| 97 |
+
if not isinstance(value, list):
|
| 98 |
+
return value
|
| 99 |
+
shape, first = [], value
|
| 100 |
+
while isinstance(first, list) and first:
|
| 101 |
+
shape.append(len(first))
|
| 102 |
+
first = first[0]
|
| 103 |
+
if isinstance(first, (int, float)) and math.prod(shape) > 64:
|
| 104 |
+
return f"list · {'×'.join(map(str, shape))}"
|
| 105 |
+
return [_folded(v) for v in value]
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def _short(value: Any, limit: int = 160) -> str:
|
| 109 |
+
value = _rounded(value)
|
| 110 |
+
if isinstance(value, list) and len(value) > 20:
|
| 111 |
+
text = f"{len(value)} items: {json.dumps(value[:8], default=str)[:-1]}, …]"
|
| 112 |
+
else:
|
| 113 |
+
text = value if isinstance(value, str) else json.dumps(value, default=str)
|
| 114 |
+
return text if len(text) <= limit else text[: limit - 1] + "…"
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def _error(obs: Dict[str, Any]) -> Optional[str]:
|
| 118 |
+
"""The error an observation reports: a failed tool call, or an env error in metadata."""
|
| 119 |
+
error = obs.get("error") or (obs.get("metadata") or {}).get("error")
|
| 120 |
+
if not error:
|
| 121 |
+
return None
|
| 122 |
+
return str(error.get("message", error) if isinstance(error, dict) else error)
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def _output(obs: Dict[str, Any]) -> Any:
|
| 126 |
+
"""What a tool call returned."""
|
| 127 |
+
result = obs.get("result")
|
| 128 |
+
return result.get("data", result) if isinstance(result, dict) else result
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def _result_html(data: Dict[str, Any], step_count: int) -> str:
|
| 132 |
+
"""The observation (a tool's output or its fields), then reward, done and step."""
|
| 133 |
+
obs = data.get("observation", {}) or {}
|
| 134 |
+
error = _error(obs)
|
| 135 |
+
if error:
|
| 136 |
+
body = (
|
| 137 |
+
f'<pre class="oe-output oe-error">Error: {html.escape(error[:2000])}</pre>'
|
| 138 |
+
)
|
| 139 |
+
elif obs.get("result") is not None:
|
| 140 |
+
output = _output(obs)
|
| 141 |
+
text = (
|
| 142 |
+
output
|
| 143 |
+
if isinstance(output, str)
|
| 144 |
+
else json.dumps(_rounded(output), indent=2, default=str)
|
| 145 |
+
)
|
| 146 |
+
body = f'<pre class="oe-output">{html.escape(text[:4000])}</pre>'
|
| 147 |
+
else:
|
| 148 |
+
rows = "".join(
|
| 149 |
+
f'<div><span title="{html.escape(k)}">{html.escape(k)}</span>'
|
| 150 |
+
f"<code>{html.escape(_short(v, 600 if isinstance(v, str) else 160))}</code></div>"
|
| 151 |
+
for k, v in obs.items()
|
| 152 |
+
if k not in ("metadata", "done", "reward", "result", "error")
|
| 153 |
+
and v not in (None, "", [], {})
|
| 154 |
+
)
|
| 155 |
+
body = f'<div class="oe-fields">{rows}</div>' if rows else ""
|
| 156 |
+
reward = data.get("reward")
|
| 157 |
+
stats = "".join(
|
| 158 |
+
f"<div><span>{label}</span><code>{html.escape(str(value))}</code></div>"
|
| 159 |
+
for label, value in (
|
| 160 |
+
("reward", "–" if reward is None else _rounded(reward)),
|
| 161 |
+
("done", str(bool(data.get("done"))).lower()),
|
| 162 |
+
("step", step_count),
|
| 163 |
+
)
|
| 164 |
+
)
|
| 165 |
+
return f'<div class="oe-result">{body}<div class="oe-stats">{stats}</div></div>'
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
def _error_html(message: str, step_count: int) -> str:
|
| 169 |
+
return _result_html({"observation": {"error": message}}, step_count)
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
def _episode_html(entries: List[List[str]]) -> str:
|
| 173 |
+
if not entries:
|
| 174 |
+
items = '<p class="oe-muted">Nothing yet. Reset, then run a step.</p>'
|
| 175 |
+
else:
|
| 176 |
+
items = (
|
| 177 |
+
"<ol>"
|
| 178 |
+
+ "".join(
|
| 179 |
+
f"<li><span>{html.escape(badge)}</span><div><code>{html.escape(call)}</code>"
|
| 180 |
+
f"<small>{html.escape(result)}</small></div></li>"
|
| 181 |
+
for badge, call, result in entries
|
| 182 |
+
)
|
| 183 |
+
+ "</ol>"
|
| 184 |
+
)
|
| 185 |
+
return f'<div class="oe-episode"><h2>Episode</h2>{items}</div>'
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
def _list_tools(env: Any) -> List[Any]:
|
| 189 |
+
return list(env.step(ListToolsAction()).tools)
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
def _tool_label(tool: Any) -> str:
|
| 193 |
+
"""The tool's name and the first line of its description, if it has one."""
|
| 194 |
+
first_line = (tool.description or "").strip().split("\n")[0]
|
| 195 |
+
return f"{tool.name}: {first_line}" if first_line else tool.name
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
def _params(schema: Dict[str, Any]) -> List[Tuple[str, Dict[str, Any], bool]]:
|
| 199 |
+
"""
|
| 200 |
+
The (name, schema, required) of each property of a JSON schema, with `$ref`s
|
| 201 |
+
resolved and `X | None` unwrapped to `X`.
|
| 202 |
+
"""
|
| 203 |
+
defs = schema.get("$defs", {})
|
| 204 |
+
|
| 205 |
+
def resolve(prop: Dict[str, Any]) -> Dict[str, Any]:
|
| 206 |
+
if "$ref" in prop:
|
| 207 |
+
prop = {
|
| 208 |
+
**defs.get(prop["$ref"].split("/")[-1], {}),
|
| 209 |
+
**{k: v for k, v in prop.items() if k != "$ref"},
|
| 210 |
+
}
|
| 211 |
+
for key in ("anyOf", "oneOf", "allOf"):
|
| 212 |
+
options = [o for o in prop.get(key, []) if o.get("type") != "null"]
|
| 213 |
+
if options:
|
| 214 |
+
merged = {k: v for k, v in prop.items() if k != key}
|
| 215 |
+
return resolve({**resolve(options[0]), **merged})
|
| 216 |
+
return prop
|
| 217 |
+
|
| 218 |
+
required = set(schema.get("required", []))
|
| 219 |
+
return [
|
| 220 |
+
(name, resolve(prop), name in required)
|
| 221 |
+
for name, prop in schema.get("properties", {}).items()
|
| 222 |
+
if name != "metadata"
|
| 223 |
+
]
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
def _call_args(arguments: Dict[str, Any]) -> str:
|
| 227 |
+
"""Arguments as `name=value` for the episode log, with secrets masked and long values cut."""
|
| 228 |
+
return ", ".join(
|
| 229 |
+
f"{k}={'***' if k.lower().endswith(_SECRET_NAMES) else _short(json.dumps(v), 60)}"
|
| 230 |
+
for k, v in arguments.items()
|
| 231 |
+
)
|
| 232 |
+
|
| 233 |
+
|
| 234 |
+
def _blank(value: Any) -> bool:
|
| 235 |
+
return value is None or (isinstance(value, str) and not value.strip())
|
| 236 |
+
|
| 237 |
+
|
| 238 |
+
def _param_input(
|
| 239 |
+
name: str, schema: Dict[str, Any], required: bool
|
| 240 |
+
) -> Tuple[Any, Callable[[Any], Any]]:
|
| 241 |
+
"""
|
| 242 |
+
A form input for one parameter, and a function that turns its value into
|
| 243 |
+
the one to send (`None` to leave the parameter out).
|
| 244 |
+
"""
|
| 245 |
+
kind = schema.get("type", "string")
|
| 246 |
+
default = schema.get("default")
|
| 247 |
+
is_json = kind in ("array", "object")
|
| 248 |
+
label = f"{name} ({kind}{', JSON' if is_json else ''}){' · required' if required else ''}"
|
| 249 |
+
info = (schema.get("description") or "").strip()[:200] or None
|
| 250 |
+
|
| 251 |
+
if "enum" in schema:
|
| 252 |
+
# A required choice starts on its first option, so the form can run as is.
|
| 253 |
+
value = schema["enum"][0] if required else default
|
| 254 |
+
return (
|
| 255 |
+
gr.Dropdown(choices=schema["enum"], value=value, label=label, info=info),
|
| 256 |
+
lambda v: None if _blank(v) else v,
|
| 257 |
+
)
|
| 258 |
+
if kind == "boolean":
|
| 259 |
+
return gr.Checkbox(label=label, value=bool(default), info=info), bool
|
| 260 |
+
if kind in ("integer", "number"):
|
| 261 |
+
cast = int if kind == "integer" else float
|
| 262 |
+
|
| 263 |
+
def number(value: Any) -> Any:
|
| 264 |
+
if _blank(value):
|
| 265 |
+
return None
|
| 266 |
+
try:
|
| 267 |
+
return cast(float(value))
|
| 268 |
+
except ValueError:
|
| 269 |
+
raise ValueError(f"{name} must be a number")
|
| 270 |
+
|
| 271 |
+
# Gradio shows an empty number box as 0, so optional numbers without a default are text.
|
| 272 |
+
if default is None and not required:
|
| 273 |
+
return gr.Textbox(label=label, info=info, placeholder="optional"), number
|
| 274 |
+
return gr.Number(
|
| 275 |
+
label=label,
|
| 276 |
+
value=default,
|
| 277 |
+
info=info,
|
| 278 |
+
precision=0 if kind == "integer" else None,
|
| 279 |
+
), number
|
| 280 |
+
if is_json:
|
| 281 |
+
|
| 282 |
+
def parse(value: Any) -> Any:
|
| 283 |
+
if _blank(value):
|
| 284 |
+
return None
|
| 285 |
+
try:
|
| 286 |
+
return json.loads(value)
|
| 287 |
+
except json.JSONDecodeError as e:
|
| 288 |
+
raise ValueError(f"{name} must be JSON: {e}")
|
| 289 |
+
|
| 290 |
+
return (
|
| 291 |
+
gr.Textbox(
|
| 292 |
+
label=label,
|
| 293 |
+
info=info,
|
| 294 |
+
lines=3,
|
| 295 |
+
value=None if default is None else json.dumps(default),
|
| 296 |
+
placeholder="[ ... ]" if kind == "array" else "{ ... }",
|
| 297 |
+
),
|
| 298 |
+
parse,
|
| 299 |
+
)
|
| 300 |
+
long_text = (
|
| 301 |
+
any(n in name.lower() for n in _LONG_TEXT_NAMES)
|
| 302 |
+
or schema.get("maxLength", 0) > 100
|
| 303 |
+
)
|
| 304 |
+
return (
|
| 305 |
+
gr.Textbox(
|
| 306 |
+
label=label,
|
| 307 |
+
info=info,
|
| 308 |
+
value=default if isinstance(default, str) else None,
|
| 309 |
+
lines=4 if long_text else 1,
|
| 310 |
+
type="password" if name.lower().endswith(_SECRET_NAMES) else "text",
|
| 311 |
+
),
|
| 312 |
+
lambda v: None if _blank(v) else v,
|
| 313 |
+
)
|
| 314 |
+
|
| 315 |
+
|
| 316 |
+
class _Form:
|
| 317 |
+
"""Inputs for a set of parameters, and the dict to send built from their values."""
|
| 318 |
+
|
| 319 |
+
def __init__(self, params: List[Tuple[str, Dict[str, Any], bool]]):
|
| 320 |
+
required = [p for p in params if p[2]]
|
| 321 |
+
optional = [p for p in params if not p[2]]
|
| 322 |
+
self.names: List[Tuple[str, bool]] = []
|
| 323 |
+
self.inputs: List[Any] = []
|
| 324 |
+
self.converters: List[Callable[[Any], Any]] = []
|
| 325 |
+
self._add(required)
|
| 326 |
+
if required and optional:
|
| 327 |
+
with gr.Accordion("More fields", open=False, elem_classes="oe-raw"):
|
| 328 |
+
self._add(optional)
|
| 329 |
+
else:
|
| 330 |
+
self._add(optional)
|
| 331 |
+
|
| 332 |
+
def _add(self, params: List[Tuple[str, Dict[str, Any], bool]]) -> None:
|
| 333 |
+
for name, schema, required in params:
|
| 334 |
+
component, convert = _param_input(name, schema, required)
|
| 335 |
+
self.names.append((name, required))
|
| 336 |
+
self.inputs.append(component)
|
| 337 |
+
self.converters.append(convert)
|
| 338 |
+
|
| 339 |
+
def values(self, raw: List[Any]) -> Dict[str, Any]:
|
| 340 |
+
"""The values to send. Raises `ValueError` for a missing or invalid one."""
|
| 341 |
+
values, missing = {}, []
|
| 342 |
+
for (name, required), convert, value in zip(self.names, self.converters, raw):
|
| 343 |
+
converted = convert(value)
|
| 344 |
+
if converted is None:
|
| 345 |
+
if required:
|
| 346 |
+
missing.append(name)
|
| 347 |
+
continue
|
| 348 |
+
values[name] = converted
|
| 349 |
+
if missing:
|
| 350 |
+
raise ValueError(f"Fill in {', '.join(missing)}.")
|
| 351 |
+
return values
|
| 352 |
+
|
| 353 |
+
|
| 354 |
def build_gradio_app(
|
| 355 |
web_manager: Any,
|
| 356 |
action_fields: List[Dict[str, Any]],
|
|
|
|
| 364 |
|
| 365 |
Args:
|
| 366 |
web_manager: WebInterfaceManager (reset/step_environment, get_state).
|
| 367 |
+
action_fields: Field dicts from _extract_action_fields(action_cls). The form is
|
| 368 |
+
built from the action's JSON schema, so this is kept for `gradio_builder`s.
|
| 369 |
metadata: Environment metadata for README/name.
|
| 370 |
+
is_chat_env: If True, a single message box; else a form from the action schema.
|
| 371 |
title: App title (overridden by metadata.name when present; see get_gradio_display_title).
|
| 372 |
quick_start_md: Optional Quick Start markdown (class names already replaced).
|
| 373 |
|
| 374 |
Returns:
|
| 375 |
gr.Blocks to mount with gr.mount_gradio_app(app, blocks, path="/web").
|
| 376 |
"""
|
| 377 |
+
env = web_manager.env
|
| 378 |
+
tools = _list_tools(env) if isinstance(env, MCPEnvironment) else []
|
| 379 |
+
is_mcp = bool(tools)
|
| 380 |
+
kind = "MCP environment" if is_mcp else "environment"
|
| 381 |
|
| 382 |
+
def render(data: Dict[str, Any]):
|
| 383 |
+
"""The env's drawing and its one-click actions for this observation."""
|
| 384 |
+
obs = {
|
| 385 |
+
**(data.get("observation") or {}),
|
| 386 |
+
"reward": data.get("reward"),
|
| 387 |
+
"done": data.get("done"),
|
| 388 |
+
}
|
| 389 |
+
drawing = env.render_web(obs)
|
| 390 |
+
actions = env.web_actions(obs) if not obs["done"] else []
|
| 391 |
+
return (
|
| 392 |
+
gr.update(value=drawing or "", visible=bool(drawing)),
|
| 393 |
+
gr.update(
|
| 394 |
+
choices=[(label, str(i)) for i, (label, _) in enumerate(actions)],
|
| 395 |
+
value=None,
|
| 396 |
+
visible=bool(actions),
|
| 397 |
+
),
|
| 398 |
+
[action for _, action in actions],
|
| 399 |
+
)
|
| 400 |
+
|
| 401 |
+
def steps_in(entries: List[List[str]]) -> int:
|
| 402 |
+
return sum(1 for badge, _, _ in entries if badge != "R")
|
| 403 |
+
|
| 404 |
+
def unchanged(message: str, entries: List[List[str]]):
|
| 405 |
+
"""Show an error without touching the episode."""
|
| 406 |
+
return (
|
| 407 |
+
gr.update(),
|
| 408 |
+
gr.update(),
|
| 409 |
+
gr.update(),
|
| 410 |
+
_error_html(message, steps_in(entries)),
|
| 411 |
+
gr.update(),
|
| 412 |
+
gr.update(),
|
| 413 |
+
entries,
|
| 414 |
+
)
|
| 415 |
+
|
| 416 |
+
async def reset_env(entries):
|
| 417 |
try:
|
| 418 |
data = await web_manager.reset_environment()
|
| 419 |
+
except Exception as e:
|
| 420 |
+
# Keep the current episode and the last step's result.
|
| 421 |
+
(
|
| 422 |
+
visual_update,
|
| 423 |
+
quick_update,
|
| 424 |
+
actions,
|
| 425 |
+
result_html,
|
| 426 |
+
raw,
|
| 427 |
+
episode_html,
|
| 428 |
+
kept,
|
| 429 |
+
) = unchanged(f"reset() failed: {e}", entries)
|
| 430 |
return (
|
| 431 |
+
visual_update,
|
| 432 |
+
quick_update,
|
| 433 |
+
actions,
|
| 434 |
+
result_html,
|
| 435 |
+
raw,
|
| 436 |
+
episode_html,
|
| 437 |
+
kept,
|
| 438 |
+
gr.update(),
|
| 439 |
)
|
| 440 |
+
error = _error(data.get("observation", {}) or {})
|
| 441 |
+
entries = [
|
| 442 |
+
["R", "reset()", f"error: {_short(error)}" if error else "new episode"]
|
| 443 |
+
]
|
| 444 |
+
visual_update, quick_update, actions = render(data)
|
| 445 |
+
shown = _folded(data)
|
| 446 |
+
return (
|
| 447 |
+
visual_update,
|
| 448 |
+
quick_update,
|
| 449 |
+
actions,
|
| 450 |
+
_result_html(shown, 0),
|
| 451 |
+
json.dumps(shown, indent=2, default=str, ensure_ascii=False),
|
| 452 |
+
_episode_html(entries),
|
| 453 |
+
entries,
|
| 454 |
+
"",
|
| 455 |
+
)
|
| 456 |
|
| 457 |
+
async def run_step(action: Dict[str, Any], call: str, entries):
|
| 458 |
+
if not entries:
|
| 459 |
try:
|
| 460 |
+
await web_manager.reset_environment()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 461 |
except Exception as e:
|
| 462 |
+
return unchanged(f"reset() failed: {e}", entries)
|
| 463 |
+
entries = [["R", "reset()", "new episode"]]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 464 |
try:
|
| 465 |
+
data = await web_manager.step_environment(action)
|
|
|
|
| 466 |
except Exception as e:
|
| 467 |
+
entries = entries + [
|
| 468 |
+
[str(steps_in(entries) + 1), call, f"error: {_short(str(e))}"]
|
| 469 |
+
]
|
| 470 |
+
return (
|
| 471 |
+
gr.update(),
|
| 472 |
+
gr.update(),
|
| 473 |
+
gr.update(),
|
| 474 |
+
_error_html(str(e), steps_in(entries)),
|
| 475 |
+
gr.update(),
|
| 476 |
+
_episode_html(entries),
|
| 477 |
+
entries,
|
| 478 |
+
)
|
| 479 |
+
obs = data.get("observation", {}) or {}
|
| 480 |
+
error = _error(obs)
|
| 481 |
+
summary = (
|
| 482 |
+
f"error: {error}"
|
| 483 |
+
if error
|
| 484 |
+
else _output(obs)
|
| 485 |
+
if obs.get("result") is not None
|
| 486 |
+
else f"reward {data.get('reward')}"
|
| 487 |
+
)
|
| 488 |
+
entries = entries + [[str(steps_in(entries) + 1), call, _short(summary)]]
|
| 489 |
+
visual_update, quick_update, actions = render(data)
|
| 490 |
+
shown = _folded(data)
|
| 491 |
+
return (
|
| 492 |
+
visual_update,
|
| 493 |
+
quick_update,
|
| 494 |
+
actions,
|
| 495 |
+
_result_html(shown, steps_in(entries)),
|
| 496 |
+
json.dumps(shown, indent=2, default=str, ensure_ascii=False),
|
| 497 |
+
_episode_html(entries),
|
| 498 |
+
entries,
|
| 499 |
+
)
|
| 500 |
|
| 501 |
+
with gr.Blocks(title=get_gradio_display_title(metadata, fallback=title)) as demo:
|
| 502 |
+
entries_state = gr.State([])
|
| 503 |
+
gr.HTML(_header_html(metadata, kind))
|
| 504 |
+
with gr.Row(equal_height=False):
|
| 505 |
+
with gr.Column(scale=3, min_width=360):
|
| 506 |
+
with gr.Column(elem_classes="oe-card"):
|
| 507 |
+
with gr.Row(elem_classes="oe-row", equal_height=True):
|
| 508 |
+
gr.HTML(
|
| 509 |
+
_step_title(
|
| 510 |
+
1,
|
| 511 |
+
"Start an episode",
|
| 512 |
+
"<code>reset()</code> returns the first observation.",
|
| 513 |
+
)
|
| 514 |
+
)
|
| 515 |
+
reset_btn = gr.Button(
|
| 516 |
+
"Reset", variant="secondary", scale=0, min_width=110
|
| 517 |
+
)
|
| 518 |
+
reset_result = gr.HTML()
|
| 519 |
+
|
| 520 |
+
with gr.Column(elem_classes="oe-card"):
|
| 521 |
+
if is_mcp:
|
| 522 |
+
gr.HTML(
|
| 523 |
+
_step_title(
|
| 524 |
+
2,
|
| 525 |
+
"Call a tool",
|
| 526 |
+
"<code>step()</code> sends a tool call. Pick one:",
|
| 527 |
+
)
|
| 528 |
)
|
|
|
|
|
|
|
| 529 |
else:
|
| 530 |
+
gr.HTML(
|
| 531 |
+
_step_title(
|
| 532 |
+
2,
|
| 533 |
+
"Take an action",
|
| 534 |
+
"<code>step()</code> sends an action.",
|
| 535 |
+
)
|
| 536 |
+
)
|
| 537 |
+
with gr.Row(equal_height=False):
|
| 538 |
+
visual = gr.HTML(visible=False, elem_classes="oe-visual")
|
| 539 |
+
with gr.Column(min_width=260):
|
| 540 |
+
quick = gr.Radio(
|
| 541 |
+
choices=[],
|
| 542 |
+
visible=False,
|
| 543 |
+
label="Legal actions",
|
| 544 |
+
elem_classes="oe-actions",
|
| 545 |
+
)
|
| 546 |
+
if is_mcp:
|
| 547 |
+
tool_choice = gr.Radio(
|
| 548 |
+
choices=[(_tool_label(t), t.name) for t in tools],
|
| 549 |
+
value=tools[0].name,
|
| 550 |
+
show_label=False,
|
| 551 |
+
elem_classes="oe-tools",
|
| 552 |
)
|
| 553 |
+
forms, groups = {}, []
|
| 554 |
+
for i, tool in enumerate(tools):
|
| 555 |
+
with gr.Column(visible=i == 0) as group:
|
| 556 |
+
forms[tool.name] = _Form(
|
| 557 |
+
_params(tool.input_schema)
|
| 558 |
+
)
|
| 559 |
+
groups.append(group)
|
| 560 |
+
tool_choice.change(
|
| 561 |
+
lambda name: [
|
| 562 |
+
gr.update(visible=t.name == name) for t in tools
|
| 563 |
+
],
|
| 564 |
+
inputs=tool_choice,
|
| 565 |
+
outputs=groups,
|
| 566 |
)
|
| 567 |
+
all_inputs = [
|
| 568 |
+
i for f in forms.values() for i in f.inputs
|
| 569 |
+
]
|
| 570 |
+
|
| 571 |
+
async def step_fn(entries, name, *values):
|
| 572 |
+
start = 0
|
| 573 |
+
for tool_name, form in forms.items():
|
| 574 |
+
if tool_name == name:
|
| 575 |
+
break
|
| 576 |
+
start += len(form.inputs)
|
| 577 |
+
form = forms[name]
|
| 578 |
+
try:
|
| 579 |
+
arguments = form.values(
|
| 580 |
+
list(
|
| 581 |
+
values[start : start + len(form.inputs)]
|
| 582 |
+
)
|
| 583 |
+
)
|
| 584 |
+
except ValueError as e:
|
| 585 |
+
return unchanged(str(e), entries)
|
| 586 |
+
action = {
|
| 587 |
+
"type": "call_tool",
|
| 588 |
+
"tool_name": name,
|
| 589 |
+
"arguments": arguments,
|
| 590 |
+
}
|
| 591 |
+
return await run_step(
|
| 592 |
+
action,
|
| 593 |
+
f"{name}({_call_args(arguments)})",
|
| 594 |
+
entries,
|
| 595 |
+
)
|
| 596 |
+
|
| 597 |
+
step_inputs = [entries_state, tool_choice, *all_inputs]
|
| 598 |
else:
|
| 599 |
+
if is_chat_env:
|
| 600 |
+
params = [
|
| 601 |
+
(
|
| 602 |
+
"message",
|
| 603 |
+
{
|
| 604 |
+
"type": "string",
|
| 605 |
+
"description": "Your message to the model.",
|
| 606 |
+
},
|
| 607 |
+
True,
|
| 608 |
+
)
|
| 609 |
+
]
|
| 610 |
+
else:
|
| 611 |
+
params = [
|
| 612 |
+
p
|
| 613 |
+
for p in _params(
|
| 614 |
+
web_manager.action_cls.model_json_schema()
|
| 615 |
+
)
|
| 616 |
+
if p[0] != "type"
|
| 617 |
+
]
|
| 618 |
+
form = _Form(params)
|
| 619 |
+
|
| 620 |
+
async def step_fn(entries, *values):
|
| 621 |
+
try:
|
| 622 |
+
action = form.values(list(values))
|
| 623 |
+
except ValueError as e:
|
| 624 |
+
return unchanged(str(e), entries)
|
| 625 |
+
return await run_step(
|
| 626 |
+
action, f"step({_call_args(action)})", entries
|
| 627 |
+
)
|
| 628 |
+
|
| 629 |
+
step_inputs = [entries_state, *form.inputs]
|
| 630 |
+
|
| 631 |
+
step_btn = gr.Button(
|
| 632 |
+
"Run step", variant="primary", elem_classes="oe-run"
|
| 633 |
+
)
|
| 634 |
+
result = gr.HTML()
|
| 635 |
+
with gr.Accordion(
|
| 636 |
+
"raw JSON", open=False, elem_classes="oe-raw oe-json"
|
| 637 |
+
):
|
| 638 |
+
raw_json = gr.Code(
|
| 639 |
+
language="json", interactive=False, show_label=False
|
| 640 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 641 |
|
| 642 |
+
with gr.Column(scale=2, min_width=320):
|
| 643 |
+
with gr.Column(elem_classes="oe-card"):
|
| 644 |
+
episode = gr.HTML(_episode_html([]))
|
| 645 |
+
with gr.Accordion(
|
| 646 |
+
"State", open=False, elem_classes="oe-raw oe-json"
|
| 647 |
+
):
|
| 648 |
+
state_json = gr.Code(
|
| 649 |
+
language="json", interactive=False, show_label=False
|
| 650 |
+
)
|
| 651 |
+
if quick_start_md:
|
| 652 |
+
with gr.Column(elem_classes="oe-card oe-code"):
|
| 653 |
+
gr.HTML(
|
| 654 |
+
'<div class="oe-step"><h2>Same call in Python</h2></div>'
|
| 655 |
+
)
|
| 656 |
+
gr.Markdown(
|
| 657 |
+
quick_start_md.replace(
|
| 658 |
+
"### Connect to this environment\n\n", ""
|
| 659 |
+
)
|
| 660 |
+
)
|
| 661 |
+
|
| 662 |
+
if metadata and metadata.readme_content:
|
| 663 |
+
with gr.Accordion("README", open=False, elem_classes="oe-raw"):
|
| 664 |
+
gr.Markdown(_readme_body(metadata.readme_content))
|
| 665 |
+
|
| 666 |
+
quick_state = gr.State([])
|
| 667 |
+
|
| 668 |
+
def show_state():
|
| 669 |
+
try:
|
| 670 |
+
return json.dumps(web_manager.get_state(), indent=2, default=str)
|
| 671 |
+
except Exception:
|
| 672 |
+
return ""
|
| 673 |
+
|
| 674 |
+
async def quick_step(entries, actions, choice):
|
| 675 |
+
action = actions[int(choice)]
|
| 676 |
+
return await run_step(action, f"step({_call_args(action)})", entries)
|
| 677 |
+
|
| 678 |
+
outputs = [visual, quick, quick_state, result, raw_json, episode, entries_state]
|
| 679 |
+
reset_outputs = [
|
| 680 |
+
visual,
|
| 681 |
+
quick,
|
| 682 |
+
quick_state,
|
| 683 |
+
reset_result,
|
| 684 |
+
raw_json,
|
| 685 |
+
episode,
|
| 686 |
+
entries_state,
|
| 687 |
+
result,
|
| 688 |
+
]
|
| 689 |
+
# One call to the environment at a time, so a slow reset can't overlap a step.
|
| 690 |
+
busy = dict(concurrency_id="env", concurrency_limit=1, trigger_mode="once")
|
| 691 |
reset_btn.click(
|
| 692 |
+
reset_env, inputs=[entries_state], outputs=reset_outputs, **busy
|
| 693 |
+
).then(show_state, outputs=state_json)
|
| 694 |
+
# Enter submits a one-line text field, Shift+Enter a multi-line one.
|
| 695 |
+
gr.on(
|
| 696 |
+
[step_btn.click]
|
| 697 |
+
+ [i.submit for i in step_inputs if isinstance(i, gr.Textbox)],
|
| 698 |
+
step_fn,
|
| 699 |
inputs=step_inputs,
|
| 700 |
+
outputs=outputs,
|
| 701 |
+
**busy,
|
| 702 |
+
).then(show_state, outputs=state_json)
|
| 703 |
+
quick.input(
|
| 704 |
+
quick_step,
|
| 705 |
+
inputs=[entries_state, quick_state, quick],
|
| 706 |
+
outputs=outputs,
|
| 707 |
+
**busy,
|
| 708 |
+
).then(show_state, outputs=state_json)
|
|
|
|
|
|
|
|
|
|
| 709 |
|
| 710 |
return demo
|
src/core/env_server/http_server.py
CHANGED
|
@@ -1,8 +1,4 @@
|
|
| 1 |
-
#
|
| 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.
|
|
@@ -22,9 +18,20 @@ import time
|
|
| 22 |
import uuid
|
| 23 |
from concurrent.futures import ThreadPoolExecutor
|
| 24 |
from contextlib import AsyncExitStack
|
| 25 |
-
from typing import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
|
| 27 |
_MISSING = object()
|
|
|
|
| 28 |
|
| 29 |
from fastapi import (
|
| 30 |
Body,
|
|
@@ -37,12 +44,17 @@ from fastapi import (
|
|
| 37 |
)
|
| 38 |
from pydantic import ValidationError
|
| 39 |
|
|
|
|
| 40 |
from .interfaces import Environment
|
| 41 |
from .mcp_environment import get_server_tools
|
| 42 |
from .mcp_types import (
|
|
|
|
|
|
|
| 43 |
JsonRpcErrorCode,
|
| 44 |
JsonRpcRequest,
|
| 45 |
JsonRpcResponse,
|
|
|
|
|
|
|
| 46 |
McpMethod,
|
| 47 |
WSMCPMessage,
|
| 48 |
WSMCPResponse,
|
|
@@ -53,8 +65,12 @@ from .types import (
|
|
| 53 |
Action,
|
| 54 |
ConcurrencyConfig,
|
| 55 |
EnvironmentMetadata,
|
|
|
|
|
|
|
| 56 |
HealthResponse,
|
| 57 |
HealthStatus,
|
|
|
|
|
|
|
| 58 |
Observation,
|
| 59 |
ResetRequest,
|
| 60 |
ResetResponse,
|
|
@@ -83,10 +99,11 @@ def _make_json_serializable(obj: Any) -> Any:
|
|
| 83 |
Handles Pydantic models, dataclasses, and other common types.
|
| 84 |
|
| 85 |
Args:
|
| 86 |
-
obj
|
|
|
|
| 87 |
|
| 88 |
Returns:
|
| 89 |
-
A JSON-serializable representation of the object
|
| 90 |
"""
|
| 91 |
if obj is None:
|
| 92 |
return None
|
|
@@ -106,6 +123,13 @@ def _make_json_serializable(obj: Any) -> Any:
|
|
| 106 |
return str(obj)
|
| 107 |
|
| 108 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 109 |
from .exceptions import (
|
| 110 |
ConcurrencyConfigurationError,
|
| 111 |
EnvironmentFactoryError,
|
|
@@ -124,23 +148,26 @@ class HTTPEnvServer:
|
|
| 124 |
- Action deserialization: Converts JSON dict to Action subclass
|
| 125 |
- Observation serialization: Converts Observation subclass to JSON dict
|
| 126 |
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
|
|
|
|
|
|
|
|
|
| 144 |
"""
|
| 145 |
|
| 146 |
def __init__(
|
|
@@ -150,24 +177,42 @@ class HTTPEnvServer:
|
|
| 150 |
observation_cls: Type[Observation],
|
| 151 |
max_concurrent_envs: Optional[int] = None,
|
| 152 |
concurrency_config: Optional[ConcurrencyConfig] = None,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 153 |
):
|
| 154 |
"""
|
| 155 |
Initialize HTTP server wrapper.
|
| 156 |
|
| 157 |
Args:
|
| 158 |
-
env
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 166 |
|
| 167 |
Raises:
|
| 168 |
-
ValueError: If both max_concurrent_envs and concurrency_config are provided.
|
| 169 |
-
ConcurrencyConfigurationError: If max_concurrent_envs > 1 for an
|
| 170 |
-
|
| 171 |
"""
|
| 172 |
# Validate that env is callable
|
| 173 |
if not callable(env):
|
|
@@ -206,18 +251,28 @@ class HTTPEnvServer:
|
|
| 206 |
|
| 207 |
self.action_cls = action_cls
|
| 208 |
self.observation_cls = observation_cls
|
|
|
|
|
|
|
|
|
|
| 209 |
|
| 210 |
# Session management for WebSocket connections
|
| 211 |
self._sessions: Dict[str, Optional[Environment]] = {}
|
| 212 |
self._session_executors: Dict[str, ThreadPoolExecutor] = {}
|
| 213 |
self._session_stacks: Dict[str, AsyncExitStack] = {}
|
| 214 |
self._session_info: Dict[str, SessionInfo] = {}
|
|
|
|
|
|
|
| 215 |
self._session_lock = asyncio.Lock()
|
| 216 |
|
| 217 |
# Create thread pool for running sync code in async context
|
| 218 |
# This is needed for environments using sync libraries (e.g., Playwright)
|
| 219 |
self._executor = ThreadPoolExecutor(max_workers=32)
|
| 220 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 221 |
# Idle session reaper configuration.
|
| 222 |
# Timeout is taken from ConcurrencyConfig.session_timeout;
|
| 223 |
# None means no timeout (default — reaper is a no-op).
|
|
@@ -226,19 +281,31 @@ class HTTPEnvServer:
|
|
| 226 |
)
|
| 227 |
self._reaper_task: Optional[asyncio.Task[None]] = None
|
| 228 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 229 |
def _validate_concurrency_safety(self) -> None:
|
| 230 |
"""
|
| 231 |
Validate that the environment supports the configured concurrency level.
|
| 232 |
|
| 233 |
Raises:
|
| 234 |
-
ConcurrencyConfigurationError: If max_concurrent_envs > 1 for an
|
| 235 |
-
|
| 236 |
"""
|
|
|
|
|
|
|
| 237 |
if self._max_concurrent_envs <= 1:
|
| 238 |
return
|
| 239 |
|
| 240 |
-
|
| 241 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 242 |
else:
|
| 243 |
_temp_env = self._env_factory()
|
| 244 |
env_cls = type(_temp_env)
|
|
@@ -251,12 +318,22 @@ class HTTPEnvServer:
|
|
| 251 |
max_concurrent_envs=self._max_concurrent_envs,
|
| 252 |
)
|
| 253 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 254 |
def get_capacity_status(self) -> ServerCapacityStatus:
|
| 255 |
"""
|
| 256 |
Get the current capacity status of the server.
|
| 257 |
|
| 258 |
Returns:
|
| 259 |
-
ServerCapacityStatus with current session counts and availability.
|
| 260 |
"""
|
| 261 |
return ServerCapacityStatus.from_counts(
|
| 262 |
active=len(self._sessions),
|
|
@@ -298,11 +375,11 @@ class HTTPEnvServer:
|
|
| 298 |
Create a new WebSocket session with its own environment instance.
|
| 299 |
|
| 300 |
Returns:
|
| 301 |
-
Tuple of (session_id, environment)
|
| 302 |
|
| 303 |
Raises:
|
| 304 |
-
SessionCapacityError: If max concurrent sessions reached
|
| 305 |
-
EnvironmentFactoryError: If the factory fails to create an environment
|
| 306 |
"""
|
| 307 |
async with self._session_lock:
|
| 308 |
if len(self._sessions) >= self._max_concurrent_envs:
|
|
@@ -316,7 +393,10 @@ class HTTPEnvServer:
|
|
| 316 |
|
| 317 |
# Create executor and reserve slot so capacity is not exceeded while
|
| 318 |
# we create the env outside the lock (avoids blocking other sessions)
|
| 319 |
-
|
|
|
|
|
|
|
|
|
|
| 320 |
self._session_executors[session_id] = executor
|
| 321 |
self._sessions[session_id] = None # placeholder until env is ready
|
| 322 |
|
|
@@ -326,7 +406,8 @@ class HTTPEnvServer:
|
|
| 326 |
env = await loop.run_in_executor(executor, self._env_factory)
|
| 327 |
except Exception as e:
|
| 328 |
async with self._session_lock:
|
| 329 |
-
executor.
|
|
|
|
| 330 |
self._session_executors.pop(session_id, None)
|
| 331 |
self._sessions.pop(session_id, None)
|
| 332 |
factory_name = getattr(
|
|
@@ -376,13 +457,16 @@ class HTTPEnvServer:
|
|
| 376 |
Destroy a WebSocket session and cleanup resources.
|
| 377 |
|
| 378 |
Args:
|
| 379 |
-
session_id
|
|
|
|
| 380 |
"""
|
| 381 |
async with self._session_lock:
|
| 382 |
env = self._sessions.pop(session_id, None)
|
| 383 |
executor = self._session_executors.pop(session_id, None)
|
| 384 |
stack = self._session_stacks.pop(session_id, None)
|
| 385 |
self._session_info.pop(session_id, None)
|
|
|
|
|
|
|
| 386 |
|
| 387 |
await self._cleanup_session_resources(env, executor, stack)
|
| 388 |
|
|
@@ -421,8 +505,7 @@ class HTTPEnvServer:
|
|
| 421 |
except Exception:
|
| 422 |
pass # Best effort cleanup
|
| 423 |
|
| 424 |
-
|
| 425 |
-
if executor is not None:
|
| 426 |
executor.shutdown(wait=False)
|
| 427 |
|
| 428 |
def _update_session_activity(
|
|
@@ -432,8 +515,10 @@ class HTTPEnvServer:
|
|
| 432 |
Update session activity timestamp and optionally increment step count.
|
| 433 |
|
| 434 |
Args:
|
| 435 |
-
session_id
|
| 436 |
-
|
|
|
|
|
|
|
| 437 |
"""
|
| 438 |
if session_id in self._session_info:
|
| 439 |
self._session_info[session_id].last_activity_at = time.time()
|
|
@@ -453,7 +538,10 @@ class HTTPEnvServer:
|
|
| 453 |
stale_ids: list[str] = []
|
| 454 |
async with self._session_lock:
|
| 455 |
for sid, info in self._session_info.items():
|
| 456 |
-
if
|
|
|
|
|
|
|
|
|
|
| 457 |
stale_ids.append(sid)
|
| 458 |
for sid in stale_ids:
|
| 459 |
# Re-check under lock: activity may have arrived since
|
|
@@ -463,7 +551,11 @@ class HTTPEnvServer:
|
|
| 463 |
now = time.time()
|
| 464 |
async with self._session_lock:
|
| 465 |
info = self._session_info.get(sid)
|
| 466 |
-
if
|
|
|
|
|
|
|
|
|
|
|
|
|
| 467 |
continue
|
| 468 |
await self._destroy_session(sid)
|
| 469 |
except asyncio.CancelledError:
|
|
@@ -490,10 +582,11 @@ class HTTPEnvServer:
|
|
| 490 |
Get information about a specific session.
|
| 491 |
|
| 492 |
Args:
|
| 493 |
-
session_id
|
|
|
|
| 494 |
|
| 495 |
Returns:
|
| 496 |
-
SessionInfo if the session exists, None otherwise
|
| 497 |
"""
|
| 498 |
return self._session_info.get(session_id)
|
| 499 |
|
|
@@ -505,6 +598,25 @@ class HTTPEnvServer:
|
|
| 505 |
loop = asyncio.get_event_loop()
|
| 506 |
return await loop.run_in_executor(executor, lambda: func(*args, **kwargs))
|
| 507 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 508 |
@property
|
| 509 |
def active_sessions(self) -> int:
|
| 510 |
"""Return the number of active WebSocket sessions."""
|
|
@@ -534,6 +646,212 @@ class HTTPEnvServer:
|
|
| 534 |
"""Return the concurrency configuration."""
|
| 535 |
return self._concurrency_config
|
| 536 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 537 |
def register_routes(
|
| 538 |
self, app: FastAPI, mode: ServerMode | str = ServerMode.SIMULATION
|
| 539 |
) -> None:
|
|
@@ -541,14 +859,15 @@ class HTTPEnvServer:
|
|
| 541 |
Register HTTP routes on a FastAPI application.
|
| 542 |
|
| 543 |
Args:
|
| 544 |
-
app
|
| 545 |
-
|
| 546 |
-
|
| 547 |
-
|
| 548 |
-
|
|
|
|
| 549 |
|
| 550 |
Raises:
|
| 551 |
-
ValueError: If mode is not a valid ServerMode or string equivalent.
|
| 552 |
"""
|
| 553 |
# Convert string to ServerMode enum for backwards compatibility
|
| 554 |
if isinstance(mode, str):
|
|
@@ -568,6 +887,8 @@ class HTTPEnvServer:
|
|
| 568 |
|
| 569 |
async def _stop_session_reaper() -> None:
|
| 570 |
server_ref._stop_reaper()
|
|
|
|
|
|
|
| 571 |
|
| 572 |
if not getattr(app.router, "_openenv_reaper_registered", False):
|
| 573 |
app.router.on_startup.append(_start_session_reaper)
|
|
@@ -584,7 +905,7 @@ class HTTPEnvServer:
|
|
| 584 |
try:
|
| 585 |
kwargs = request.model_dump(exclude_unset=True)
|
| 586 |
|
| 587 |
-
is_async = _env.reset_async
|
| 588 |
|
| 589 |
if is_async:
|
| 590 |
sig = inspect.signature(_env.reset_async)
|
|
@@ -619,7 +940,7 @@ class HTTPEnvServer:
|
|
| 619 |
try:
|
| 620 |
kwargs = request.model_dump(exclude_unset=True, exclude={"action"})
|
| 621 |
|
| 622 |
-
is_async = _env.step_async
|
| 623 |
|
| 624 |
if is_async:
|
| 625 |
sig = inspect.signature(_env.step_async)
|
|
@@ -650,9 +971,8 @@ class HTTPEnvServer:
|
|
| 650 |
Handle MCP JSON-RPC requests.
|
| 651 |
|
| 652 |
Supports tools/list and tools/call methods in JSON-RPC 2.0 format,
|
| 653 |
-
plus OpenEnv session lifecycle methods for HTTP MCP
|
| 654 |
-
|
| 655 |
-
- openenv/session/close
|
| 656 |
"""
|
| 657 |
method = request.method
|
| 658 |
request_id = request.id
|
|
@@ -713,15 +1033,33 @@ class HTTPEnvServer:
|
|
| 713 |
)
|
| 714 |
|
| 715 |
async with self._session_lock:
|
| 716 |
-
|
| 717 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 718 |
executor = self._session_executors.pop(target_session_id, None)
|
| 719 |
stack = self._session_stacks.pop(target_session_id, None)
|
| 720 |
self._session_info.pop(target_session_id, None)
|
| 721 |
-
|
| 722 |
executor = None
|
| 723 |
stack = None
|
| 724 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 725 |
if env is _MISSING:
|
| 726 |
return JsonRpcResponse.error_response(
|
| 727 |
JsonRpcErrorCode.INVALID_PARAMS,
|
|
@@ -786,9 +1124,45 @@ class HTTPEnvServer:
|
|
| 786 |
mcp_server = getattr(_env, "mcp_server", None)
|
| 787 |
mcp_session_factory = getattr(_env, "mcp_session", None)
|
| 788 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 789 |
if method == McpMethod.TOOLS_LIST:
|
| 790 |
# Check if environment is MCP-enabled
|
| 791 |
if mcp_client is None and mcp_server is None:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 792 |
return JsonRpcResponse.error_response(
|
| 793 |
JsonRpcErrorCode.INTERNAL_ERROR,
|
| 794 |
"Environment does not support MCP",
|
|
@@ -796,21 +1170,12 @@ class HTTPEnvServer:
|
|
| 796 |
)
|
| 797 |
|
| 798 |
if mcp_client:
|
| 799 |
-
|
| 800 |
-
|
| 801 |
-
|
| 802 |
-
|
| 803 |
-
|
| 804 |
-
|
| 805 |
-
# background transport was lost: (re-)open.
|
| 806 |
-
mcp_session_cm = cast(
|
| 807 |
-
AsyncContextManager[Any], mcp_session_factory()
|
| 808 |
-
)
|
| 809 |
-
async with mcp_session_cm:
|
| 810 |
-
tools = await mcp_client.list_tools()
|
| 811 |
-
else:
|
| 812 |
-
async with mcp_client:
|
| 813 |
-
tools = await mcp_client.list_tools()
|
| 814 |
|
| 815 |
return JsonRpcResponse.success(
|
| 816 |
result={
|
|
@@ -849,41 +1214,48 @@ class HTTPEnvServer:
|
|
| 849 |
tool_name = params.get("name")
|
| 850 |
arguments = params.get("arguments", {})
|
| 851 |
|
| 852 |
-
if
|
| 853 |
return JsonRpcResponse.error_response(
|
| 854 |
-
JsonRpcErrorCode.
|
| 855 |
-
"
|
| 856 |
request_id=request_id,
|
| 857 |
)
|
| 858 |
|
| 859 |
-
if
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 860 |
return JsonRpcResponse.error_response(
|
| 861 |
-
JsonRpcErrorCode.
|
| 862 |
-
"
|
| 863 |
request_id=request_id,
|
| 864 |
)
|
| 865 |
|
| 866 |
if mcp_client:
|
| 867 |
-
|
| 868 |
-
|
| 869 |
-
|
|
|
|
|
|
|
| 870 |
name=tool_name, arguments=arguments
|
| 871 |
-
)
|
| 872 |
-
|
| 873 |
-
# Stateless request, or session-managed but the
|
| 874 |
-
# background transport was lost: (re-)open.
|
| 875 |
-
mcp_session_cm = cast(
|
| 876 |
-
AsyncContextManager[Any], mcp_session_factory()
|
| 877 |
-
)
|
| 878 |
-
async with mcp_session_cm:
|
| 879 |
-
result = await mcp_client.call_tool(
|
| 880 |
-
name=tool_name, arguments=arguments
|
| 881 |
-
)
|
| 882 |
-
else:
|
| 883 |
-
async with mcp_client:
|
| 884 |
-
result = await mcp_client.call_tool(
|
| 885 |
-
name=tool_name, arguments=arguments
|
| 886 |
-
)
|
| 887 |
elif mcp_server:
|
| 888 |
server_tools = get_server_tools(mcp_server)
|
| 889 |
if tool_name in server_tools:
|
|
@@ -935,6 +1307,96 @@ class HTTPEnvServer:
|
|
| 935 |
if should_close:
|
| 936 |
_env.close()
|
| 937 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 938 |
# Register MCP WebSocket endpoint (available in both production and simulation modes)
|
| 939 |
@app.websocket("/mcp")
|
| 940 |
async def mcp_websocket_endpoint(websocket: WebSocket):
|
|
@@ -942,10 +1404,8 @@ class HTTPEnvServer:
|
|
| 942 |
WebSocket endpoint for MCP JSON-RPC requests.
|
| 943 |
|
| 944 |
Each WebSocket connection gets its own environment instance for MCP operations.
|
| 945 |
-
|
| 946 |
-
|
| 947 |
-
- Client sends: JSON-RPC 2.0 request (tools/list, tools/call)
|
| 948 |
-
- Server responds: JSON-RPC 2.0 response (result or error)
|
| 949 |
"""
|
| 950 |
await websocket.accept()
|
| 951 |
|
|
@@ -1039,9 +1499,14 @@ class HTTPEnvServer:
|
|
| 1039 |
await self._destroy_session(session_id)
|
| 1040 |
try:
|
| 1041 |
await websocket.close()
|
| 1042 |
-
except RuntimeError:
|
| 1043 |
pass
|
| 1044 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1045 |
# Register simulation control routes only in simulation mode
|
| 1046 |
if mode == ServerMode.SIMULATION:
|
| 1047 |
|
|
@@ -1176,7 +1641,7 @@ version, author, and documentation links.
|
|
| 1176 |
GetEndpointConfig(
|
| 1177 |
path="/state",
|
| 1178 |
handler=get_state_handler,
|
| 1179 |
-
response_model=
|
| 1180 |
tag="State Management",
|
| 1181 |
summary="Get current environment state",
|
| 1182 |
description="""
|
|
@@ -1200,7 +1665,8 @@ Get JSON schemas for actions, observations, and state in a single response.
|
|
| 1200 |
|
| 1201 |
Returns a combined schema object containing:
|
| 1202 |
- **action**: JSON schema for actions accepted by this environment
|
| 1203 |
-
- **observation**: JSON schema for observations returned by
|
|
|
|
| 1204 |
- **state**: JSON schema for environment state objects
|
| 1205 |
|
| 1206 |
This is more efficient than calling individual schema endpoints and provides
|
|
@@ -1235,14 +1701,14 @@ all schema information needed to interact with the environment.
|
|
| 1235 |
return SchemaResponse(
|
| 1236 |
action=self.action_cls.model_json_schema(),
|
| 1237 |
observation=self.observation_cls.model_json_schema(),
|
| 1238 |
-
|
|
|
|
| 1239 |
)
|
| 1240 |
|
| 1241 |
# Register MCP endpoint for production mode (direct MCP access)
|
| 1242 |
@app.post("/mcp")
|
| 1243 |
async def mcp_endpoint(request_raw: Request) -> Dict[str, Any]:
|
| 1244 |
-
"""
|
| 1245 |
-
MCP JSON-RPC endpoint for production mode.
|
| 1246 |
|
| 1247 |
Bypasses step() overhead and provides direct access to MCP tools.
|
| 1248 |
Supports tools/list and tools/call methods.
|
|
@@ -1275,20 +1741,48 @@ all schema information needed to interact with the environment.
|
|
| 1275 |
"""
|
| 1276 |
WebSocket endpoint for persistent environment sessions.
|
| 1277 |
|
| 1278 |
-
|
| 1279 |
-
|
| 1280 |
-
|
| 1281 |
-
|
| 1282 |
-
|
| 1283 |
"""
|
| 1284 |
await websocket.accept()
|
| 1285 |
|
| 1286 |
session_id = None
|
| 1287 |
session_env = None
|
|
|
|
|
|
|
| 1288 |
|
| 1289 |
try:
|
| 1290 |
-
|
| 1291 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1292 |
if session_env is None:
|
| 1293 |
raise RuntimeError(
|
| 1294 |
"Session environment not initialized for websocket"
|
|
@@ -1299,7 +1793,7 @@ all schema information needed to interact with the environment.
|
|
| 1299 |
|
| 1300 |
async with AsyncExitStack() as stack:
|
| 1301 |
mcp_session_factory = getattr(session_env, "mcp_session", None)
|
| 1302 |
-
if callable(mcp_session_factory):
|
| 1303 |
mcp_session_cm = cast(
|
| 1304 |
AsyncContextManager[Any], mcp_session_factory()
|
| 1305 |
)
|
|
@@ -1328,9 +1822,9 @@ all schema information needed to interact with the environment.
|
|
| 1328 |
case "reset":
|
| 1329 |
msg = WSResetMessage(**message_dict)
|
| 1330 |
|
| 1331 |
-
is_async = (
|
| 1332 |
-
session_env.reset_async
|
| 1333 |
-
|
| 1334 |
)
|
| 1335 |
|
| 1336 |
if is_async:
|
|
@@ -1366,9 +1860,9 @@ all schema information needed to interact with the environment.
|
|
| 1366 |
msg.data, self.action_cls
|
| 1367 |
)
|
| 1368 |
|
| 1369 |
-
is_async = (
|
| 1370 |
-
session_env.step_async
|
| 1371 |
-
|
| 1372 |
)
|
| 1373 |
|
| 1374 |
if is_async:
|
|
@@ -1478,11 +1972,18 @@ all schema information needed to interact with the environment.
|
|
| 1478 |
)
|
| 1479 |
await websocket.send_text(error_resp.model_dump_json())
|
| 1480 |
finally:
|
| 1481 |
-
if session_id:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1482 |
await self._destroy_session(session_id)
|
| 1483 |
try:
|
| 1484 |
await websocket.close()
|
| 1485 |
-
except RuntimeError:
|
| 1486 |
pass
|
| 1487 |
|
| 1488 |
|
|
@@ -1494,6 +1995,14 @@ def create_app(
|
|
| 1494 |
max_concurrent_envs: Optional[int] = None,
|
| 1495 |
concurrency_config: Optional[ConcurrencyConfig] = None,
|
| 1496 |
gradio_builder: Optional[Callable[..., Any]] = None,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1497 |
) -> FastAPI:
|
| 1498 |
"""
|
| 1499 |
Create a FastAPI application with or without web interface.
|
|
@@ -1502,21 +2011,48 @@ def create_app(
|
|
| 1502 |
including README integration for better user experience.
|
| 1503 |
|
| 1504 |
Args:
|
| 1505 |
-
env
|
| 1506 |
-
|
| 1507 |
-
|
| 1508 |
-
|
| 1509 |
-
|
| 1510 |
-
|
| 1511 |
-
|
| 1512 |
-
|
| 1513 |
-
|
| 1514 |
-
|
| 1515 |
-
|
| 1516 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1517 |
|
| 1518 |
Returns:
|
| 1519 |
-
FastAPI application instance with or without web interface and README integration
|
| 1520 |
"""
|
| 1521 |
# Check if web interface should be enabled
|
| 1522 |
# This can be controlled via environment variable or build argument
|
|
@@ -1537,12 +2073,27 @@ def create_app(
|
|
| 1537 |
env_name,
|
| 1538 |
max_concurrent_envs,
|
| 1539 |
concurrency_config,
|
|
|
|
|
|
|
| 1540 |
gradio_builder=gradio_builder,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1541 |
)
|
| 1542 |
else:
|
| 1543 |
# Use standard FastAPI app without web interface
|
| 1544 |
return create_fastapi_app(
|
| 1545 |
-
env,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1546 |
)
|
| 1547 |
|
| 1548 |
|
|
@@ -1552,21 +2103,42 @@ def create_fastapi_app(
|
|
| 1552 |
observation_cls: Type[Observation],
|
| 1553 |
max_concurrent_envs: Optional[int] = None,
|
| 1554 |
concurrency_config: Optional[ConcurrencyConfig] = None,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1555 |
) -> FastAPI:
|
| 1556 |
"""
|
| 1557 |
Create a FastAPI application with comprehensive documentation.
|
| 1558 |
|
| 1559 |
Args:
|
| 1560 |
-
env
|
| 1561 |
-
|
| 1562 |
-
|
| 1563 |
-
|
| 1564 |
-
|
| 1565 |
-
|
| 1566 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1567 |
|
| 1568 |
Returns:
|
| 1569 |
-
FastAPI application instance
|
| 1570 |
"""
|
| 1571 |
try:
|
| 1572 |
from fastapi import FastAPI
|
|
@@ -1627,11 +2199,11 @@ HTTP API for interacting with OpenEnv environments through a standardized interf
|
|
| 1627 |
openapi_url="/openapi.json",
|
| 1628 |
contact={
|
| 1629 |
"name": "OpenEnv Team",
|
| 1630 |
-
"url": "https://github.com/
|
| 1631 |
},
|
| 1632 |
license_info={
|
| 1633 |
"name": "BSD-3-Clause",
|
| 1634 |
-
"url": "https://github.com/
|
| 1635 |
},
|
| 1636 |
)
|
| 1637 |
|
|
@@ -1641,6 +2213,11 @@ HTTP API for interacting with OpenEnv environments through a standardized interf
|
|
| 1641 |
observation_cls,
|
| 1642 |
max_concurrent_envs,
|
| 1643 |
concurrency_config=concurrency_config,
|
|
|
|
|
|
|
|
|
|
| 1644 |
)
|
| 1645 |
-
|
|
|
|
|
|
|
| 1646 |
return app
|
|
|
|
| 1 |
+
# SPDX-License-Identifier: BSD-3-Clause
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
"""
|
| 4 |
HTTP server wrapper for Environment instances.
|
|
|
|
| 18 |
import uuid
|
| 19 |
from concurrent.futures import ThreadPoolExecutor
|
| 20 |
from contextlib import AsyncExitStack
|
| 21 |
+
from typing import (
|
| 22 |
+
Any,
|
| 23 |
+
AsyncContextManager,
|
| 24 |
+
Awaitable,
|
| 25 |
+
Callable,
|
| 26 |
+
cast,
|
| 27 |
+
Dict,
|
| 28 |
+
Optional,
|
| 29 |
+
Type,
|
| 30 |
+
TypeVar,
|
| 31 |
+
)
|
| 32 |
|
| 33 |
_MISSING = object()
|
| 34 |
+
_MCPResult = TypeVar("_MCPResult")
|
| 35 |
|
| 36 |
from fastapi import (
|
| 37 |
Body,
|
|
|
|
| 44 |
)
|
| 45 |
from pydantic import ValidationError
|
| 46 |
|
| 47 |
+
from ._utils import overrides_method
|
| 48 |
from .interfaces import Environment
|
| 49 |
from .mcp_environment import get_server_tools
|
| 50 |
from .mcp_types import (
|
| 51 |
+
CallToolAction,
|
| 52 |
+
CallToolObservation,
|
| 53 |
JsonRpcErrorCode,
|
| 54 |
JsonRpcRequest,
|
| 55 |
JsonRpcResponse,
|
| 56 |
+
ListToolsAction,
|
| 57 |
+
ListToolsObservation,
|
| 58 |
McpMethod,
|
| 59 |
WSMCPMessage,
|
| 60 |
WSMCPResponse,
|
|
|
|
| 65 |
Action,
|
| 66 |
ConcurrencyConfig,
|
| 67 |
EnvironmentMetadata,
|
| 68 |
+
GetTaskRangeRequest,
|
| 69 |
+
GetTaskRequest,
|
| 70 |
HealthResponse,
|
| 71 |
HealthStatus,
|
| 72 |
+
ListTasksRequest,
|
| 73 |
+
NumTasksRequest,
|
| 74 |
Observation,
|
| 75 |
ResetRequest,
|
| 76 |
ResetResponse,
|
|
|
|
| 99 |
Handles Pydantic models, dataclasses, and other common types.
|
| 100 |
|
| 101 |
Args:
|
| 102 |
+
obj (`Any`):
|
| 103 |
+
The object to convert.
|
| 104 |
|
| 105 |
Returns:
|
| 106 |
+
`Any`: A JSON-serializable representation of the object.
|
| 107 |
"""
|
| 108 |
if obj is None:
|
| 109 |
return None
|
|
|
|
| 123 |
return str(obj)
|
| 124 |
|
| 125 |
|
| 126 |
+
async def _maybe_await(value: Any) -> Any:
|
| 127 |
+
"""Await values returned by async task APIs while preserving sync APIs."""
|
| 128 |
+
if inspect.isawaitable(value):
|
| 129 |
+
return await value
|
| 130 |
+
return value
|
| 131 |
+
|
| 132 |
+
|
| 133 |
from .exceptions import (
|
| 134 |
ConcurrencyConfigurationError,
|
| 135 |
EnvironmentFactoryError,
|
|
|
|
| 148 |
- Action deserialization: Converts JSON dict to Action subclass
|
| 149 |
- Observation serialization: Converts Observation subclass to JSON dict
|
| 150 |
|
| 151 |
+
Examples:
|
| 152 |
+
|
| 153 |
+
```python
|
| 154 |
+
from core.env_server import HTTPEnvServer
|
| 155 |
+
from envs.coding_env.server import CodeExecutionEnvironment
|
| 156 |
+
from envs.coding_env.models import CodeAction, CodeObservation
|
| 157 |
+
|
| 158 |
+
# Pass environment class (factory pattern)
|
| 159 |
+
server = HTTPEnvServer(
|
| 160 |
+
env=CodeExecutionEnvironment,
|
| 161 |
+
action_cls=CodeAction,
|
| 162 |
+
observation_cls=CodeObservation,
|
| 163 |
+
max_concurrent_envs=4,
|
| 164 |
+
)
|
| 165 |
+
|
| 166 |
+
# Register routes with FastAPI
|
| 167 |
+
from fastapi import FastAPI
|
| 168 |
+
app = FastAPI()
|
| 169 |
+
server.register_routes(app)
|
| 170 |
+
```
|
| 171 |
"""
|
| 172 |
|
| 173 |
def __init__(
|
|
|
|
| 177 |
observation_cls: Type[Observation],
|
| 178 |
max_concurrent_envs: Optional[int] = None,
|
| 179 |
concurrency_config: Optional[ConcurrencyConfig] = None,
|
| 180 |
+
env_name: Optional[str] = None,
|
| 181 |
+
state_cls: Type[State] = State,
|
| 182 |
+
*,
|
| 183 |
+
reset_observation_cls: Optional[Type[Observation]] = None,
|
| 184 |
):
|
| 185 |
"""
|
| 186 |
Initialize HTTP server wrapper.
|
| 187 |
|
| 188 |
Args:
|
| 189 |
+
env (`Callable[[], Environment]`):
|
| 190 |
+
Environment factory (callable) that creates new instances. Will be called
|
| 191 |
+
to create a new environment for each WebSocket session.
|
| 192 |
+
action_cls (`Type[Action]`):
|
| 193 |
+
The `Action` subclass this environment expects.
|
| 194 |
+
observation_cls (`Type[Observation]`):
|
| 195 |
+
The `Observation` subclass returned by step.
|
| 196 |
+
reset_observation_cls (`Type[Observation]`, *optional*):
|
| 197 |
+
The reset observation model published in `/schema`. Defaults to
|
| 198 |
+
`observation_cls`; declare a distinct model when reset differs.
|
| 199 |
+
max_concurrent_envs (`int`, *optional*):
|
| 200 |
+
Maximum number of concurrent WebSocket sessions. Mutually exclusive with
|
| 201 |
+
`concurrency_config`.
|
| 202 |
+
concurrency_config (`ConcurrencyConfig`, *optional*):
|
| 203 |
+
Advanced concurrency settings. Mutually exclusive with
|
| 204 |
+
`max_concurrent_envs`.
|
| 205 |
+
env_name (`str`, *optional*):
|
| 206 |
+
Public environment name used by task/split endpoints.
|
| 207 |
+
state_cls (`Type[State]`, *optional*, defaults to `State`):
|
| 208 |
+
The `State` subclass this environment reports. Used for the `/state`
|
| 209 |
+
response model and the `state` entry of `/schema`, so that fields
|
| 210 |
+
declared by the subclass are published and serialized.
|
| 211 |
|
| 212 |
Raises:
|
| 213 |
+
`ValueError`: If both `max_concurrent_envs` and `concurrency_config` are provided.
|
| 214 |
+
`ConcurrencyConfigurationError`: If `max_concurrent_envs` > 1 for an environment
|
| 215 |
+
that is not marked as `SUPPORTS_CONCURRENT_SESSIONS`.
|
| 216 |
"""
|
| 217 |
# Validate that env is callable
|
| 218 |
if not callable(env):
|
|
|
|
| 251 |
|
| 252 |
self.action_cls = action_cls
|
| 253 |
self.observation_cls = observation_cls
|
| 254 |
+
self.reset_observation_cls = reset_observation_cls or observation_cls
|
| 255 |
+
self.state_cls = state_cls
|
| 256 |
+
self.env_name = env_name or self._default_env_name()
|
| 257 |
|
| 258 |
# Session management for WebSocket connections
|
| 259 |
self._sessions: Dict[str, Optional[Environment]] = {}
|
| 260 |
self._session_executors: Dict[str, ThreadPoolExecutor] = {}
|
| 261 |
self._session_stacks: Dict[str, AsyncExitStack] = {}
|
| 262 |
self._session_info: Dict[str, SessionInfo] = {}
|
| 263 |
+
self._session_websocket_attachments: set[str] = set()
|
| 264 |
+
self._session_pending_closes: set[str] = set()
|
| 265 |
self._session_lock = asyncio.Lock()
|
| 266 |
|
| 267 |
# Create thread pool for running sync code in async context
|
| 268 |
# This is needed for environments using sync libraries (e.g., Playwright)
|
| 269 |
self._executor = ThreadPoolExecutor(max_workers=32)
|
| 270 |
|
| 271 |
+
self._requires_single_thread_executor = self._detect_single_thread_requirement()
|
| 272 |
+
self._shared_session_executor: Optional[ThreadPoolExecutor] = None
|
| 273 |
+
if self._requires_single_thread_executor:
|
| 274 |
+
self._shared_session_executor = ThreadPoolExecutor(max_workers=1)
|
| 275 |
+
|
| 276 |
# Idle session reaper configuration.
|
| 277 |
# Timeout is taken from ConcurrencyConfig.session_timeout;
|
| 278 |
# None means no timeout (default — reaper is a no-op).
|
|
|
|
| 281 |
)
|
| 282 |
self._reaper_task: Optional[asyncio.Task[None]] = None
|
| 283 |
|
| 284 |
+
def _default_env_name(self) -> str:
|
| 285 |
+
factory = self._env_factory
|
| 286 |
+
if inspect.isclass(factory):
|
| 287 |
+
return factory.__name__
|
| 288 |
+
return getattr(factory, "__name__", "environment")
|
| 289 |
+
|
| 290 |
def _validate_concurrency_safety(self) -> None:
|
| 291 |
"""
|
| 292 |
Validate that the environment supports the configured concurrency level.
|
| 293 |
|
| 294 |
Raises:
|
| 295 |
+
`ConcurrencyConfigurationError`: If `max_concurrent_envs` > 1 for an environment
|
| 296 |
+
that is not marked as `SUPPORTS_CONCURRENT_SESSIONS`.
|
| 297 |
"""
|
| 298 |
+
import functools
|
| 299 |
+
|
| 300 |
if self._max_concurrent_envs <= 1:
|
| 301 |
return
|
| 302 |
|
| 303 |
+
factory = self._env_factory
|
| 304 |
+
if isinstance(factory, functools.partial):
|
| 305 |
+
factory = factory.func
|
| 306 |
+
|
| 307 |
+
if inspect.isclass(factory):
|
| 308 |
+
env_cls = factory
|
| 309 |
else:
|
| 310 |
_temp_env = self._env_factory()
|
| 311 |
env_cls = type(_temp_env)
|
|
|
|
| 318 |
max_concurrent_envs=self._max_concurrent_envs,
|
| 319 |
)
|
| 320 |
|
| 321 |
+
def _detect_single_thread_requirement(self) -> bool:
|
| 322 |
+
import functools
|
| 323 |
+
|
| 324 |
+
factory = self._env_factory
|
| 325 |
+
if isinstance(factory, functools.partial):
|
| 326 |
+
factory = factory.func
|
| 327 |
+
if inspect.isclass(factory):
|
| 328 |
+
return getattr(factory, "REQUIRES_SINGLE_THREAD_EXECUTOR", False)
|
| 329 |
+
return False
|
| 330 |
+
|
| 331 |
def get_capacity_status(self) -> ServerCapacityStatus:
|
| 332 |
"""
|
| 333 |
Get the current capacity status of the server.
|
| 334 |
|
| 335 |
Returns:
|
| 336 |
+
[`ServerCapacityStatus`] with current session counts and availability.
|
| 337 |
"""
|
| 338 |
return ServerCapacityStatus.from_counts(
|
| 339 |
active=len(self._sessions),
|
|
|
|
| 375 |
Create a new WebSocket session with its own environment instance.
|
| 376 |
|
| 377 |
Returns:
|
| 378 |
+
`tuple[str, Environment]`: Tuple of (session_id, environment).
|
| 379 |
|
| 380 |
Raises:
|
| 381 |
+
`SessionCapacityError`: If max concurrent sessions reached.
|
| 382 |
+
`EnvironmentFactoryError`: If the factory fails to create an environment.
|
| 383 |
"""
|
| 384 |
async with self._session_lock:
|
| 385 |
if len(self._sessions) >= self._max_concurrent_envs:
|
|
|
|
| 393 |
|
| 394 |
# Create executor and reserve slot so capacity is not exceeded while
|
| 395 |
# we create the env outside the lock (avoids blocking other sessions)
|
| 396 |
+
if self._shared_session_executor is not None:
|
| 397 |
+
executor = self._shared_session_executor
|
| 398 |
+
else:
|
| 399 |
+
executor = ThreadPoolExecutor(max_workers=1)
|
| 400 |
self._session_executors[session_id] = executor
|
| 401 |
self._sessions[session_id] = None # placeholder until env is ready
|
| 402 |
|
|
|
|
| 406 |
env = await loop.run_in_executor(executor, self._env_factory)
|
| 407 |
except Exception as e:
|
| 408 |
async with self._session_lock:
|
| 409 |
+
if executor is not self._shared_session_executor:
|
| 410 |
+
executor.shutdown(wait=False)
|
| 411 |
self._session_executors.pop(session_id, None)
|
| 412 |
self._sessions.pop(session_id, None)
|
| 413 |
factory_name = getattr(
|
|
|
|
| 457 |
Destroy a WebSocket session and cleanup resources.
|
| 458 |
|
| 459 |
Args:
|
| 460 |
+
session_id (`str`):
|
| 461 |
+
The session ID to destroy.
|
| 462 |
"""
|
| 463 |
async with self._session_lock:
|
| 464 |
env = self._sessions.pop(session_id, None)
|
| 465 |
executor = self._session_executors.pop(session_id, None)
|
| 466 |
stack = self._session_stacks.pop(session_id, None)
|
| 467 |
self._session_info.pop(session_id, None)
|
| 468 |
+
self._session_websocket_attachments.discard(session_id)
|
| 469 |
+
self._session_pending_closes.discard(session_id)
|
| 470 |
|
| 471 |
await self._cleanup_session_resources(env, executor, stack)
|
| 472 |
|
|
|
|
| 505 |
except Exception:
|
| 506 |
pass # Best effort cleanup
|
| 507 |
|
| 508 |
+
if executor is not None and executor is not self._shared_session_executor:
|
|
|
|
| 509 |
executor.shutdown(wait=False)
|
| 510 |
|
| 511 |
def _update_session_activity(
|
|
|
|
| 515 |
Update session activity timestamp and optionally increment step count.
|
| 516 |
|
| 517 |
Args:
|
| 518 |
+
session_id (`str`):
|
| 519 |
+
The session ID to update.
|
| 520 |
+
increment_step (`bool`, *optional*, defaults to `False`):
|
| 521 |
+
If `True`, increment the step count.
|
| 522 |
"""
|
| 523 |
if session_id in self._session_info:
|
| 524 |
self._session_info[session_id].last_activity_at = time.time()
|
|
|
|
| 538 |
stale_ids: list[str] = []
|
| 539 |
async with self._session_lock:
|
| 540 |
for sid, info in self._session_info.items():
|
| 541 |
+
if (
|
| 542 |
+
sid not in self._session_websocket_attachments
|
| 543 |
+
and now - info.last_activity_at > timeout
|
| 544 |
+
):
|
| 545 |
stale_ids.append(sid)
|
| 546 |
for sid in stale_ids:
|
| 547 |
# Re-check under lock: activity may have arrived since
|
|
|
|
| 551 |
now = time.time()
|
| 552 |
async with self._session_lock:
|
| 553 |
info = self._session_info.get(sid)
|
| 554 |
+
if (
|
| 555 |
+
info is None
|
| 556 |
+
or sid in self._session_websocket_attachments
|
| 557 |
+
or (now - info.last_activity_at) <= timeout
|
| 558 |
+
):
|
| 559 |
continue
|
| 560 |
await self._destroy_session(sid)
|
| 561 |
except asyncio.CancelledError:
|
|
|
|
| 582 |
Get information about a specific session.
|
| 583 |
|
| 584 |
Args:
|
| 585 |
+
session_id (`str`):
|
| 586 |
+
The session ID to query.
|
| 587 |
|
| 588 |
Returns:
|
| 589 |
+
[`SessionInfo`] if the session exists, `None` otherwise.
|
| 590 |
"""
|
| 591 |
return self._session_info.get(session_id)
|
| 592 |
|
|
|
|
| 598 |
loop = asyncio.get_event_loop()
|
| 599 |
return await loop.run_in_executor(executor, lambda: func(*args, **kwargs))
|
| 600 |
|
| 601 |
+
async def _run_mcp_client_operation(
|
| 602 |
+
self,
|
| 603 |
+
mcp_client: Any,
|
| 604 |
+
mcp_session_factory: Any,
|
| 605 |
+
managed_session_id: Optional[str],
|
| 606 |
+
operation: Callable[[], Awaitable[_MCPResult]],
|
| 607 |
+
) -> _MCPResult:
|
| 608 |
+
"""Run an MCP client operation with the appropriate transport lifecycle."""
|
| 609 |
+
if managed_session_id and mcp_client.is_connected():
|
| 610 |
+
return await operation()
|
| 611 |
+
|
| 612 |
+
if callable(mcp_session_factory):
|
| 613 |
+
mcp_session_cm = cast(AsyncContextManager[Any], mcp_session_factory())
|
| 614 |
+
async with mcp_session_cm:
|
| 615 |
+
return await operation()
|
| 616 |
+
|
| 617 |
+
async with mcp_client:
|
| 618 |
+
return await operation()
|
| 619 |
+
|
| 620 |
@property
|
| 621 |
def active_sessions(self) -> int:
|
| 622 |
"""Return the number of active WebSocket sessions."""
|
|
|
|
| 646 |
"""Return the concurrency configuration."""
|
| 647 |
return self._concurrency_config
|
| 648 |
|
| 649 |
+
def _factory_produces_harness_env(self) -> bool:
|
| 650 |
+
"""Return whether the env factory produces a HarnessEnvironment."""
|
| 651 |
+
import inspect
|
| 652 |
+
|
| 653 |
+
# Lazy import: openenv.core.harness imports env_server modules, so a
|
| 654 |
+
# top-level import here would be circular.
|
| 655 |
+
from ..harness.environment import HarnessEnvironment
|
| 656 |
+
|
| 657 |
+
if inspect.isclass(self._env_factory):
|
| 658 |
+
return issubclass(self._env_factory, HarnessEnvironment)
|
| 659 |
+
_temp_env = self._env_factory()
|
| 660 |
+
try:
|
| 661 |
+
return isinstance(_temp_env, HarnessEnvironment)
|
| 662 |
+
finally:
|
| 663 |
+
_temp_env.close()
|
| 664 |
+
|
| 665 |
+
def _register_harness_route(self, app: FastAPI) -> None:
|
| 666 |
+
"""
|
| 667 |
+
Register the production `/harness` WebSocket route (RFC 005).
|
| 668 |
+
|
| 669 |
+
Each connection gets its own environment session: connecting resets
|
| 670 |
+
the environment (which starts the harness process and injects tools),
|
| 671 |
+
and each `{"type": "message", "content": ...}` frame runs one
|
| 672 |
+
conversational turn, streamed back as `HarnessEvent` JSON frames
|
| 673 |
+
ending with a `turn_complete` event. Malformed client frames receive a
|
| 674 |
+
recoverable `protocol_error` response without starting a turn; the
|
| 675 |
+
connection remains usable. Terminal failures use `error`.
|
| 676 |
+
"""
|
| 677 |
+
# Lazy import to avoid a circular import with openenv.core.harness.
|
| 678 |
+
from ..harness.adapter import HarnessNotRunningError
|
| 679 |
+
from ..harness.events import (
|
| 680 |
+
HarnessClientMessage,
|
| 681 |
+
HarnessEvent,
|
| 682 |
+
HarnessEventType,
|
| 683 |
+
HarnessProtocolError,
|
| 684 |
+
)
|
| 685 |
+
|
| 686 |
+
@app.websocket("/harness")
|
| 687 |
+
async def harness_websocket_endpoint(websocket: WebSocket):
|
| 688 |
+
await websocket.accept()
|
| 689 |
+
|
| 690 |
+
session_id = None
|
| 691 |
+
session_env = None
|
| 692 |
+
|
| 693 |
+
async def send_error(message: str, code: WSErrorCode) -> None:
|
| 694 |
+
error_response = WSErrorResponse(
|
| 695 |
+
data={"message": message, "code": code}
|
| 696 |
+
)
|
| 697 |
+
await websocket.send_text(error_response.model_dump_json())
|
| 698 |
+
|
| 699 |
+
async def send_protocol_error(message: str, code: WSErrorCode) -> None:
|
| 700 |
+
error_response = HarnessProtocolError(
|
| 701 |
+
data={"message": message, "code": code}
|
| 702 |
+
)
|
| 703 |
+
await websocket.send_text(error_response.model_dump_json())
|
| 704 |
+
|
| 705 |
+
async def send_harness_error(message: str) -> None:
|
| 706 |
+
"""Emit a terminal ERROR event in the harness event stream."""
|
| 707 |
+
error_event = HarnessEvent(
|
| 708 |
+
type=HarnessEventType.ERROR,
|
| 709 |
+
data={"message": message, "recoverable": False},
|
| 710 |
+
)
|
| 711 |
+
await websocket.send_text(error_event.model_dump_json())
|
| 712 |
+
|
| 713 |
+
try:
|
| 714 |
+
session_id, session_env = await self._create_session()
|
| 715 |
+
# Protect the live harness from idle reaping and HTTP session
|
| 716 |
+
# close, including startup and turns that emit no events.
|
| 717 |
+
self._session_websocket_attachments.add(session_id)
|
| 718 |
+
|
| 719 |
+
async with AsyncExitStack() as stack:
|
| 720 |
+
mcp_session_factory = getattr(session_env, "mcp_session", None)
|
| 721 |
+
if callable(mcp_session_factory):
|
| 722 |
+
mcp_session_cm = cast(
|
| 723 |
+
AsyncContextManager[Any], mcp_session_factory()
|
| 724 |
+
)
|
| 725 |
+
await stack.enter_async_context(mcp_session_cm)
|
| 726 |
+
|
| 727 |
+
# Starts the harness process and injects environment tools
|
| 728 |
+
await session_env.reset_async()
|
| 729 |
+
await websocket.send_text(
|
| 730 |
+
json.dumps(
|
| 731 |
+
{
|
| 732 |
+
"type": "session_started",
|
| 733 |
+
"data": {
|
| 734 |
+
"session_id": session_id,
|
| 735 |
+
"harness": session_env.adapter.config.name,
|
| 736 |
+
},
|
| 737 |
+
}
|
| 738 |
+
)
|
| 739 |
+
)
|
| 740 |
+
|
| 741 |
+
while True:
|
| 742 |
+
raw_message = await websocket.receive_text()
|
| 743 |
+
|
| 744 |
+
try:
|
| 745 |
+
message_dict = json.loads(raw_message)
|
| 746 |
+
except json.JSONDecodeError as e:
|
| 747 |
+
await send_protocol_error(
|
| 748 |
+
f"Invalid JSON: {e}", WSErrorCode.INVALID_JSON
|
| 749 |
+
)
|
| 750 |
+
continue
|
| 751 |
+
try:
|
| 752 |
+
client_message = HarnessClientMessage(**message_dict)
|
| 753 |
+
except (ValidationError, TypeError) as e:
|
| 754 |
+
await send_protocol_error(
|
| 755 |
+
f"Invalid message: {e}",
|
| 756 |
+
WSErrorCode.VALIDATION_ERROR,
|
| 757 |
+
)
|
| 758 |
+
continue
|
| 759 |
+
|
| 760 |
+
self._update_session_activity(session_id, increment_step=True)
|
| 761 |
+
|
| 762 |
+
async def stream_turn(content: str) -> bool:
|
| 763 |
+
"""Stream one turn; True if it ended with TURN_COMPLETE."""
|
| 764 |
+
saw_terminal = False
|
| 765 |
+
adapter = session_env.adapter
|
| 766 |
+
if not await adapter.is_alive():
|
| 767 |
+
raise HarnessNotRunningError(
|
| 768 |
+
"harness process is not running"
|
| 769 |
+
)
|
| 770 |
+
async for event in adapter.send_message_streaming(content):
|
| 771 |
+
await websocket.send_text(event.model_dump_json())
|
| 772 |
+
# Record progress throughout the turn.
|
| 773 |
+
self._update_session_activity(session_id)
|
| 774 |
+
saw_terminal = (
|
| 775 |
+
event.type is HarnessEventType.TURN_COMPLETE
|
| 776 |
+
)
|
| 777 |
+
return saw_terminal
|
| 778 |
+
|
| 779 |
+
# Bound the turn in wall-clock time, matching what
|
| 780 |
+
# simulation mode does in HarnessEnvironment._run_turn.
|
| 781 |
+
# Without this a hung harness holds the session open
|
| 782 |
+
# forever, and the server sits at capacity.
|
| 783 |
+
turn_timeout_s = session_env.adapter.config.session_timeout_s
|
| 784 |
+
try:
|
| 785 |
+
completed = await asyncio.wait_for(
|
| 786 |
+
stream_turn(client_message.content),
|
| 787 |
+
turn_timeout_s,
|
| 788 |
+
)
|
| 789 |
+
except asyncio.TimeoutError:
|
| 790 |
+
await send_harness_error(
|
| 791 |
+
f"harness turn exceeded {turn_timeout_s} seconds"
|
| 792 |
+
)
|
| 793 |
+
break
|
| 794 |
+
except HarnessNotRunningError:
|
| 795 |
+
await send_harness_error("harness process is not running")
|
| 796 |
+
break
|
| 797 |
+
except Exception:
|
| 798 |
+
# Harness state after a crash is undefined; end
|
| 799 |
+
# the session so a reconnect gets a fresh one.
|
| 800 |
+
# Adapter exceptions can contain credentials or
|
| 801 |
+
# subprocess output; do not expose them to clients.
|
| 802 |
+
await send_harness_error("harness turn failed")
|
| 803 |
+
break
|
| 804 |
+
|
| 805 |
+
if not completed:
|
| 806 |
+
# send_message() raises HarnessError here; the
|
| 807 |
+
# socket equivalent is to say so and end the
|
| 808 |
+
# session, rather than leaving a client that
|
| 809 |
+
# blocks on the terminal event waiting forever.
|
| 810 |
+
await send_harness_error(
|
| 811 |
+
"harness event stream ended without a "
|
| 812 |
+
"TURN_COMPLETE event"
|
| 813 |
+
)
|
| 814 |
+
break
|
| 815 |
+
|
| 816 |
+
except WebSocketDisconnect:
|
| 817 |
+
pass
|
| 818 |
+
except SessionCapacityError as e:
|
| 819 |
+
await send_error(str(e), WSErrorCode.CAPACITY_REACHED)
|
| 820 |
+
except EnvironmentFactoryError as e:
|
| 821 |
+
await send_error(str(e), WSErrorCode.FACTORY_ERROR)
|
| 822 |
+
except Exception:
|
| 823 |
+
try:
|
| 824 |
+
await send_error(
|
| 825 |
+
"harness session failed", WSErrorCode.SESSION_ERROR
|
| 826 |
+
)
|
| 827 |
+
except (RuntimeError, WebSocketDisconnect):
|
| 828 |
+
pass
|
| 829 |
+
finally:
|
| 830 |
+
if session_id:
|
| 831 |
+
# Release ownership without an await so cancellation cannot
|
| 832 |
+
# leave a session permanently exempt from idle reaping.
|
| 833 |
+
self._session_websocket_attachments.discard(session_id)
|
| 834 |
+
cleanup = asyncio.create_task(self._destroy_session(session_id))
|
| 835 |
+
try:
|
| 836 |
+
await asyncio.shield(cleanup)
|
| 837 |
+
except asyncio.CancelledError:
|
| 838 |
+
# ASGI cancellation must not orphan a running harness.
|
| 839 |
+
# Finish teardown before propagating cancellation, even
|
| 840 |
+
# when the request's cancel scope cancels us repeatedly.
|
| 841 |
+
while not cleanup.done():
|
| 842 |
+
try:
|
| 843 |
+
await asyncio.shield(cleanup)
|
| 844 |
+
except asyncio.CancelledError:
|
| 845 |
+
pass
|
| 846 |
+
cleanup.result()
|
| 847 |
+
raise
|
| 848 |
+
try:
|
| 849 |
+
await websocket.close()
|
| 850 |
+
except (RuntimeError, WebSocketDisconnect):
|
| 851 |
+
# TestClient raises RuntimeError, real ASGI servers raise
|
| 852 |
+
# WebSocketDisconnect when the client is already gone.
|
| 853 |
+
pass
|
| 854 |
+
|
| 855 |
def register_routes(
|
| 856 |
self, app: FastAPI, mode: ServerMode | str = ServerMode.SIMULATION
|
| 857 |
) -> None:
|
|
|
|
| 859 |
Register HTTP routes on a FastAPI application.
|
| 860 |
|
| 861 |
Args:
|
| 862 |
+
app (`FastAPI`):
|
| 863 |
+
FastAPI application instance.
|
| 864 |
+
mode (`ServerMode` or `str`, *optional*, defaults to `ServerMode.SIMULATION`):
|
| 865 |
+
Server mode. In production mode, simulation control endpoints (/reset, /step,
|
| 866 |
+
/state) are NOT registered. Only safe endpoints (/health, /schema, /metadata,
|
| 867 |
+
/ws) are available.
|
| 868 |
|
| 869 |
Raises:
|
| 870 |
+
`ValueError`: If `mode` is not a valid `ServerMode` or string equivalent.
|
| 871 |
"""
|
| 872 |
# Convert string to ServerMode enum for backwards compatibility
|
| 873 |
if isinstance(mode, str):
|
|
|
|
| 887 |
|
| 888 |
async def _stop_session_reaper() -> None:
|
| 889 |
server_ref._stop_reaper()
|
| 890 |
+
if server_ref._shared_session_executor is not None:
|
| 891 |
+
server_ref._shared_session_executor.shutdown(wait=True)
|
| 892 |
|
| 893 |
if not getattr(app.router, "_openenv_reaper_registered", False):
|
| 894 |
app.router.on_startup.append(_start_session_reaper)
|
|
|
|
| 905 |
try:
|
| 906 |
kwargs = request.model_dump(exclude_unset=True)
|
| 907 |
|
| 908 |
+
is_async = overrides_method(_env.reset_async, Environment.reset_async)
|
| 909 |
|
| 910 |
if is_async:
|
| 911 |
sig = inspect.signature(_env.reset_async)
|
|
|
|
| 940 |
try:
|
| 941 |
kwargs = request.model_dump(exclude_unset=True, exclude={"action"})
|
| 942 |
|
| 943 |
+
is_async = overrides_method(_env.step_async, Environment.step_async)
|
| 944 |
|
| 945 |
if is_async:
|
| 946 |
sig = inspect.signature(_env.step_async)
|
|
|
|
| 971 |
Handle MCP JSON-RPC requests.
|
| 972 |
|
| 973 |
Supports tools/list and tools/call methods in JSON-RPC 2.0 format,
|
| 974 |
+
plus OpenEnv session lifecycle methods for HTTP MCP (openenv/session/create,
|
| 975 |
+
openenv/session/close).
|
|
|
|
| 976 |
"""
|
| 977 |
method = request.method
|
| 978 |
request_id = request.id
|
|
|
|
| 1033 |
)
|
| 1034 |
|
| 1035 |
async with self._session_lock:
|
| 1036 |
+
if target_session_id in self._session_websocket_attachments:
|
| 1037 |
+
env = _MISSING
|
| 1038 |
+
attached = True
|
| 1039 |
+
self._session_pending_closes.add(target_session_id)
|
| 1040 |
+
executor = None
|
| 1041 |
+
stack = None
|
| 1042 |
+
else:
|
| 1043 |
+
attached = False
|
| 1044 |
+
env = self._sessions.pop(target_session_id, _MISSING)
|
| 1045 |
+
if not attached and env is not _MISSING:
|
| 1046 |
executor = self._session_executors.pop(target_session_id, None)
|
| 1047 |
stack = self._session_stacks.pop(target_session_id, None)
|
| 1048 |
self._session_info.pop(target_session_id, None)
|
| 1049 |
+
elif not attached:
|
| 1050 |
executor = None
|
| 1051 |
stack = None
|
| 1052 |
|
| 1053 |
+
if attached:
|
| 1054 |
+
return JsonRpcResponse.success(
|
| 1055 |
+
result={
|
| 1056 |
+
"session_id": target_session_id,
|
| 1057 |
+
"closed": False,
|
| 1058 |
+
"closing": True,
|
| 1059 |
+
},
|
| 1060 |
+
request_id=request_id,
|
| 1061 |
+
)
|
| 1062 |
+
|
| 1063 |
if env is _MISSING:
|
| 1064 |
return JsonRpcResponse.error_response(
|
| 1065 |
JsonRpcErrorCode.INVALID_PARAMS,
|
|
|
|
| 1124 |
mcp_server = getattr(_env, "mcp_server", None)
|
| 1125 |
mcp_session_factory = getattr(_env, "mcp_session", None)
|
| 1126 |
|
| 1127 |
+
async def call_mcp_style_step(action: Action) -> Observation:
|
| 1128 |
+
is_async = overrides_method(_env.step_async, Environment.step_async)
|
| 1129 |
+
if is_async:
|
| 1130 |
+
return await _env.step_async(action)
|
| 1131 |
+
if managed_session_id:
|
| 1132 |
+
return await self._run_in_session_executor(
|
| 1133 |
+
managed_session_id,
|
| 1134 |
+
_env.step,
|
| 1135 |
+
action,
|
| 1136 |
+
)
|
| 1137 |
+
return await self._run_sync_in_thread_pool(_env.step, action)
|
| 1138 |
+
|
| 1139 |
+
supports_mcp_style_actions = self.action_cls in {
|
| 1140 |
+
CallToolAction,
|
| 1141 |
+
ListToolsAction,
|
| 1142 |
+
}
|
| 1143 |
+
|
| 1144 |
if method == McpMethod.TOOLS_LIST:
|
| 1145 |
# Check if environment is MCP-enabled
|
| 1146 |
if mcp_client is None and mcp_server is None:
|
| 1147 |
+
if supports_mcp_style_actions:
|
| 1148 |
+
observation = await call_mcp_style_step(ListToolsAction())
|
| 1149 |
+
if isinstance(observation, ListToolsObservation):
|
| 1150 |
+
return JsonRpcResponse.success(
|
| 1151 |
+
result={
|
| 1152 |
+
"tools": [
|
| 1153 |
+
tool.model_dump()
|
| 1154 |
+
for tool in observation.tools
|
| 1155 |
+
]
|
| 1156 |
+
},
|
| 1157 |
+
request_id=request_id,
|
| 1158 |
+
)
|
| 1159 |
+
return JsonRpcResponse.error_response(
|
| 1160 |
+
JsonRpcErrorCode.INTERNAL_ERROR,
|
| 1161 |
+
"MCP-style tools/list step returned "
|
| 1162 |
+
f"{type(observation).__name__}, expected "
|
| 1163 |
+
"ListToolsObservation",
|
| 1164 |
+
request_id=request_id,
|
| 1165 |
+
)
|
| 1166 |
return JsonRpcResponse.error_response(
|
| 1167 |
JsonRpcErrorCode.INTERNAL_ERROR,
|
| 1168 |
"Environment does not support MCP",
|
|
|
|
| 1170 |
)
|
| 1171 |
|
| 1172 |
if mcp_client:
|
| 1173 |
+
tools = await self._run_mcp_client_operation(
|
| 1174 |
+
mcp_client,
|
| 1175 |
+
mcp_session_factory,
|
| 1176 |
+
managed_session_id,
|
| 1177 |
+
mcp_client.list_tools,
|
| 1178 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1179 |
|
| 1180 |
return JsonRpcResponse.success(
|
| 1181 |
result={
|
|
|
|
| 1214 |
tool_name = params.get("name")
|
| 1215 |
arguments = params.get("arguments", {})
|
| 1216 |
|
| 1217 |
+
if not tool_name:
|
| 1218 |
return JsonRpcResponse.error_response(
|
| 1219 |
+
JsonRpcErrorCode.INVALID_PARAMS,
|
| 1220 |
+
"Missing 'name' in params",
|
| 1221 |
request_id=request_id,
|
| 1222 |
)
|
| 1223 |
|
| 1224 |
+
if mcp_client is None and mcp_server is None:
|
| 1225 |
+
if supports_mcp_style_actions:
|
| 1226 |
+
observation = await call_mcp_style_step(
|
| 1227 |
+
CallToolAction(
|
| 1228 |
+
tool_name=tool_name,
|
| 1229 |
+
arguments=arguments,
|
| 1230 |
+
)
|
| 1231 |
+
)
|
| 1232 |
+
if isinstance(observation, CallToolObservation):
|
| 1233 |
+
return JsonRpcResponse.success(
|
| 1234 |
+
result=_make_json_serializable(observation),
|
| 1235 |
+
request_id=request_id,
|
| 1236 |
+
)
|
| 1237 |
+
return JsonRpcResponse.error_response(
|
| 1238 |
+
JsonRpcErrorCode.INTERNAL_ERROR,
|
| 1239 |
+
"MCP-style tools/call step returned "
|
| 1240 |
+
f"{type(observation).__name__}, expected "
|
| 1241 |
+
"CallToolObservation",
|
| 1242 |
+
request_id=request_id,
|
| 1243 |
+
)
|
| 1244 |
return JsonRpcResponse.error_response(
|
| 1245 |
+
JsonRpcErrorCode.INTERNAL_ERROR,
|
| 1246 |
+
"Environment does not support MCP",
|
| 1247 |
request_id=request_id,
|
| 1248 |
)
|
| 1249 |
|
| 1250 |
if mcp_client:
|
| 1251 |
+
result = await self._run_mcp_client_operation(
|
| 1252 |
+
mcp_client,
|
| 1253 |
+
mcp_session_factory,
|
| 1254 |
+
managed_session_id,
|
| 1255 |
+
lambda: mcp_client.call_tool(
|
| 1256 |
name=tool_name, arguments=arguments
|
| 1257 |
+
),
|
| 1258 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1259 |
elif mcp_server:
|
| 1260 |
server_tools = get_server_tools(mcp_server)
|
| 1261 |
if tool_name in server_tools:
|
|
|
|
| 1307 |
if should_close:
|
| 1308 |
_env.close()
|
| 1309 |
|
| 1310 |
+
def _check_env_name(env_name: str) -> None:
|
| 1311 |
+
if env_name.lower() != self.env_name.lower():
|
| 1312 |
+
raise HTTPException(
|
| 1313 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 1314 |
+
detail=f"unknown environment {env_name!r}",
|
| 1315 |
+
)
|
| 1316 |
+
|
| 1317 |
+
def _normalize_split(split: Any) -> Dict[str, Any]:
|
| 1318 |
+
if hasattr(split, "model_dump"):
|
| 1319 |
+
return cast(Dict[str, Any], split.model_dump())
|
| 1320 |
+
if isinstance(split, dict):
|
| 1321 |
+
return _make_json_serializable(split)
|
| 1322 |
+
if isinstance(split, str):
|
| 1323 |
+
split_type = (
|
| 1324 |
+
split if split in {"train", "validation", "test"} else "validation"
|
| 1325 |
+
)
|
| 1326 |
+
return {"name": split, "type": split_type}
|
| 1327 |
+
return {"name": str(split), "type": "validation"}
|
| 1328 |
+
|
| 1329 |
+
async def _call_task_method(method_name: str, *args: Any) -> Any:
|
| 1330 |
+
_env = self._env_factory()
|
| 1331 |
+
try:
|
| 1332 |
+
method = getattr(_env, method_name, None)
|
| 1333 |
+
if not callable(method):
|
| 1334 |
+
raise NotImplementedError
|
| 1335 |
+
return await _maybe_await(method(*args))
|
| 1336 |
+
except NotImplementedError as e:
|
| 1337 |
+
raise HTTPException(
|
| 1338 |
+
status_code=status.HTTP_501_NOT_IMPLEMENTED,
|
| 1339 |
+
detail=f"{method_name} is not supported for this environment",
|
| 1340 |
+
) from e
|
| 1341 |
+
except IndexError as e:
|
| 1342 |
+
raise HTTPException(
|
| 1343 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 1344 |
+
detail="Invalid task index",
|
| 1345 |
+
) from e
|
| 1346 |
+
finally:
|
| 1347 |
+
_env.close()
|
| 1348 |
+
|
| 1349 |
+
@app.get("/list_environments", tags=["Task API"])
|
| 1350 |
+
async def list_environments() -> list[str]:
|
| 1351 |
+
return [self.env_name]
|
| 1352 |
+
|
| 1353 |
+
@app.get("/{env_name}/splits", tags=["Task API"])
|
| 1354 |
+
async def list_splits(env_name: str) -> list[Dict[str, Any]]:
|
| 1355 |
+
_check_env_name(env_name)
|
| 1356 |
+
splits = await _call_task_method("list_splits")
|
| 1357 |
+
return [_normalize_split(split) for split in splits]
|
| 1358 |
+
|
| 1359 |
+
@app.post("/{env_name}/tasks", tags=["Task API"])
|
| 1360 |
+
async def list_tasks(
|
| 1361 |
+
env_name: str,
|
| 1362 |
+
request: ListTasksRequest,
|
| 1363 |
+
) -> Dict[str, Any]:
|
| 1364 |
+
_check_env_name(env_name)
|
| 1365 |
+
tasks = await _call_task_method("list_tasks", request.split)
|
| 1366 |
+
return {
|
| 1367 |
+
"tasks": _make_json_serializable(tasks),
|
| 1368 |
+
"env_name": self.env_name,
|
| 1369 |
+
}
|
| 1370 |
+
|
| 1371 |
+
@app.post("/{env_name}/num_tasks", tags=["Task API"])
|
| 1372 |
+
async def num_tasks(
|
| 1373 |
+
env_name: str,
|
| 1374 |
+
request: NumTasksRequest,
|
| 1375 |
+
) -> Dict[str, Any]:
|
| 1376 |
+
_check_env_name(env_name)
|
| 1377 |
+
count = await _call_task_method("num_tasks", request.split)
|
| 1378 |
+
return {"num_tasks": count}
|
| 1379 |
+
|
| 1380 |
+
@app.post("/{env_name}/task", tags=["Task API"])
|
| 1381 |
+
async def get_task(
|
| 1382 |
+
env_name: str,
|
| 1383 |
+
request: GetTaskRequest,
|
| 1384 |
+
) -> Dict[str, Any]:
|
| 1385 |
+
_check_env_name(env_name)
|
| 1386 |
+
task = await _call_task_method("get_task", request.split, request.index)
|
| 1387 |
+
return {"task": _make_json_serializable(task)}
|
| 1388 |
+
|
| 1389 |
+
@app.post("/{env_name}/task_range", tags=["Task API"])
|
| 1390 |
+
async def get_task_range(
|
| 1391 |
+
env_name: str,
|
| 1392 |
+
request: GetTaskRangeRequest,
|
| 1393 |
+
) -> Dict[str, Any]:
|
| 1394 |
+
_check_env_name(env_name)
|
| 1395 |
+
tasks = await _call_task_method(
|
| 1396 |
+
"get_task_range", request.split, request.start, request.stop
|
| 1397 |
+
)
|
| 1398 |
+
return {"tasks": _make_json_serializable(tasks)}
|
| 1399 |
+
|
| 1400 |
# Register MCP WebSocket endpoint (available in both production and simulation modes)
|
| 1401 |
@app.websocket("/mcp")
|
| 1402 |
async def mcp_websocket_endpoint(websocket: WebSocket):
|
|
|
|
| 1404 |
WebSocket endpoint for MCP JSON-RPC requests.
|
| 1405 |
|
| 1406 |
Each WebSocket connection gets its own environment instance for MCP operations.
|
| 1407 |
+
The client sends JSON-RPC 2.0 requests (tools/list, tools/call) and the server
|
| 1408 |
+
responds with JSON-RPC 2.0 responses (result or error).
|
|
|
|
|
|
|
| 1409 |
"""
|
| 1410 |
await websocket.accept()
|
| 1411 |
|
|
|
|
| 1499 |
await self._destroy_session(session_id)
|
| 1500 |
try:
|
| 1501 |
await websocket.close()
|
| 1502 |
+
except (RuntimeError, WebSocketDisconnect):
|
| 1503 |
pass
|
| 1504 |
|
| 1505 |
+
# In production mode, a harness environment is exposed directly to
|
| 1506 |
+
# clients via a streaming WebSocket (RFC 005).
|
| 1507 |
+
if mode == ServerMode.PRODUCTION and self._factory_produces_harness_env():
|
| 1508 |
+
self._register_harness_route(app)
|
| 1509 |
+
|
| 1510 |
# Register simulation control routes only in simulation mode
|
| 1511 |
if mode == ServerMode.SIMULATION:
|
| 1512 |
|
|
|
|
| 1641 |
GetEndpointConfig(
|
| 1642 |
path="/state",
|
| 1643 |
handler=get_state_handler,
|
| 1644 |
+
response_model=self.state_cls,
|
| 1645 |
tag="State Management",
|
| 1646 |
summary="Get current environment state",
|
| 1647 |
description="""
|
|
|
|
| 1665 |
|
| 1666 |
Returns a combined schema object containing:
|
| 1667 |
- **action**: JSON schema for actions accepted by this environment
|
| 1668 |
+
- **observation**: JSON schema for observations returned by step
|
| 1669 |
+
- **reset_observation**: JSON schema for observations returned by reset
|
| 1670 |
- **state**: JSON schema for environment state objects
|
| 1671 |
|
| 1672 |
This is more efficient than calling individual schema endpoints and provides
|
|
|
|
| 1701 |
return SchemaResponse(
|
| 1702 |
action=self.action_cls.model_json_schema(),
|
| 1703 |
observation=self.observation_cls.model_json_schema(),
|
| 1704 |
+
reset_observation=self.reset_observation_cls.model_json_schema(),
|
| 1705 |
+
state=self.state_cls.model_json_schema(),
|
| 1706 |
)
|
| 1707 |
|
| 1708 |
# Register MCP endpoint for production mode (direct MCP access)
|
| 1709 |
@app.post("/mcp")
|
| 1710 |
async def mcp_endpoint(request_raw: Request) -> Dict[str, Any]:
|
| 1711 |
+
"""MCP JSON-RPC endpoint for production mode.
|
|
|
|
| 1712 |
|
| 1713 |
Bypasses step() overhead and provides direct access to MCP tools.
|
| 1714 |
Supports tools/list and tools/call methods.
|
|
|
|
| 1741 |
"""
|
| 1742 |
WebSocket endpoint for persistent environment sessions.
|
| 1743 |
|
| 1744 |
+
By default, each WebSocket connection gets its own environment instance.
|
| 1745 |
+
A client can instead attach to an existing HTTP MCP session by passing its
|
| 1746 |
+
session ID in the query string. The client sends WSResetMessage,
|
| 1747 |
+
WSStepMessage, WSStateMessage, or WSCloseMessage; the server responds with
|
| 1748 |
+
WSObservationResponse, WSStateResponse, or WSErrorResponse.
|
| 1749 |
"""
|
| 1750 |
await websocket.accept()
|
| 1751 |
|
| 1752 |
session_id = None
|
| 1753 |
session_env = None
|
| 1754 |
+
owns_session = False
|
| 1755 |
+
attached_session = False
|
| 1756 |
|
| 1757 |
try:
|
| 1758 |
+
requested_session_id = websocket.query_params.get("session_id")
|
| 1759 |
+
if requested_session_id:
|
| 1760 |
+
async with self._session_lock:
|
| 1761 |
+
attached_env = self._sessions.get(
|
| 1762 |
+
requested_session_id, _MISSING
|
| 1763 |
+
)
|
| 1764 |
+
if attached_env is _MISSING:
|
| 1765 |
+
raise RuntimeError(
|
| 1766 |
+
f"Unknown session_id: {requested_session_id}"
|
| 1767 |
+
)
|
| 1768 |
+
if attached_env is None:
|
| 1769 |
+
raise RuntimeError(
|
| 1770 |
+
f"Session {requested_session_id} is still initializing"
|
| 1771 |
+
)
|
| 1772 |
+
if requested_session_id in self._session_websocket_attachments:
|
| 1773 |
+
raise RuntimeError(
|
| 1774 |
+
f"Session {requested_session_id} already has "
|
| 1775 |
+
"an attached WebSocket"
|
| 1776 |
+
)
|
| 1777 |
+
self._session_websocket_attachments.add(requested_session_id)
|
| 1778 |
+
session_id = requested_session_id
|
| 1779 |
+
session_env = attached_env
|
| 1780 |
+
attached_session = True
|
| 1781 |
+
self._update_session_activity(session_id)
|
| 1782 |
+
else:
|
| 1783 |
+
session_id, session_env = await self._create_session()
|
| 1784 |
+
owns_session = True
|
| 1785 |
+
|
| 1786 |
if session_env is None:
|
| 1787 |
raise RuntimeError(
|
| 1788 |
"Session environment not initialized for websocket"
|
|
|
|
| 1793 |
|
| 1794 |
async with AsyncExitStack() as stack:
|
| 1795 |
mcp_session_factory = getattr(session_env, "mcp_session", None)
|
| 1796 |
+
if owns_session and callable(mcp_session_factory):
|
| 1797 |
mcp_session_cm = cast(
|
| 1798 |
AsyncContextManager[Any], mcp_session_factory()
|
| 1799 |
)
|
|
|
|
| 1822 |
case "reset":
|
| 1823 |
msg = WSResetMessage(**message_dict)
|
| 1824 |
|
| 1825 |
+
is_async = overrides_method(
|
| 1826 |
+
session_env.reset_async,
|
| 1827 |
+
Environment.reset_async,
|
| 1828 |
)
|
| 1829 |
|
| 1830 |
if is_async:
|
|
|
|
| 1860 |
msg.data, self.action_cls
|
| 1861 |
)
|
| 1862 |
|
| 1863 |
+
is_async = overrides_method(
|
| 1864 |
+
session_env.step_async,
|
| 1865 |
+
Environment.step_async,
|
| 1866 |
)
|
| 1867 |
|
| 1868 |
if is_async:
|
|
|
|
| 1972 |
)
|
| 1973 |
await websocket.send_text(error_resp.model_dump_json())
|
| 1974 |
finally:
|
| 1975 |
+
if attached_session and session_id:
|
| 1976 |
+
# Do not await the lock before releasing ownership: a task
|
| 1977 |
+
# cancelled while waiting would leave this session
|
| 1978 |
+
# permanently exempt from idle reaping.
|
| 1979 |
+
self._session_websocket_attachments.discard(session_id)
|
| 1980 |
+
if session_id in self._session_pending_closes:
|
| 1981 |
+
await asyncio.shield(self._destroy_session(session_id))
|
| 1982 |
+
elif owns_session and session_id:
|
| 1983 |
await self._destroy_session(session_id)
|
| 1984 |
try:
|
| 1985 |
await websocket.close()
|
| 1986 |
+
except (RuntimeError, WebSocketDisconnect):
|
| 1987 |
pass
|
| 1988 |
|
| 1989 |
|
|
|
|
| 1995 |
max_concurrent_envs: Optional[int] = None,
|
| 1996 |
concurrency_config: Optional[ConcurrencyConfig] = None,
|
| 1997 |
gradio_builder: Optional[Callable[..., Any]] = None,
|
| 1998 |
+
custom_tab_name: str = "Custom",
|
| 1999 |
+
custom_tab_primary: bool = False,
|
| 2000 |
+
show_default_tab: bool = True,
|
| 2001 |
+
title_override: Optional[str] = None,
|
| 2002 |
+
state_cls: Type[State] = State,
|
| 2003 |
+
*,
|
| 2004 |
+
mode: Optional[ServerMode | str] = None,
|
| 2005 |
+
reset_observation_cls: Optional[Type[Observation]] = None,
|
| 2006 |
) -> FastAPI:
|
| 2007 |
"""
|
| 2008 |
Create a FastAPI application with or without web interface.
|
|
|
|
| 2011 |
including README integration for better user experience.
|
| 2012 |
|
| 2013 |
Args:
|
| 2014 |
+
env (`Callable[[], Environment]`):
|
| 2015 |
+
Environment factory (callable) that creates new instances.
|
| 2016 |
+
action_cls (`Type[Action]`):
|
| 2017 |
+
The Action subclass this environment expects.
|
| 2018 |
+
observation_cls (`Type[Observation]`):
|
| 2019 |
+
The Observation subclass returned by step.
|
| 2020 |
+
reset_observation_cls (`Type[Observation]`, *optional*):
|
| 2021 |
+
The reset observation model published in `/schema`. Defaults to
|
| 2022 |
+
`observation_cls`; declare a distinct model when reset differs.
|
| 2023 |
+
env_name (`str`, *optional*):
|
| 2024 |
+
Environment name for README loading.
|
| 2025 |
+
max_concurrent_envs (`int`, *optional*):
|
| 2026 |
+
Maximum concurrent WebSocket sessions. Mutually exclusive with
|
| 2027 |
+
`concurrency_config`.
|
| 2028 |
+
concurrency_config (`ConcurrencyConfig`, *optional*):
|
| 2029 |
+
Advanced concurrency settings. Mutually exclusive with
|
| 2030 |
+
`max_concurrent_envs`.
|
| 2031 |
+
gradio_builder (`Callable`, *optional*):
|
| 2032 |
+
Callable to build a custom Gradio UI at /web. Signature:
|
| 2033 |
+
`(web_manager, action_fields, metadata, is_chat_env, title, quick_start_md) -> gr.Blocks`.
|
| 2034 |
+
When `None`, the default Gradio app is used.
|
| 2035 |
+
custom_tab_name (`str`, *optional*, defaults to `"Custom"`):
|
| 2036 |
+
Label for the env-specific tab when `gradio_builder` is provided.
|
| 2037 |
+
custom_tab_primary (`bool`, *optional*, defaults to `False`):
|
| 2038 |
+
When `True`, the env-specific tab is active first; the auto-generated
|
| 2039 |
+
Playground becomes secondary.
|
| 2040 |
+
show_default_tab (`bool`, *optional*, defaults to `True`):
|
| 2041 |
+
When `False`, mount the env's `gradio_builder` output alone (no
|
| 2042 |
+
auto-generated Playground, no tab chrome). Only meaningful when
|
| 2043 |
+
`gradio_builder` is provided.
|
| 2044 |
+
title_override (`str`, *optional*):
|
| 2045 |
+
If set, used as the Gradio app title instead of the default
|
| 2046 |
+
`"OpenEnv Agentic Environment: {name}"`.
|
| 2047 |
+
state_cls (`Type[State]`, *optional*, defaults to `State`):
|
| 2048 |
+
The `State` subclass this environment reports, used for the `/state`
|
| 2049 |
+
response model and the `state` entry of `/schema`.
|
| 2050 |
+
mode (`ServerMode` or `str`, *optional*):
|
| 2051 |
+
Server mode. When `None`, resolved from the `OPENENV_MODE`
|
| 2052 |
+
environment variable, defaulting to simulation.
|
| 2053 |
|
| 2054 |
Returns:
|
| 2055 |
+
`FastAPI` application instance with or without web interface and README integration.
|
| 2056 |
"""
|
| 2057 |
# Check if web interface should be enabled
|
| 2058 |
# This can be controlled via environment variable or build argument
|
|
|
|
| 2073 |
env_name,
|
| 2074 |
max_concurrent_envs,
|
| 2075 |
concurrency_config,
|
| 2076 |
+
state_cls=state_cls,
|
| 2077 |
+
reset_observation_cls=reset_observation_cls,
|
| 2078 |
gradio_builder=gradio_builder,
|
| 2079 |
+
custom_tab_name=custom_tab_name,
|
| 2080 |
+
custom_tab_primary=custom_tab_primary,
|
| 2081 |
+
show_default_tab=show_default_tab,
|
| 2082 |
+
title_override=title_override,
|
| 2083 |
+
mode=mode,
|
| 2084 |
)
|
| 2085 |
else:
|
| 2086 |
# Use standard FastAPI app without web interface
|
| 2087 |
return create_fastapi_app(
|
| 2088 |
+
env,
|
| 2089 |
+
action_cls,
|
| 2090 |
+
observation_cls,
|
| 2091 |
+
max_concurrent_envs,
|
| 2092 |
+
concurrency_config,
|
| 2093 |
+
env_name=env_name,
|
| 2094 |
+
state_cls=state_cls,
|
| 2095 |
+
reset_observation_cls=reset_observation_cls,
|
| 2096 |
+
mode=mode,
|
| 2097 |
)
|
| 2098 |
|
| 2099 |
|
|
|
|
| 2103 |
observation_cls: Type[Observation],
|
| 2104 |
max_concurrent_envs: Optional[int] = None,
|
| 2105 |
concurrency_config: Optional[ConcurrencyConfig] = None,
|
| 2106 |
+
env_name: Optional[str] = None,
|
| 2107 |
+
state_cls: Type[State] = State,
|
| 2108 |
+
*,
|
| 2109 |
+
mode: Optional[ServerMode | str] = None,
|
| 2110 |
+
reset_observation_cls: Optional[Type[Observation]] = None,
|
| 2111 |
) -> FastAPI:
|
| 2112 |
"""
|
| 2113 |
Create a FastAPI application with comprehensive documentation.
|
| 2114 |
|
| 2115 |
Args:
|
| 2116 |
+
env (`Callable[[], Environment]`):
|
| 2117 |
+
Environment factory (callable) that creates new instances.
|
| 2118 |
+
action_cls (`Type[Action]`):
|
| 2119 |
+
The Action subclass this environment expects.
|
| 2120 |
+
observation_cls (`Type[Observation]`):
|
| 2121 |
+
The Observation subclass returned by step.
|
| 2122 |
+
reset_observation_cls (`Type[Observation]`, *optional*):
|
| 2123 |
+
The reset observation model published in `/schema`. Defaults to
|
| 2124 |
+
`observation_cls`; declare a distinct model when reset differs.
|
| 2125 |
+
max_concurrent_envs (`int`, *optional*):
|
| 2126 |
+
Maximum concurrent WebSocket sessions. Mutually exclusive with
|
| 2127 |
+
`concurrency_config`.
|
| 2128 |
+
concurrency_config (`ConcurrencyConfig`, *optional*):
|
| 2129 |
+
Advanced concurrency settings. Mutually exclusive with
|
| 2130 |
+
`max_concurrent_envs`.
|
| 2131 |
+
env_name (`str`, *optional*):
|
| 2132 |
+
Optional environment name for task/split endpoints.
|
| 2133 |
+
state_cls (`Type[State]`, *optional*, defaults to `State`):
|
| 2134 |
+
The `State` subclass this environment reports, used for the `/state`
|
| 2135 |
+
response model and the `state` entry of `/schema`.
|
| 2136 |
+
mode (`ServerMode` or `str`, *optional*):
|
| 2137 |
+
Server mode. When `None`, resolved from the `OPENENV_MODE`
|
| 2138 |
+
environment variable, defaulting to simulation.
|
| 2139 |
|
| 2140 |
Returns:
|
| 2141 |
+
`FastAPI` application instance.
|
| 2142 |
"""
|
| 2143 |
try:
|
| 2144 |
from fastapi import FastAPI
|
|
|
|
| 2199 |
openapi_url="/openapi.json",
|
| 2200 |
contact={
|
| 2201 |
"name": "OpenEnv Team",
|
| 2202 |
+
"url": "https://github.com/huggingface/OpenEnv",
|
| 2203 |
},
|
| 2204 |
license_info={
|
| 2205 |
"name": "BSD-3-Clause",
|
| 2206 |
+
"url": "https://github.com/huggingface/OpenEnv/blob/main/LICENSE",
|
| 2207 |
},
|
| 2208 |
)
|
| 2209 |
|
|
|
|
| 2213 |
observation_cls,
|
| 2214 |
max_concurrent_envs,
|
| 2215 |
concurrency_config=concurrency_config,
|
| 2216 |
+
env_name=env_name,
|
| 2217 |
+
state_cls=state_cls,
|
| 2218 |
+
reset_observation_cls=reset_observation_cls,
|
| 2219 |
)
|
| 2220 |
+
if mode is None:
|
| 2221 |
+
mode = os.environ.get("OPENENV_MODE", ServerMode.SIMULATION.value)
|
| 2222 |
+
server.register_routes(app, mode=mode)
|
| 2223 |
return app
|
src/core/env_server/interfaces.py
CHANGED
|
@@ -1,12 +1,18 @@
|
|
| 1 |
-
#
|
| 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 |
import inspect
|
| 8 |
from abc import ABC, abstractmethod
|
| 9 |
-
from typing import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
|
| 11 |
from typing_extensions import TypedDict
|
| 12 |
|
|
@@ -48,13 +54,17 @@ class ModelTokenizer(Protocol):
|
|
| 48 |
"""Apply a chat template to format and optionally tokenize a conversation.
|
| 49 |
|
| 50 |
Args:
|
| 51 |
-
conversation
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
|
| 56 |
Returns:
|
| 57 |
-
Formatted and optionally tokenized conversation
|
| 58 |
"""
|
| 59 |
...
|
| 60 |
|
|
@@ -64,12 +74,126 @@ class ModelTokenizer(Protocol):
|
|
| 64 |
"""Decode token IDs back to text.
|
| 65 |
|
| 66 |
Args:
|
| 67 |
-
token_ids
|
| 68 |
-
|
| 69 |
-
**
|
|
|
|
|
|
|
|
|
|
| 70 |
|
| 71 |
Returns:
|
| 72 |
-
Decoded text string
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
"""
|
| 74 |
...
|
| 75 |
|
|
@@ -87,10 +211,11 @@ class Transform(ABC, Generic[ObsT]):
|
|
| 87 |
"""Transform an observation.
|
| 88 |
|
| 89 |
Args:
|
| 90 |
-
observation
|
|
|
|
| 91 |
|
| 92 |
Returns:
|
| 93 |
-
The transformed observation
|
| 94 |
"""
|
| 95 |
pass
|
| 96 |
|
|
@@ -98,35 +223,41 @@ class Transform(ABC, Generic[ObsT]):
|
|
| 98 |
class Environment(ABC, Generic[ActT, ObsT, StateT]):
|
| 99 |
"""Base class for all environment servers following Gym/Gymnasium API.
|
| 100 |
|
|
|
|
|
|
|
| 101 |
Args:
|
| 102 |
-
transform
|
| 103 |
-
|
|
|
|
|
|
|
| 104 |
rubric's output can be used to set the observation's reward in step().
|
| 105 |
|
| 106 |
-
Class Attributes:
|
| 107 |
-
SUPPORTS_CONCURRENT_SESSIONS: Whether this environment supports concurrent sessions.
|
| 108 |
-
When True, multiple WebSocket connections can each have their own
|
| 109 |
-
environment instance (up to max_concurrent_envs). When False (default),
|
| 110 |
-
the environment should only be used with a single session at a time.
|
| 111 |
-
|
| 112 |
-
Set this to True in your Environment subclass if:
|
| 113 |
-
- The environment uses proper session isolation (e.g., unique working dirs)
|
| 114 |
-
- No shared mutable state exists between instances
|
| 115 |
-
- External resources (databases, APIs) can handle concurrent access
|
| 116 |
-
|
| 117 |
Attributes:
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
|
| 124 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 125 |
"""
|
| 126 |
|
| 127 |
# Class-level flag indicating whether this environment supports concurrent sessions
|
| 128 |
SUPPORTS_CONCURRENT_SESSIONS: bool = False
|
| 129 |
|
|
|
|
|
|
|
| 130 |
# Optional rubric for reward computation
|
| 131 |
rubric: Optional["Rubric"]
|
| 132 |
|
|
@@ -196,7 +327,7 @@ class Environment(ABC, Generic[ActT, ObsT, StateT]):
|
|
| 196 |
Default implementation returns basic metadata derived from class name.
|
| 197 |
|
| 198 |
Returns:
|
| 199 |
-
EnvironmentMetadata with environment information
|
| 200 |
"""
|
| 201 |
return EnvironmentMetadata(
|
| 202 |
name=self.__class__.__name__,
|
|
@@ -214,17 +345,22 @@ class Environment(ABC, Generic[ActT, ObsT, StateT]):
|
|
| 214 |
"""Apply rubric if one is provided.
|
| 215 |
|
| 216 |
Args:
|
| 217 |
-
action
|
| 218 |
-
|
|
|
|
|
|
|
| 219 |
|
| 220 |
Returns:
|
| 221 |
-
Reward value from the rubric, or 0.0 if no rubric is set.
|
| 222 |
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
|
|
|
|
|
|
|
|
|
| 228 |
"""
|
| 229 |
if self.rubric is not None:
|
| 230 |
return self.rubric(action, observation)
|
|
@@ -234,17 +370,22 @@ class Environment(ABC, Generic[ActT, ObsT, StateT]):
|
|
| 234 |
"""Apply rubric asynchronously if one is provided.
|
| 235 |
|
| 236 |
Args:
|
| 237 |
-
action
|
| 238 |
-
|
|
|
|
|
|
|
| 239 |
|
| 240 |
Returns:
|
| 241 |
-
Reward value from the rubric, or 0.0 if no rubric is set.
|
|
|
|
|
|
|
| 242 |
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
|
|
|
| 248 |
"""
|
| 249 |
if self.rubric is not None:
|
| 250 |
result = self.rubric(action, observation)
|
|
@@ -257,13 +398,14 @@ class Environment(ABC, Generic[ActT, ObsT, StateT]):
|
|
| 257 |
def _reset_rubric(self) -> None:
|
| 258 |
"""Reset the rubric state if one is provided.
|
| 259 |
|
| 260 |
-
Call this in reset() to clear any trajectory state in the rubric
|
| 261 |
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
|
|
|
| 267 |
"""
|
| 268 |
if self.rubric is not None:
|
| 269 |
self.rubric.reset()
|
|
@@ -271,13 +413,14 @@ class Environment(ABC, Generic[ActT, ObsT, StateT]):
|
|
| 271 |
async def _reset_rubric_async(self) -> None:
|
| 272 |
"""Reset the rubric state asynchronously if one is provided.
|
| 273 |
|
| 274 |
-
Call this in reset_async() to clear any trajectory state in the rubric
|
| 275 |
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
|
|
|
| 281 |
"""
|
| 282 |
if self.rubric is not None:
|
| 283 |
# Check if rubric has async reset method
|
|
@@ -288,6 +431,37 @@ class Environment(ABC, Generic[ActT, ObsT, StateT]):
|
|
| 288 |
else:
|
| 289 |
self.rubric.reset()
|
| 290 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 291 |
def close(self) -> None:
|
| 292 |
"""Clean up resources used by the environment.
|
| 293 |
|
|
|
|
| 1 |
+
# SPDX-License-Identifier: BSD-3-Clause
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
import inspect
|
| 4 |
from abc import ABC, abstractmethod
|
| 5 |
+
from typing import (
|
| 6 |
+
Any,
|
| 7 |
+
Dict,
|
| 8 |
+
Generic,
|
| 9 |
+
List,
|
| 10 |
+
Optional,
|
| 11 |
+
Protocol,
|
| 12 |
+
Tuple,
|
| 13 |
+
TYPE_CHECKING,
|
| 14 |
+
TypeVar,
|
| 15 |
+
)
|
| 16 |
|
| 17 |
from typing_extensions import TypedDict
|
| 18 |
|
|
|
|
| 54 |
"""Apply a chat template to format and optionally tokenize a conversation.
|
| 55 |
|
| 56 |
Args:
|
| 57 |
+
conversation (`list[Message]`):
|
| 58 |
+
List of message dictionaries with 'role' and 'content'.
|
| 59 |
+
tokenize (`bool`, *optional*, defaults to `True`):
|
| 60 |
+
Whether to tokenize the output.
|
| 61 |
+
return_tensors (`str`, *optional*):
|
| 62 |
+
Format for returned tensors ('pt' for PyTorch).
|
| 63 |
+
**kwargs:
|
| 64 |
+
Additional arguments.
|
| 65 |
|
| 66 |
Returns:
|
| 67 |
+
Formatted and optionally tokenized conversation.
|
| 68 |
"""
|
| 69 |
...
|
| 70 |
|
|
|
|
| 74 |
"""Decode token IDs back to text.
|
| 75 |
|
| 76 |
Args:
|
| 77 |
+
token_ids (`Any`):
|
| 78 |
+
Token IDs to decode.
|
| 79 |
+
skip_special_tokens (`bool`, *optional*, defaults to `False`):
|
| 80 |
+
Whether to skip special tokens in output.
|
| 81 |
+
**kwargs:
|
| 82 |
+
Additional arguments.
|
| 83 |
|
| 84 |
Returns:
|
| 85 |
+
`str`: Decoded text string.
|
| 86 |
+
"""
|
| 87 |
+
...
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
class TaskProvider(Protocol):
|
| 91 |
+
"""
|
| 92 |
+
Optional task discovery API for dataset-backed environments.
|
| 93 |
+
|
| 94 |
+
An environment implements this protocol structurally — declare the methods on
|
| 95 |
+
an [`~openenv.core.env_server.interfaces.Environment`] subclass, without
|
| 96 |
+
inheriting from `TaskProvider`. When the methods are present,
|
| 97 |
+
[`~openenv.core.env_server.http_server.HTTPEnvServer`] exposes them as HTTP
|
| 98 |
+
routes under `/{env_name}/…`; when they are absent, those routes return
|
| 99 |
+
`501 Not Implemented`. Each method may be sync or async.
|
| 100 |
+
|
| 101 |
+
Task provider methods are for metadata/discovery only and should be
|
| 102 |
+
side-effect-free. They must be callable on a freshly constructed
|
| 103 |
+
environment instance because HTTP compatibility routes may create a
|
| 104 |
+
short-lived instance solely for task discovery.
|
| 105 |
+
|
| 106 |
+
Selecting a task is not part of this protocol — pass the chosen split and
|
| 107 |
+
index to `reset()` instead. See the
|
| 108 |
+
[Task API guide](https://huggingface.co/docs/openenv/guides/task-api).
|
| 109 |
+
|
| 110 |
+
Examples:
|
| 111 |
+
|
| 112 |
+
```python
|
| 113 |
+
env.list_splits() # ["train", "test"]
|
| 114 |
+
env.num_tasks("test") # 7595
|
| 115 |
+
env.get_task("test", 12) # {"id": "test-12", "index": 12}
|
| 116 |
+
env.reset(split="test", index=12)
|
| 117 |
+
```
|
| 118 |
+
"""
|
| 119 |
+
|
| 120 |
+
def list_splits(self) -> list[Any]:
|
| 121 |
+
"""
|
| 122 |
+
Return task split descriptors supported by this environment.
|
| 123 |
+
|
| 124 |
+
Returns:
|
| 125 |
+
`list[Any]`: Split descriptors. Plain strings, dicts, and Pydantic
|
| 126 |
+
models are all accepted; the server normalizes each entry to
|
| 127 |
+
`{"name": ..., "type": ...}`.
|
| 128 |
+
"""
|
| 129 |
+
...
|
| 130 |
+
|
| 131 |
+
def list_tasks(self, split: str) -> list[Any]:
|
| 132 |
+
"""
|
| 133 |
+
Return all task specs for a split.
|
| 134 |
+
|
| 135 |
+
Args:
|
| 136 |
+
split (`str`):
|
| 137 |
+
Task split name.
|
| 138 |
+
|
| 139 |
+
Returns:
|
| 140 |
+
`list[Any]`: Task specs for the split. Environments backed by very
|
| 141 |
+
large or streamed splits may return a bounded preview, but
|
| 142 |
+
`num_tasks` should still report the true total.
|
| 143 |
+
"""
|
| 144 |
+
...
|
| 145 |
+
|
| 146 |
+
def num_tasks(self, split: str) -> int:
|
| 147 |
+
"""
|
| 148 |
+
Return the number of task specs in a split.
|
| 149 |
+
|
| 150 |
+
Args:
|
| 151 |
+
split (`str`):
|
| 152 |
+
Task split name.
|
| 153 |
+
|
| 154 |
+
Returns:
|
| 155 |
+
`int`: Number of task specs available in the split.
|
| 156 |
+
"""
|
| 157 |
+
...
|
| 158 |
+
|
| 159 |
+
def get_task(self, split: str, index: int) -> Any:
|
| 160 |
+
"""
|
| 161 |
+
Return one task spec by split and index.
|
| 162 |
+
|
| 163 |
+
Args:
|
| 164 |
+
split (`str`):
|
| 165 |
+
Task split name.
|
| 166 |
+
index (`int`):
|
| 167 |
+
Task index within the split.
|
| 168 |
+
|
| 169 |
+
Returns:
|
| 170 |
+
`Any`: The task spec at that position.
|
| 171 |
+
|
| 172 |
+
Raises:
|
| 173 |
+
`IndexError`: If `index` is out of range for the split. The HTTP
|
| 174 |
+
route converts this to a `400 Bad Request`.
|
| 175 |
+
"""
|
| 176 |
+
...
|
| 177 |
+
|
| 178 |
+
def get_task_range(
|
| 179 |
+
self,
|
| 180 |
+
split: str,
|
| 181 |
+
start: Optional[int] = None,
|
| 182 |
+
stop: Optional[int] = None,
|
| 183 |
+
) -> list[Any]:
|
| 184 |
+
"""
|
| 185 |
+
Return task specs for Python slice-style range bounds.
|
| 186 |
+
|
| 187 |
+
Args:
|
| 188 |
+
split (`str`):
|
| 189 |
+
Task split name.
|
| 190 |
+
start (`int`, *optional*):
|
| 191 |
+
Inclusive start index. Defaults to the beginning of the split.
|
| 192 |
+
stop (`int`, *optional*):
|
| 193 |
+
Exclusive stop index. Defaults to the end of the split.
|
| 194 |
+
|
| 195 |
+
Returns:
|
| 196 |
+
`list[Any]`: Task specs in `[start, stop)`.
|
| 197 |
"""
|
| 198 |
...
|
| 199 |
|
|
|
|
| 211 |
"""Transform an observation.
|
| 212 |
|
| 213 |
Args:
|
| 214 |
+
observation (`ObsT`):
|
| 215 |
+
The input observation.
|
| 216 |
|
| 217 |
Returns:
|
| 218 |
+
`ObsT`: The transformed observation.
|
| 219 |
"""
|
| 220 |
pass
|
| 221 |
|
|
|
|
| 223 |
class Environment(ABC, Generic[ActT, ObsT, StateT]):
|
| 224 |
"""Base class for all environment servers following Gym/Gymnasium API.
|
| 225 |
|
| 226 |
+
See [rfcs/004-rubrics.md](https://github.com/huggingface/OpenEnv/blob/main/rfcs/004-rubrics.md) for rubric design details.
|
| 227 |
+
|
| 228 |
Args:
|
| 229 |
+
transform (`Transform`, *optional*):
|
| 230 |
+
Optional transform to apply to observations.
|
| 231 |
+
rubric (`Rubric`, *optional*):
|
| 232 |
+
Optional rubric for reward computation. When provided, the
|
| 233 |
rubric's output can be used to set the observation's reward in step().
|
| 234 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 235 |
Attributes:
|
| 236 |
+
SUPPORTS_CONCURRENT_SESSIONS (`bool`):
|
| 237 |
+
Whether this environment supports concurrent sessions. When ``True``,
|
| 238 |
+
multiple WebSocket connections can each have their own environment
|
| 239 |
+
instance (up to ``max_concurrent_envs``). When ``False`` (default),
|
| 240 |
+
the environment should only be used with a single session at a time.
|
| 241 |
|
| 242 |
+
Set this to ``True`` in your subclass if the environment uses proper
|
| 243 |
+
session isolation (unique working dirs, no shared mutable state, and
|
| 244 |
+
external resources that can handle concurrent access).
|
| 245 |
+
rubric (`Rubric`, *optional*):
|
| 246 |
+
Optional rubric for computing rewards. Set in ``__init__`` and use in
|
| 247 |
+
``step()`` to compute observation rewards. Training infrastructure can
|
| 248 |
+
access it for introspection:
|
| 249 |
+
|
| 250 |
+
```python
|
| 251 |
+
for name, r in env.rubric.named_rubrics():
|
| 252 |
+
print(f"{name}: {r.last_score}")
|
| 253 |
+
```
|
| 254 |
"""
|
| 255 |
|
| 256 |
# Class-level flag indicating whether this environment supports concurrent sessions
|
| 257 |
SUPPORTS_CONCURRENT_SESSIONS: bool = False
|
| 258 |
|
| 259 |
+
REQUIRES_SINGLE_THREAD_EXECUTOR: bool = False
|
| 260 |
+
|
| 261 |
# Optional rubric for reward computation
|
| 262 |
rubric: Optional["Rubric"]
|
| 263 |
|
|
|
|
| 327 |
Default implementation returns basic metadata derived from class name.
|
| 328 |
|
| 329 |
Returns:
|
| 330 |
+
[`EnvironmentMetadata`] with environment information.
|
| 331 |
"""
|
| 332 |
return EnvironmentMetadata(
|
| 333 |
name=self.__class__.__name__,
|
|
|
|
| 345 |
"""Apply rubric if one is provided.
|
| 346 |
|
| 347 |
Args:
|
| 348 |
+
action (`ActT`):
|
| 349 |
+
The action taken by the agent.
|
| 350 |
+
observation (`ObsT`):
|
| 351 |
+
The resulting observation.
|
| 352 |
|
| 353 |
Returns:
|
| 354 |
+
`float`: Reward value from the rubric, or 0.0 if no rubric is set.
|
| 355 |
|
| 356 |
+
Call this in `step()` to compute and assign the reward:
|
| 357 |
+
|
| 358 |
+
```python
|
| 359 |
+
def step(self, action: MyAction, ...) -> MyObservation:
|
| 360 |
+
# ... execute action and create observation ...
|
| 361 |
+
observation.reward = self._apply_rubric(action, observation)
|
| 362 |
+
return observation
|
| 363 |
+
```
|
| 364 |
"""
|
| 365 |
if self.rubric is not None:
|
| 366 |
return self.rubric(action, observation)
|
|
|
|
| 370 |
"""Apply rubric asynchronously if one is provided.
|
| 371 |
|
| 372 |
Args:
|
| 373 |
+
action (`ActT`):
|
| 374 |
+
The action taken by the agent.
|
| 375 |
+
observation (`ObsT`):
|
| 376 |
+
The resulting observation.
|
| 377 |
|
| 378 |
Returns:
|
| 379 |
+
`float`: Reward value from the rubric, or 0.0 if no rubric is set.
|
| 380 |
+
|
| 381 |
+
Call this in `step_async()` to compute and assign the reward:
|
| 382 |
|
| 383 |
+
```python
|
| 384 |
+
async def step_async(self, action: MyAction, ...) -> MyObservation:
|
| 385 |
+
# ... execute action and create observation ...
|
| 386 |
+
observation.reward = await self._apply_rubric_async(action, observation)
|
| 387 |
+
return observation
|
| 388 |
+
```
|
| 389 |
"""
|
| 390 |
if self.rubric is not None:
|
| 391 |
result = self.rubric(action, observation)
|
|
|
|
| 398 |
def _reset_rubric(self) -> None:
|
| 399 |
"""Reset the rubric state if one is provided.
|
| 400 |
|
| 401 |
+
Call this in `reset()` to clear any trajectory state in the rubric:
|
| 402 |
|
| 403 |
+
```python
|
| 404 |
+
def reset(self, ...) -> MyObservation:
|
| 405 |
+
self._reset_rubric()
|
| 406 |
+
# ... create initial observation ...
|
| 407 |
+
return observation
|
| 408 |
+
```
|
| 409 |
"""
|
| 410 |
if self.rubric is not None:
|
| 411 |
self.rubric.reset()
|
|
|
|
| 413 |
async def _reset_rubric_async(self) -> None:
|
| 414 |
"""Reset the rubric state asynchronously if one is provided.
|
| 415 |
|
| 416 |
+
Call this in `reset_async()` to clear any trajectory state in the rubric:
|
| 417 |
|
| 418 |
+
```python
|
| 419 |
+
async def reset_async(self, ...) -> MyObservation:
|
| 420 |
+
await self._reset_rubric_async()
|
| 421 |
+
# ... create initial observation ...
|
| 422 |
+
return observation
|
| 423 |
+
```
|
| 424 |
"""
|
| 425 |
if self.rubric is not None:
|
| 426 |
# Check if rubric has async reset method
|
|
|
|
| 431 |
else:
|
| 432 |
self.rubric.reset()
|
| 433 |
|
| 434 |
+
def render_web(self, observation: Dict[str, Any]) -> Optional[str]:
|
| 435 |
+
"""
|
| 436 |
+
HTML that draws an observation in the web interface at `/web`.
|
| 437 |
+
|
| 438 |
+
Override it to show the environment's state (a board, a page, a plot).
|
| 439 |
+
The default draws nothing and the interface lists the observation's fields.
|
| 440 |
+
|
| 441 |
+
Args:
|
| 442 |
+
observation (`dict`):
|
| 443 |
+
The serialized observation, as returned by `reset()` or `step()`,
|
| 444 |
+
plus top-level `reward` and `done`.
|
| 445 |
+
"""
|
| 446 |
+
return None
|
| 447 |
+
|
| 448 |
+
def web_actions(
|
| 449 |
+
self, observation: Dict[str, Any]
|
| 450 |
+
) -> List[Tuple[str, Dict[str, Any]]]:
|
| 451 |
+
"""
|
| 452 |
+
One-click actions for the web interface at `/web`, as `(label, action)` pairs.
|
| 453 |
+
|
| 454 |
+
Override it when the valid actions are known from the observation (the legal
|
| 455 |
+
moves of a game). Each action is the dict `step()` receives. The default
|
| 456 |
+
offers none and the interface shows the action's form only.
|
| 457 |
+
|
| 458 |
+
Args:
|
| 459 |
+
observation (`dict`):
|
| 460 |
+
The serialized observation, as returned by `reset()` or `step()`,
|
| 461 |
+
plus top-level `reward` and `done`.
|
| 462 |
+
"""
|
| 463 |
+
return []
|
| 464 |
+
|
| 465 |
def close(self) -> None:
|
| 466 |
"""Clean up resources used by the environment.
|
| 467 |
|
src/core/env_server/mcp_environment.py
CHANGED
|
@@ -1,8 +1,4 @@
|
|
| 1 |
-
#
|
| 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 |
MCP Environment base class for OpenEnv.
|
|
@@ -19,7 +15,9 @@ Key features:
|
|
| 19 |
- Mode-aware tool registration (production vs simulation)
|
| 20 |
- Code mode support via get_callables() and execute_code()
|
| 21 |
|
| 22 |
-
|
|
|
|
|
|
|
| 23 |
from fastmcp import FastMCP
|
| 24 |
from openenv.core.env_server.mcp_environment import MCPEnvironment
|
| 25 |
|
|
@@ -50,6 +48,7 @@ Usage:
|
|
| 50 |
def state(self):
|
| 51 |
# Return current state
|
| 52 |
...
|
|
|
|
| 53 |
"""
|
| 54 |
|
| 55 |
import asyncio
|
|
@@ -104,6 +103,33 @@ def get_server_tools(mcp_server: Any) -> Dict[str, Any]:
|
|
| 104 |
return {}
|
| 105 |
|
| 106 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
class MCPEnvironment(Environment):
|
| 108 |
"""
|
| 109 |
Base class for environments that expose tools via MCP (Model Context Protocol).
|
|
@@ -127,16 +153,21 @@ class MCPEnvironment(Environment):
|
|
| 127 |
ValueError: If any tool in the MCP server uses a reserved name
|
| 128 |
(reset, step, state, close).
|
| 129 |
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
>
|
| 139 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 140 |
"""
|
| 141 |
|
| 142 |
def __init__(self, mcp_server: Any, transform: Optional[Any] = None) -> None:
|
|
@@ -398,19 +429,20 @@ class MCPEnvironment(Environment):
|
|
| 398 |
the subclass's _step_impl() method.
|
| 399 |
|
| 400 |
Args:
|
| 401 |
-
action
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
timeout_s
|
| 406 |
-
|
| 407 |
-
|
|
|
|
|
|
|
| 408 |
|
| 409 |
Returns:
|
| 410 |
-
Observation
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
- Subclass-defined Observation for other actions
|
| 414 |
"""
|
| 415 |
if isinstance(action, ListToolsAction):
|
| 416 |
return self._handle_list_tools()
|
|
@@ -465,34 +497,11 @@ class MCPEnvironment(Environment):
|
|
| 465 |
tools = []
|
| 466 |
for tool in tools_result:
|
| 467 |
if tool.name not in self._mode_tool_schemas:
|
| 468 |
-
tools.append(
|
| 469 |
-
|
| 470 |
-
|
| 471 |
-
|
| 472 |
-
|
| 473 |
-
if hasattr(tool, "inputSchema")
|
| 474 |
-
else {},
|
| 475 |
-
)
|
| 476 |
-
)
|
| 477 |
-
for tool_name, mode_schemas in self._mode_tool_schemas.items():
|
| 478 |
-
if None in mode_schemas:
|
| 479 |
-
schema = mode_schemas[None]
|
| 480 |
-
tools.append(
|
| 481 |
-
Tool(
|
| 482 |
-
name=schema["name"],
|
| 483 |
-
description=schema["description"],
|
| 484 |
-
input_schema=schema["input_schema"],
|
| 485 |
-
)
|
| 486 |
-
)
|
| 487 |
-
elif current_mode in mode_schemas:
|
| 488 |
-
schema = mode_schemas[current_mode]
|
| 489 |
-
tools.append(
|
| 490 |
-
Tool(
|
| 491 |
-
name=schema["name"],
|
| 492 |
-
description=schema["description"],
|
| 493 |
-
input_schema=schema["input_schema"],
|
| 494 |
-
)
|
| 495 |
-
)
|
| 496 |
return ListToolsObservation(tools=tools)
|
| 497 |
except Exception as e:
|
| 498 |
return ListToolsObservation(
|
|
|
|
| 1 |
+
# SPDX-License-Identifier: BSD-3-Clause
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
"""
|
| 4 |
MCP Environment base class for OpenEnv.
|
|
|
|
| 15 |
- Mode-aware tool registration (production vs simulation)
|
| 16 |
- Code mode support via get_callables() and execute_code()
|
| 17 |
|
| 18 |
+
Examples:
|
| 19 |
+
|
| 20 |
+
```python
|
| 21 |
from fastmcp import FastMCP
|
| 22 |
from openenv.core.env_server.mcp_environment import MCPEnvironment
|
| 23 |
|
|
|
|
| 48 |
def state(self):
|
| 49 |
# Return current state
|
| 50 |
...
|
| 51 |
+
```
|
| 52 |
"""
|
| 53 |
|
| 54 |
import asyncio
|
|
|
|
| 103 |
return {}
|
| 104 |
|
| 105 |
|
| 106 |
+
def _tool_from_server_tool(tool: Any) -> Tool:
|
| 107 |
+
"""Convert a FastMCP tool object into OpenEnv's Tool model."""
|
| 108 |
+
return Tool(
|
| 109 |
+
name=tool.name,
|
| 110 |
+
description=tool.description or "",
|
| 111 |
+
input_schema=tool.inputSchema if hasattr(tool, "inputSchema") else {},
|
| 112 |
+
)
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def _tool_from_mode_schema(schema: Dict[str, Any]) -> Tool:
|
| 116 |
+
"""Convert a stored mode-aware schema into OpenEnv's Tool model."""
|
| 117 |
+
return Tool(
|
| 118 |
+
name=schema["name"],
|
| 119 |
+
description=schema["description"],
|
| 120 |
+
input_schema=schema["input_schema"],
|
| 121 |
+
)
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def _schema_for_mode(
|
| 125 |
+
mode_schemas: Dict[Optional[str], Dict[str, Any]], current_mode: Optional[str]
|
| 126 |
+
) -> Optional[Dict[str, Any]]:
|
| 127 |
+
"""Return the schema visible for the current mode, if any."""
|
| 128 |
+
if None in mode_schemas:
|
| 129 |
+
return mode_schemas[None]
|
| 130 |
+
return mode_schemas.get(current_mode)
|
| 131 |
+
|
| 132 |
+
|
| 133 |
class MCPEnvironment(Environment):
|
| 134 |
"""
|
| 135 |
Base class for environments that expose tools via MCP (Model Context Protocol).
|
|
|
|
| 153 |
ValueError: If any tool in the MCP server uses a reserved name
|
| 154 |
(reset, step, state, close).
|
| 155 |
|
| 156 |
+
Examples:
|
| 157 |
+
|
| 158 |
+
```python
|
| 159 |
+
from fastmcp import FastMCP
|
| 160 |
+
|
| 161 |
+
mcp = FastMCP("calculator")
|
| 162 |
+
|
| 163 |
+
@mcp.tool()
|
| 164 |
+
def add(a: int, b: int) -> int:
|
| 165 |
+
return a + b
|
| 166 |
+
|
| 167 |
+
env = MyMCPEnvironment(mcp)
|
| 168 |
+
obs = env.step(ListToolsAction())
|
| 169 |
+
obs.tools[0].name # 'add'
|
| 170 |
+
```
|
| 171 |
"""
|
| 172 |
|
| 173 |
def __init__(self, mcp_server: Any, transform: Optional[Any] = None) -> None:
|
|
|
|
| 429 |
the subclass's _step_impl() method.
|
| 430 |
|
| 431 |
Args:
|
| 432 |
+
action (`Action`):
|
| 433 |
+
The action to execute. `ListToolsAction` returns available MCP tools,
|
| 434 |
+
`CallToolAction` invokes a specific MCP tool, and any other action
|
| 435 |
+
is delegated to _step_impl().
|
| 436 |
+
timeout_s (`float`, *optional*):
|
| 437 |
+
Timeout in seconds for the action. Defaults to MCP_TOOL_CALL_TIMEOUT
|
| 438 |
+
(30s) for MCP actions.
|
| 439 |
+
**kwargs (`Any`):
|
| 440 |
+
Additional arguments passed to handlers.
|
| 441 |
|
| 442 |
Returns:
|
| 443 |
+
`Observation`: `ListToolsObservation` for `ListToolsAction`,
|
| 444 |
+
`CallToolObservation` for `CallToolAction`, or a subclass-defined
|
| 445 |
+
Observation for other actions.
|
|
|
|
| 446 |
"""
|
| 447 |
if isinstance(action, ListToolsAction):
|
| 448 |
return self._handle_list_tools()
|
|
|
|
| 497 |
tools = []
|
| 498 |
for tool in tools_result:
|
| 499 |
if tool.name not in self._mode_tool_schemas:
|
| 500 |
+
tools.append(_tool_from_server_tool(tool))
|
| 501 |
+
for mode_schemas in self._mode_tool_schemas.values():
|
| 502 |
+
schema = _schema_for_mode(mode_schemas, current_mode)
|
| 503 |
+
if schema is not None:
|
| 504 |
+
tools.append(_tool_from_mode_schema(schema))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 505 |
return ListToolsObservation(tools=tools)
|
| 506 |
except Exception as e:
|
| 507 |
return ListToolsObservation(
|
src/core/env_server/mcp_types.py
CHANGED
|
@@ -1,8 +1,4 @@
|
|
| 1 |
-
#
|
| 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 |
MCP (Model Context Protocol) type definitions for OpenEnv.
|
|
@@ -231,9 +227,8 @@ class ListToolsAction(Action):
|
|
| 231 |
Request list of available tools from the environment.
|
| 232 |
|
| 233 |
This action triggers MCP's tools/list operation and returns
|
| 234 |
-
all available tools with their schemas.
|
| 235 |
-
|
| 236 |
-
Note: Does NOT require reset() to be called first.
|
| 237 |
"""
|
| 238 |
|
| 239 |
type: Literal["list_tools"] = Field(
|
|
|
|
| 1 |
+
# SPDX-License-Identifier: BSD-3-Clause
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
"""
|
| 4 |
MCP (Model Context Protocol) type definitions for OpenEnv.
|
|
|
|
| 227 |
Request list of available tools from the environment.
|
| 228 |
|
| 229 |
This action triggers MCP's tools/list operation and returns
|
| 230 |
+
all available tools with their schemas. Does NOT require reset()
|
| 231 |
+
to be called first.
|
|
|
|
| 232 |
"""
|
| 233 |
|
| 234 |
type: Literal["list_tools"] = Field(
|
src/core/env_server/route_config.py
CHANGED
|
@@ -1,8 +1,4 @@
|
|
| 1 |
-
#
|
| 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 |
Route configuration utilities for declarative FastAPI route registration.
|
|
@@ -30,28 +26,32 @@ class GetEndpointConfig:
|
|
| 30 |
description: str
|
| 31 |
|
| 32 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
def register_get_endpoints(app: FastAPI, configs: List[GetEndpointConfig]) -> None:
|
| 34 |
"""
|
| 35 |
Register multiple GET endpoints from configuration.
|
| 36 |
|
| 37 |
Args:
|
| 38 |
-
app
|
| 39 |
-
|
|
|
|
|
|
|
| 40 |
"""
|
| 41 |
for config in configs:
|
| 42 |
-
# Capture handler in a closure to avoid non-serializable default parameter
|
| 43 |
-
def make_endpoint(
|
| 44 |
-
handler: Callable[[], BaseModel | dict],
|
| 45 |
-
) -> Callable[[], BaseModel | dict]:
|
| 46 |
-
async def endpoint() -> BaseModel | dict:
|
| 47 |
-
return handler()
|
| 48 |
-
|
| 49 |
-
return endpoint
|
| 50 |
-
|
| 51 |
app.get(
|
| 52 |
config.path,
|
| 53 |
response_model=config.response_model,
|
| 54 |
tags=[config.tag],
|
| 55 |
summary=config.summary,
|
| 56 |
description=config.description,
|
| 57 |
-
)(
|
|
|
|
| 1 |
+
# SPDX-License-Identifier: BSD-3-Clause
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
"""
|
| 4 |
Route configuration utilities for declarative FastAPI route registration.
|
|
|
|
| 26 |
description: str
|
| 27 |
|
| 28 |
|
| 29 |
+
def _make_get_endpoint(
|
| 30 |
+
handler: Callable[[], BaseModel | dict],
|
| 31 |
+
) -> Callable[[], BaseModel | dict]:
|
| 32 |
+
"""Wrap a sync GET handler in the async endpoint FastAPI expects."""
|
| 33 |
+
|
| 34 |
+
async def endpoint() -> BaseModel | dict:
|
| 35 |
+
return handler()
|
| 36 |
+
|
| 37 |
+
return endpoint
|
| 38 |
+
|
| 39 |
+
|
| 40 |
def register_get_endpoints(app: FastAPI, configs: List[GetEndpointConfig]) -> None:
|
| 41 |
"""
|
| 42 |
Register multiple GET endpoints from configuration.
|
| 43 |
|
| 44 |
Args:
|
| 45 |
+
app ([`~fastapi.FastAPI`]):
|
| 46 |
+
FastAPI application instance.
|
| 47 |
+
configs (`List[GetEndpointConfig]`):
|
| 48 |
+
List of GET endpoint configurations.
|
| 49 |
"""
|
| 50 |
for config in configs:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
app.get(
|
| 52 |
config.path,
|
| 53 |
response_model=config.response_model,
|
| 54 |
tags=[config.tag],
|
| 55 |
summary=config.summary,
|
| 56 |
description=config.description,
|
| 57 |
+
)(_make_get_endpoint(config.handler))
|
src/core/env_server/serialization.py
CHANGED
|
@@ -1,8 +1,4 @@
|
|
| 1 |
-
#
|
| 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 |
Shared serialization and deserialization utilities for OpenEnv HTTP servers.
|
|
@@ -12,6 +8,7 @@ and Pydantic models (Action/Observation) to eliminate code duplication across
|
|
| 12 |
HTTP server and web interface implementations.
|
| 13 |
"""
|
| 14 |
|
|
|
|
| 15 |
from typing import Any, Dict, Type
|
| 16 |
|
| 17 |
from .mcp_types import CallToolAction, ListToolsAction
|
|
@@ -27,6 +24,22 @@ _MCP_ACTION_TYPES: Dict[str, Type[Action]] = {
|
|
| 27 |
}
|
| 28 |
|
| 29 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
def deserialize_action(action_data: Dict[str, Any], action_cls: Type[Action]) -> Action:
|
| 31 |
"""
|
| 32 |
Convert JSON dict to Action instance using Pydantic validation.
|
|
@@ -40,28 +53,20 @@ def deserialize_action(action_data: Dict[str, Any], action_cls: Type[Action]) ->
|
|
| 40 |
use deserialize_action_with_preprocessing().
|
| 41 |
|
| 42 |
Args:
|
| 43 |
-
action_data
|
| 44 |
-
|
|
|
|
|
|
|
| 45 |
|
| 46 |
Returns:
|
| 47 |
-
Action instance
|
| 48 |
|
| 49 |
Raises:
|
| 50 |
-
ValidationError: If action_data is invalid for the action class
|
| 51 |
-
|
| 52 |
-
Note:
|
| 53 |
-
This uses Pydantic's model_validate() for automatic validation.
|
| 54 |
"""
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
# silently bypassing env-specific validation for non-MCP environments
|
| 59 |
-
# that happen to use "call_tool" / "list_tools" as a type discriminator.
|
| 60 |
-
action_type = action_data.get("type")
|
| 61 |
-
if action_type in _MCP_ACTION_TYPES:
|
| 62 |
-
mcp_cls = _MCP_ACTION_TYPES[action_type]
|
| 63 |
-
if action_cls is Action or action_cls in _MCP_ACTION_TYPES.values():
|
| 64 |
-
return mcp_cls.model_validate(action_data)
|
| 65 |
|
| 66 |
return action_cls.model_validate(action_data)
|
| 67 |
|
|
@@ -73,28 +78,36 @@ def deserialize_action_with_preprocessing(
|
|
| 73 |
Convert JSON dict to Action instance with preprocessing for special types.
|
| 74 |
|
| 75 |
This version handles common type conversions needed for web interfaces:
|
|
|
|
| 76 |
- Converting lists/strings to tensors for 'tokens' field
|
| 77 |
- Converting string action_id to int
|
| 78 |
- Other custom preprocessing as needed
|
| 79 |
|
| 80 |
Args:
|
| 81 |
-
action_data
|
| 82 |
-
|
|
|
|
|
|
|
| 83 |
|
| 84 |
Returns:
|
| 85 |
-
Action instance
|
| 86 |
|
| 87 |
Raises:
|
| 88 |
-
ValidationError: If action_data is invalid for the action class
|
| 89 |
"""
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
|
| 99 |
processed_data = {}
|
| 100 |
|
|
@@ -104,8 +117,6 @@ def deserialize_action_with_preprocessing(
|
|
| 104 |
if isinstance(value, str):
|
| 105 |
# If it's a string, try to parse it as a list of numbers
|
| 106 |
try:
|
| 107 |
-
import json
|
| 108 |
-
|
| 109 |
value = json.loads(value)
|
| 110 |
except Exception:
|
| 111 |
# If parsing fails, treat as empty list
|
|
@@ -138,34 +149,37 @@ def serialize_observation(observation: Observation) -> Dict[str, Any]:
|
|
| 138 |
Convert Observation instance to JSON-compatible dict using Pydantic.
|
| 139 |
|
| 140 |
Args:
|
| 141 |
-
observation
|
|
|
|
| 142 |
|
| 143 |
Returns:
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
"reward": float | None,
|
| 150 |
-
"done": bool,
|
| 151 |
-
}
|
| 152 |
"""
|
| 153 |
-
#
|
|
|
|
|
|
|
| 154 |
obs_dict = observation.model_dump(
|
| 155 |
exclude={
|
| 156 |
"reward",
|
| 157 |
"done",
|
| 158 |
-
"metadata",
|
| 159 |
} # Exclude these from observation dict
|
| 160 |
)
|
| 161 |
|
| 162 |
-
# Extract reward and
|
| 163 |
reward = observation.reward
|
| 164 |
done = observation.done
|
|
|
|
| 165 |
|
| 166 |
-
# Return in EnvClient expected format
|
| 167 |
-
|
| 168 |
"observation": obs_dict,
|
| 169 |
"reward": reward,
|
| 170 |
"done": done,
|
| 171 |
}
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-License-Identifier: BSD-3-Clause
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
"""
|
| 4 |
Shared serialization and deserialization utilities for OpenEnv HTTP servers.
|
|
|
|
| 8 |
HTTP server and web interface implementations.
|
| 9 |
"""
|
| 10 |
|
| 11 |
+
import json
|
| 12 |
from typing import Any, Dict, Type
|
| 13 |
|
| 14 |
from .mcp_types import CallToolAction, ListToolsAction
|
|
|
|
| 24 |
}
|
| 25 |
|
| 26 |
|
| 27 |
+
def _deserialize_mcp_action(
|
| 28 |
+
action_data: Dict[str, Any], action_cls: Type[Action]
|
| 29 |
+
) -> Action | None:
|
| 30 |
+
# Only intercept when action_cls is the generic Action base or itself an
|
| 31 |
+
# MCP type. This keeps env-specific action validation authoritative.
|
| 32 |
+
action_type = action_data.get("type")
|
| 33 |
+
if action_type not in _MCP_ACTION_TYPES:
|
| 34 |
+
return None
|
| 35 |
+
|
| 36 |
+
mcp_cls = _MCP_ACTION_TYPES[action_type]
|
| 37 |
+
if action_cls is Action or action_cls in _MCP_ACTION_TYPES.values():
|
| 38 |
+
return mcp_cls.model_validate(action_data)
|
| 39 |
+
|
| 40 |
+
return None
|
| 41 |
+
|
| 42 |
+
|
| 43 |
def deserialize_action(action_data: Dict[str, Any], action_cls: Type[Action]) -> Action:
|
| 44 |
"""
|
| 45 |
Convert JSON dict to Action instance using Pydantic validation.
|
|
|
|
| 53 |
use deserialize_action_with_preprocessing().
|
| 54 |
|
| 55 |
Args:
|
| 56 |
+
action_data (`dict`):
|
| 57 |
+
Dictionary containing action data.
|
| 58 |
+
action_cls (`type`):
|
| 59 |
+
The Action subclass to instantiate.
|
| 60 |
|
| 61 |
Returns:
|
| 62 |
+
`Action` instance.
|
| 63 |
|
| 64 |
Raises:
|
| 65 |
+
`ValidationError`: If `action_data` is invalid for the action class.
|
|
|
|
|
|
|
|
|
|
| 66 |
"""
|
| 67 |
+
mcp_action = _deserialize_mcp_action(action_data, action_cls)
|
| 68 |
+
if mcp_action is not None:
|
| 69 |
+
return mcp_action
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
|
| 71 |
return action_cls.model_validate(action_data)
|
| 72 |
|
|
|
|
| 78 |
Convert JSON dict to Action instance with preprocessing for special types.
|
| 79 |
|
| 80 |
This version handles common type conversions needed for web interfaces:
|
| 81 |
+
- Converting JSON string arguments to dict for MCP call_tool actions
|
| 82 |
- Converting lists/strings to tensors for 'tokens' field
|
| 83 |
- Converting string action_id to int
|
| 84 |
- Other custom preprocessing as needed
|
| 85 |
|
| 86 |
Args:
|
| 87 |
+
action_data (`dict`):
|
| 88 |
+
Dictionary containing action data.
|
| 89 |
+
action_cls (`type`):
|
| 90 |
+
The Action subclass to instantiate.
|
| 91 |
|
| 92 |
Returns:
|
| 93 |
+
`Action` instance.
|
| 94 |
|
| 95 |
Raises:
|
| 96 |
+
`ValidationError`: If `action_data` is invalid for the action class.
|
| 97 |
"""
|
| 98 |
+
mcp_data = action_data
|
| 99 |
+
if action_data.get("type") == "call_tool" and isinstance(
|
| 100 |
+
action_data.get("arguments"), str
|
| 101 |
+
):
|
| 102 |
+
mcp_data = dict(action_data)
|
| 103 |
+
try:
|
| 104 |
+
mcp_data["arguments"] = json.loads(action_data["arguments"])
|
| 105 |
+
except Exception:
|
| 106 |
+
pass
|
| 107 |
+
|
| 108 |
+
mcp_action = _deserialize_mcp_action(mcp_data, action_cls)
|
| 109 |
+
if mcp_action is not None:
|
| 110 |
+
return mcp_action
|
| 111 |
|
| 112 |
processed_data = {}
|
| 113 |
|
|
|
|
| 117 |
if isinstance(value, str):
|
| 118 |
# If it's a string, try to parse it as a list of numbers
|
| 119 |
try:
|
|
|
|
|
|
|
| 120 |
value = json.loads(value)
|
| 121 |
except Exception:
|
| 122 |
# If parsing fails, treat as empty list
|
|
|
|
| 149 |
Convert Observation instance to JSON-compatible dict using Pydantic.
|
| 150 |
|
| 151 |
Args:
|
| 152 |
+
observation (`Observation`):
|
| 153 |
+
Observation instance to serialize.
|
| 154 |
|
| 155 |
Returns:
|
| 156 |
+
`dict` compatible with `EnvClient._parse_result()`, with keys:
|
| 157 |
+
- `observation` (`dict`): Observation fields.
|
| 158 |
+
- `reward` (`float` or `None`): Reward value.
|
| 159 |
+
- `done` (`bool`): Whether the episode is done.
|
| 160 |
+
- `metadata` (`dict`, *optional*): Additional observation metadata.
|
|
|
|
|
|
|
|
|
|
| 161 |
"""
|
| 162 |
+
# Keep metadata in the nested observation payload for backwards
|
| 163 |
+
# compatibility with typed clients, and also surface it as a top-level
|
| 164 |
+
# sibling for clients that read the generic wire format directly.
|
| 165 |
obs_dict = observation.model_dump(
|
| 166 |
exclude={
|
| 167 |
"reward",
|
| 168 |
"done",
|
|
|
|
| 169 |
} # Exclude these from observation dict
|
| 170 |
)
|
| 171 |
|
| 172 |
+
# Extract reward, done, and metadata directly from the observation.
|
| 173 |
reward = observation.reward
|
| 174 |
done = observation.done
|
| 175 |
+
metadata = observation.metadata
|
| 176 |
|
| 177 |
+
# Return in EnvClient expected format.
|
| 178 |
+
result = {
|
| 179 |
"observation": obs_dict,
|
| 180 |
"reward": reward,
|
| 181 |
"done": done,
|
| 182 |
}
|
| 183 |
+
if metadata:
|
| 184 |
+
result["metadata"] = metadata
|
| 185 |
+
return result
|
src/core/env_server/types.py
CHANGED
|
@@ -1,13 +1,9 @@
|
|
| 1 |
-
#
|
| 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 enum import Enum
|
| 8 |
from typing import Annotated, Any, Dict, Literal, Optional, Union
|
| 9 |
|
| 10 |
-
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
| 11 |
|
| 12 |
|
| 13 |
# Type aliases
|
|
@@ -121,6 +117,9 @@ class ResetResponse(BaseModel):
|
|
| 121 |
done: bool = Field(
|
| 122 |
default=False, description="Whether episode is already done (typically False)"
|
| 123 |
)
|
|
|
|
|
|
|
|
|
|
| 124 |
|
| 125 |
|
| 126 |
class StepRequest(BaseModel):
|
|
@@ -164,6 +163,9 @@ class StepResponse(BaseModel):
|
|
| 164 |
default=None, description="Reward signal from the action"
|
| 165 |
)
|
| 166 |
done: bool = Field(default=False, description="Whether the episode has terminated")
|
|
|
|
|
|
|
|
|
|
| 167 |
|
| 168 |
|
| 169 |
class BaseMessage(BaseModel):
|
|
@@ -234,6 +236,16 @@ class SchemaResponse(BaseMessage):
|
|
| 234 |
state: Dict[str, Any] = Field(
|
| 235 |
description="JSON schema for environment state objects"
|
| 236 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 237 |
|
| 238 |
|
| 239 |
class HealthResponse(BaseMessage):
|
|
@@ -245,6 +257,33 @@ class HealthResponse(BaseMessage):
|
|
| 245 |
)
|
| 246 |
|
| 247 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 248 |
class WSResetMessage(BaseMessage):
|
| 249 |
"""WebSocket message to reset the environment."""
|
| 250 |
|
|
|
|
| 1 |
+
# SPDX-License-Identifier: BSD-3-Clause
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
from enum import Enum
|
| 4 |
from typing import Annotated, Any, Dict, Literal, Optional, Union
|
| 5 |
|
| 6 |
+
from pydantic import BaseModel, ConfigDict, Field, model_serializer, model_validator
|
| 7 |
|
| 8 |
|
| 9 |
# Type aliases
|
|
|
|
| 117 |
done: bool = Field(
|
| 118 |
default=False, description="Whether episode is already done (typically False)"
|
| 119 |
)
|
| 120 |
+
metadata: Optional[Dict[str, Any]] = Field(
|
| 121 |
+
default=None, description="Additional metadata from the environment"
|
| 122 |
+
)
|
| 123 |
|
| 124 |
|
| 125 |
class StepRequest(BaseModel):
|
|
|
|
| 163 |
default=None, description="Reward signal from the action"
|
| 164 |
)
|
| 165 |
done: bool = Field(default=False, description="Whether the episode has terminated")
|
| 166 |
+
metadata: Optional[Dict[str, Any]] = Field(
|
| 167 |
+
default=None, description="Additional metadata from the environment"
|
| 168 |
+
)
|
| 169 |
|
| 170 |
|
| 171 |
class BaseMessage(BaseModel):
|
|
|
|
| 236 |
state: Dict[str, Any] = Field(
|
| 237 |
description="JSON schema for environment state objects"
|
| 238 |
)
|
| 239 |
+
reset_observation: Optional[Dict[str, Any]] = Field(
|
| 240 |
+
default=None, description="JSON schema for observations returned by reset"
|
| 241 |
+
)
|
| 242 |
+
|
| 243 |
+
@model_serializer(mode="wrap")
|
| 244 |
+
def _omit_undeclared_reset_schema(self, handler):
|
| 245 |
+
data = handler(self)
|
| 246 |
+
if self.reset_observation is None:
|
| 247 |
+
data.pop("reset_observation", None)
|
| 248 |
+
return data
|
| 249 |
|
| 250 |
|
| 251 |
class HealthResponse(BaseMessage):
|
|
|
|
| 257 |
)
|
| 258 |
|
| 259 |
|
| 260 |
+
class ListTasksRequest(BaseMessage):
|
| 261 |
+
"""Request model for ORS-compatible task listing."""
|
| 262 |
+
|
| 263 |
+
split: str = Field(description="Task split name")
|
| 264 |
+
|
| 265 |
+
|
| 266 |
+
class NumTasksRequest(BaseMessage):
|
| 267 |
+
"""Request model for ORS-compatible task counts."""
|
| 268 |
+
|
| 269 |
+
split: str = Field(description="Task split name")
|
| 270 |
+
|
| 271 |
+
|
| 272 |
+
class GetTaskRequest(BaseMessage):
|
| 273 |
+
"""Request model for ORS-compatible task lookup."""
|
| 274 |
+
|
| 275 |
+
split: str = Field(description="Task split name")
|
| 276 |
+
index: int = Field(description="Task index within the split")
|
| 277 |
+
|
| 278 |
+
|
| 279 |
+
class GetTaskRangeRequest(BaseMessage):
|
| 280 |
+
"""Request model for ORS-compatible task range lookup."""
|
| 281 |
+
|
| 282 |
+
split: str = Field(description="Task split name")
|
| 283 |
+
start: Optional[int] = Field(default=None, description="Inclusive start index")
|
| 284 |
+
stop: Optional[int] = Field(default=None, description="Exclusive stop index")
|
| 285 |
+
|
| 286 |
+
|
| 287 |
class WSResetMessage(BaseMessage):
|
| 288 |
"""WebSocket message to reset the environment."""
|
| 289 |
|
src/core/env_server/web_interface.py
CHANGED
|
@@ -1,8 +1,4 @@
|
|
| 1 |
-
#
|
| 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.
|
|
@@ -15,59 +11,79 @@ option (e.g. openenv push --enable-interface) or ENABLE_WEB_INTERFACE env var.
|
|
| 15 |
from __future__ import annotations
|
| 16 |
|
| 17 |
import asyncio
|
|
|
|
| 18 |
import inspect
|
| 19 |
import json
|
| 20 |
from concurrent.futures import ThreadPoolExecutor
|
| 21 |
from datetime import datetime
|
| 22 |
-
from typing import Any, Callable, Dict, List, Optional, Type
|
| 23 |
|
| 24 |
import gradio as gr
|
| 25 |
from fastapi import Body, FastAPI, HTTPException, status, WebSocket, WebSocketDisconnect
|
| 26 |
from fastapi.responses import RedirectResponse
|
| 27 |
from pydantic import BaseModel, ConfigDict, Field
|
| 28 |
|
|
|
|
| 29 |
from .gradio_theme import OPENENV_GRADIO_CSS, OPENENV_GRADIO_THEME
|
| 30 |
from .gradio_ui import build_gradio_app, get_gradio_display_title
|
| 31 |
from .interfaces import Environment
|
| 32 |
from .serialization import deserialize_action_with_preprocessing, serialize_observation
|
| 33 |
from .types import Action, EnvironmentMetadata, Observation, State
|
| 34 |
|
| 35 |
-
# Quick Start markdown template; placeholders match init suffixes (__ENV_NAME__, __ENV_CLASS_NAME__*).
|
| 36 |
-
DEFAULT_QUICK_START_MARKDOWN = """
|
| 37 |
-
### Connect to this environment
|
| 38 |
|
| 39 |
-
|
| 40 |
|
| 41 |
-
```python
|
| 42 |
-
from __ENV_NAME__ import __ENV_CLASS_NAME__Action, __ENV_CLASS_NAME__Env
|
| 43 |
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
``
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
### Contribute to this environment
|
| 55 |
-
|
| 56 |
-
Submit improvements via pull request on the Hugging Face Hub.
|
| 57 |
-
|
| 58 |
-
```bash
|
| 59 |
-
openenv fork <SPACE_ID> --repo-id <your-username>/<your-repo-name>
|
| 60 |
-
```
|
| 61 |
-
|
| 62 |
-
Then make your changes and submit a pull request:
|
| 63 |
-
|
| 64 |
-
```bash
|
| 65 |
-
cd <forked-repo>
|
| 66 |
-
openenv push <SPACE_ID> --create-pr
|
| 67 |
-
```
|
| 68 |
|
| 69 |
-
|
| 70 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
|
| 72 |
|
| 73 |
def get_quick_start_markdown(
|
|
@@ -76,35 +92,86 @@ def get_quick_start_markdown(
|
|
| 76 |
observation_cls: Type[Observation],
|
| 77 |
) -> str:
|
| 78 |
"""
|
| 79 |
-
Build Quick Start markdown
|
| 80 |
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
"""
|
| 85 |
import os
|
| 86 |
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
if
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
|
| 109 |
|
| 110 |
def load_environment_metadata(
|
|
@@ -114,39 +181,29 @@ def load_environment_metadata(
|
|
| 114 |
Load environment metadata including README content.
|
| 115 |
|
| 116 |
Args:
|
| 117 |
-
env: The environment instance
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
|
| 123 |
Returns:
|
| 124 |
-
EnvironmentMetadata with loaded information
|
| 125 |
"""
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
#
|
| 129 |
-
|
| 130 |
-
# 2. A function (factory function) - e.g., create_chat_environment
|
| 131 |
-
# 3. An actual instance - e.g., SnakeEnvironment()
|
| 132 |
-
is_class = inspect.isclass(env)
|
| 133 |
-
is_function = inspect.isfunction(env) or inspect.ismethod(env)
|
| 134 |
-
is_factory = is_class or is_function
|
| 135 |
-
|
| 136 |
-
# Try to get metadata from environment if it's an instance with get_metadata
|
| 137 |
-
if not is_factory and hasattr(env, "get_metadata"):
|
| 138 |
return env.get_metadata()
|
| 139 |
|
|
|
|
|
|
|
|
|
|
| 140 |
# Determine the class name for default metadata
|
| 141 |
-
if
|
| 142 |
-
# env is the class itself
|
| 143 |
class_name = env.__name__
|
| 144 |
-
elif is_function:
|
| 145 |
-
# env is a factory function - use its name or derive from env_name
|
| 146 |
-
class_name = env_name or env.__name__
|
| 147 |
else:
|
| 148 |
-
|
| 149 |
-
class_name = env.__class__.__name__
|
| 150 |
|
| 151 |
# Default metadata
|
| 152 |
metadata = EnvironmentMetadata(
|
|
@@ -167,22 +224,14 @@ def _load_readme_from_filesystem(env_name: Optional[str]) -> Optional[str]:
|
|
| 167 |
"""
|
| 168 |
Load README content from the filesystem.
|
| 169 |
|
| 170 |
-
Tries multiple locations:
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
"""
|
| 175 |
import os
|
| 176 |
from pathlib import Path
|
| 177 |
|
| 178 |
-
# Try container filesystem first
|
| 179 |
-
container_readme = Path("/app/README.md")
|
| 180 |
-
if container_readme.exists():
|
| 181 |
-
try:
|
| 182 |
-
return container_readme.read_text(encoding="utf-8")
|
| 183 |
-
except Exception:
|
| 184 |
-
pass
|
| 185 |
-
|
| 186 |
# Try environment variable path
|
| 187 |
custom_path = os.environ.get("ENV_README_PATH")
|
| 188 |
if custom_path and Path(custom_path).exists():
|
|
@@ -191,12 +240,12 @@ def _load_readme_from_filesystem(env_name: Optional[str]) -> Optional[str]:
|
|
| 191 |
except Exception:
|
| 192 |
pass
|
| 193 |
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
if
|
| 198 |
try:
|
| 199 |
-
return
|
| 200 |
except Exception:
|
| 201 |
pass
|
| 202 |
|
|
@@ -248,13 +297,8 @@ class WebInterfaceManager:
|
|
| 248 |
observation_cls: Type[Observation],
|
| 249 |
metadata: Optional[EnvironmentMetadata] = None,
|
| 250 |
):
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
# If env is a class or factory function, instantiate it
|
| 254 |
-
if inspect.isclass(env) or inspect.isfunction(env):
|
| 255 |
-
self.env = env()
|
| 256 |
-
else:
|
| 257 |
-
self.env = env
|
| 258 |
self.action_cls = action_cls
|
| 259 |
self.observation_cls = observation_cls
|
| 260 |
self.metadata = metadata or EnvironmentMetadata(
|
|
@@ -347,7 +391,7 @@ class WebInterfaceManager:
|
|
| 347 |
"""Reset the environment and update state."""
|
| 348 |
reset_kwargs = reset_kwargs or {}
|
| 349 |
|
| 350 |
-
is_async = self.env.reset_async
|
| 351 |
sig = inspect.signature(self.env.reset_async if is_async else self.env.reset)
|
| 352 |
valid_kwargs = self._get_valid_kwargs(sig, reset_kwargs)
|
| 353 |
|
|
@@ -433,6 +477,14 @@ def create_web_interface_app(
|
|
| 433 |
max_concurrent_envs: Optional[int] = None,
|
| 434 |
concurrency_config: Optional[Any] = None,
|
| 435 |
gradio_builder: Optional[Callable[..., Any]] = None,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 436 |
) -> FastAPI:
|
| 437 |
"""
|
| 438 |
Create a FastAPI application with web interface for the given environment.
|
|
@@ -440,13 +492,36 @@ def create_web_interface_app(
|
|
| 440 |
Args:
|
| 441 |
env: The Environment instance to serve
|
| 442 |
action_cls: The Action subclass this environment expects
|
| 443 |
-
observation_cls: The Observation subclass
|
|
|
|
|
|
|
| 444 |
env_name: Optional environment name for README loading
|
| 445 |
max_concurrent_envs: Maximum concurrent WebSocket sessions
|
| 446 |
concurrency_config: Optional ConcurrencyConfig for advanced concurrency settings
|
| 447 |
gradio_builder: Optional callable (web_manager, action_fields, metadata,
|
| 448 |
is_chat_env, title, quick_start_md) -> gr.Blocks to use instead of the
|
| 449 |
default Gradio UI. Lets envs replace or customize the /web interface.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 450 |
|
| 451 |
Returns:
|
| 452 |
FastAPI application instance with web interface
|
|
@@ -455,7 +530,15 @@ def create_web_interface_app(
|
|
| 455 |
|
| 456 |
# Create the base environment app
|
| 457 |
app = create_fastapi_app(
|
| 458 |
-
env,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 459 |
)
|
| 460 |
|
| 461 |
# Load environment metadata
|
|
@@ -484,7 +567,7 @@ def create_web_interface_app(
|
|
| 484 |
async def websocket_ui_endpoint(websocket: WebSocket):
|
| 485 |
"""WebSocket endpoint for web UI real-time updates.
|
| 486 |
|
| 487 |
-
|
| 488 |
which is used for concurrent environment sessions.
|
| 489 |
"""
|
| 490 |
await web_manager.connect_websocket(websocket)
|
|
@@ -534,35 +617,19 @@ def create_web_interface_app(
|
|
| 534 |
is_chat_env = _is_chat_env(action_cls)
|
| 535 |
quick_start_md = get_quick_start_markdown(metadata, action_cls, observation_cls)
|
| 536 |
|
| 537 |
-
|
|
|
|
| 538 |
web_manager,
|
| 539 |
action_fields,
|
| 540 |
metadata,
|
| 541 |
is_chat_env,
|
| 542 |
-
|
| 543 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 544 |
)
|
| 545 |
-
if gradio_builder is not None:
|
| 546 |
-
custom_blocks = gradio_builder(
|
| 547 |
-
web_manager,
|
| 548 |
-
action_fields,
|
| 549 |
-
metadata,
|
| 550 |
-
is_chat_env,
|
| 551 |
-
metadata.name,
|
| 552 |
-
quick_start_md,
|
| 553 |
-
)
|
| 554 |
-
if not isinstance(custom_blocks, gr.Blocks):
|
| 555 |
-
raise TypeError(
|
| 556 |
-
f"gradio_builder must return a gr.Blocks instance, "
|
| 557 |
-
f"got {type(custom_blocks).__name__}"
|
| 558 |
-
)
|
| 559 |
-
gradio_blocks = gr.TabbedInterface(
|
| 560 |
-
[default_blocks, custom_blocks],
|
| 561 |
-
tab_names=["Playground", "Custom"],
|
| 562 |
-
title=get_gradio_display_title(metadata),
|
| 563 |
-
)
|
| 564 |
-
else:
|
| 565 |
-
gradio_blocks = default_blocks
|
| 566 |
app = gr.mount_gradio_app(
|
| 567 |
app,
|
| 568 |
gradio_blocks,
|
|
@@ -574,6 +641,65 @@ def create_web_interface_app(
|
|
| 574 |
return app
|
| 575 |
|
| 576 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 577 |
def _is_chat_env(action_cls: Type[Action]) -> bool:
|
| 578 |
"""Return True if the action class is a chat-style env (tokens field)."""
|
| 579 |
if hasattr(action_cls, "model_fields"):
|
|
|
|
| 1 |
+
# SPDX-License-Identifier: BSD-3-Clause
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
"""
|
| 4 |
Web interface for OpenEnv environments.
|
|
|
|
| 11 |
from __future__ import annotations
|
| 12 |
|
| 13 |
import asyncio
|
| 14 |
+
import functools
|
| 15 |
import inspect
|
| 16 |
import json
|
| 17 |
from concurrent.futures import ThreadPoolExecutor
|
| 18 |
from datetime import datetime
|
| 19 |
+
from typing import Any, Callable, Dict, get_origin, List, Optional, Tuple, Type
|
| 20 |
|
| 21 |
import gradio as gr
|
| 22 |
from fastapi import Body, FastAPI, HTTPException, status, WebSocket, WebSocketDisconnect
|
| 23 |
from fastapi.responses import RedirectResponse
|
| 24 |
from pydantic import BaseModel, ConfigDict, Field
|
| 25 |
|
| 26 |
+
from ._utils import overrides_method
|
| 27 |
from .gradio_theme import OPENENV_GRADIO_CSS, OPENENV_GRADIO_THEME
|
| 28 |
from .gradio_ui import build_gradio_app, get_gradio_display_title
|
| 29 |
from .interfaces import Environment
|
| 30 |
from .serialization import deserialize_action_with_preprocessing, serialize_observation
|
| 31 |
from .types import Action, EnvironmentMetadata, Observation, State
|
| 32 |
|
|
|
|
|
|
|
|
|
|
| 33 |
|
| 34 |
+
_CLIENT_BASES = {"EnvClient", "MCPClientBase", "MCPToolClient"}
|
| 35 |
|
|
|
|
|
|
|
| 36 |
|
| 37 |
+
def _read_package(package: str) -> Tuple[Optional[str], set]:
|
| 38 |
+
"""
|
| 39 |
+
The client class defined in the package's `client.py` and the names its
|
| 40 |
+
`__init__.py` exports. The files are parsed, not imported, so the server
|
| 41 |
+
never loads client code.
|
| 42 |
+
"""
|
| 43 |
+
import ast
|
| 44 |
+
import importlib.util
|
| 45 |
+
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
|
| 47 |
+
spec = importlib.util.find_spec(package)
|
| 48 |
+
if spec is None or not spec.submodule_search_locations:
|
| 49 |
+
return None, set()
|
| 50 |
+
root = Path(next(iter(spec.submodule_search_locations)))
|
| 51 |
+
|
| 52 |
+
def parse(name: str) -> Optional[ast.Module]:
|
| 53 |
+
path = root / name
|
| 54 |
+
return ast.parse(path.read_text()) if path.exists() else None
|
| 55 |
+
|
| 56 |
+
client = None
|
| 57 |
+
tree = parse("client.py")
|
| 58 |
+
for node in tree.body if tree else []:
|
| 59 |
+
if isinstance(node, ast.ClassDef):
|
| 60 |
+
bases = {ast.unparse(b).split("[")[0].split(".")[-1] for b in node.bases}
|
| 61 |
+
if bases & _CLIENT_BASES:
|
| 62 |
+
client = node.name
|
| 63 |
+
break
|
| 64 |
+
|
| 65 |
+
exports = set()
|
| 66 |
+
tree = parse("__init__.py")
|
| 67 |
+
for node in ast.walk(tree) if tree else []:
|
| 68 |
+
if isinstance(node, ast.ImportFrom):
|
| 69 |
+
exports.update(alias.asname or alias.name for alias in node.names)
|
| 70 |
+
elif isinstance(node, ast.Constant) and isinstance(node.value, str):
|
| 71 |
+
exports.add(node.value)
|
| 72 |
+
return client, exports
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def _example_value(annotation: Any) -> str:
|
| 76 |
+
"""A placeholder literal for a field of the given type."""
|
| 77 |
+
origin = get_origin(annotation) or annotation
|
| 78 |
+
literals = {
|
| 79 |
+
bool: "False",
|
| 80 |
+
int: "0",
|
| 81 |
+
float: "0.0",
|
| 82 |
+
str: '"..."',
|
| 83 |
+
list: "[]",
|
| 84 |
+
dict: "{}",
|
| 85 |
+
}
|
| 86 |
+
return literals.get(origin, "...")
|
| 87 |
|
| 88 |
|
| 89 |
def get_quick_start_markdown(
|
|
|
|
| 92 |
observation_cls: Type[Observation],
|
| 93 |
) -> str:
|
| 94 |
"""
|
| 95 |
+
Build the Quick Start markdown shown next to the web interface.
|
| 96 |
|
| 97 |
+
The client class is the one the environment package exports, the URL is the
|
| 98 |
+
Space's own when running on Hugging Face Spaces, and the example action uses
|
| 99 |
+
the action's real fields.
|
| 100 |
"""
|
| 101 |
import os
|
| 102 |
|
| 103 |
+
from .mcp_types import CallToolAction
|
| 104 |
+
|
| 105 |
+
package = (metadata.name if metadata else "env").replace(" ", "_").lower()
|
| 106 |
+
client, exports = _read_package(package)
|
| 107 |
+
if client is None:
|
| 108 |
+
# The env name can differ from its package (reasoning_gym vs reasoning_gym_env).
|
| 109 |
+
module_root = action_cls.__module__.split(".")[0]
|
| 110 |
+
if module_root not in (package, "openenv", "models", "server"):
|
| 111 |
+
package = module_root
|
| 112 |
+
client, exports = _read_package(package)
|
| 113 |
+
space_id = os.environ.get("SPACE_ID")
|
| 114 |
+
space_host = os.environ.get("SPACE_HOST")
|
| 115 |
+
base_url = f"https://{space_host}" if space_host else "http://localhost:8000"
|
| 116 |
+
|
| 117 |
+
if client is None:
|
| 118 |
+
return (
|
| 119 |
+
"### Connect to this environment\n\n"
|
| 120 |
+
f"The server is at `{base_url}`. See the environment's README for how to connect."
|
| 121 |
+
)
|
| 122 |
|
| 123 |
+
lines = ["### Connect to this environment", ""]
|
| 124 |
+
if space_id:
|
| 125 |
+
lines += [
|
| 126 |
+
"```bash",
|
| 127 |
+
f"pip install git+https://huggingface.co/spaces/{space_id}",
|
| 128 |
+
"```",
|
| 129 |
+
"",
|
| 130 |
+
]
|
| 131 |
+
|
| 132 |
+
if issubclass(action_cls, CallToolAction):
|
| 133 |
+
code = [
|
| 134 |
+
f"from {package} import {client}",
|
| 135 |
+
"",
|
| 136 |
+
f'with {client}(base_url="{base_url}").sync() as env:',
|
| 137 |
+
" env.reset()",
|
| 138 |
+
" print([tool.name for tool in env.list_tools()])",
|
| 139 |
+
]
|
| 140 |
+
after = "Then call a tool with `env.call_tool(name, **arguments)`."
|
| 141 |
+
else:
|
| 142 |
+
action = action_cls.__name__
|
| 143 |
+
exported = action in exports
|
| 144 |
+
action_module = package if exported else action_cls.__module__
|
| 145 |
+
fields = ", ".join(
|
| 146 |
+
f"{name}={_example_value(field.annotation)}"
|
| 147 |
+
for name, field in action_cls.model_fields.items()
|
| 148 |
+
if field.is_required() and name != "metadata"
|
| 149 |
+
)
|
| 150 |
+
code = [
|
| 151 |
+
*(
|
| 152 |
+
[f"from {package} import {action}, {client}"]
|
| 153 |
+
if exported
|
| 154 |
+
else [
|
| 155 |
+
f"from {action_module} import {action}",
|
| 156 |
+
f"from {package} import {client}",
|
| 157 |
+
]
|
| 158 |
+
),
|
| 159 |
+
"",
|
| 160 |
+
f'with {client}(base_url="{base_url}").sync() as env:',
|
| 161 |
+
" result = env.reset()",
|
| 162 |
+
f" result = env.step({action}({fields}))",
|
| 163 |
+
" print(result.observation, result.reward)",
|
| 164 |
+
]
|
| 165 |
+
after = ""
|
| 166 |
+
|
| 167 |
+
lines += ["```python", *code, "```"]
|
| 168 |
+
if after:
|
| 169 |
+
lines += ["", after]
|
| 170 |
+
lines += [
|
| 171 |
+
"",
|
| 172 |
+
"More in the [OpenEnv documentation](https://huggingface.co/docs/openenv).",
|
| 173 |
+
]
|
| 174 |
+
return "\n".join(lines)
|
| 175 |
|
| 176 |
|
| 177 |
def load_environment_metadata(
|
|
|
|
| 181 |
Load environment metadata including README content.
|
| 182 |
|
| 183 |
Args:
|
| 184 |
+
env: The environment instance or factory (class, function or
|
| 185 |
+
`functools.partial`). A factory is not instantiated. If an instance,
|
| 186 |
+
`get_metadata()` is called.
|
| 187 |
+
env_name (`str`, *optional*):
|
| 188 |
+
Optional environment name for README file lookup.
|
| 189 |
|
| 190 |
Returns:
|
| 191 |
+
`EnvironmentMetadata` with loaded information.
|
| 192 |
"""
|
| 193 |
+
# An Environment instance provides its own metadata. Anything else is a factory:
|
| 194 |
+
# a class (e.g. PythonCodeActEnv), a factory function (e.g.
|
| 195 |
+
# create_chat_environment) or a `functools.partial` of either.
|
| 196 |
+
if isinstance(env, Environment):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 197 |
return env.get_metadata()
|
| 198 |
|
| 199 |
+
if isinstance(env, functools.partial):
|
| 200 |
+
env = env.func
|
| 201 |
+
|
| 202 |
# Determine the class name for default metadata
|
| 203 |
+
if inspect.isclass(env):
|
|
|
|
| 204 |
class_name = env.__name__
|
|
|
|
|
|
|
|
|
|
| 205 |
else:
|
| 206 |
+
class_name = env_name or env.__name__
|
|
|
|
| 207 |
|
| 208 |
# Default metadata
|
| 209 |
metadata = EnvironmentMetadata(
|
|
|
|
| 224 |
"""
|
| 225 |
Load README content from the filesystem.
|
| 226 |
|
| 227 |
+
Tries multiple locations in order: the path given by the `ENV_README_PATH`
|
| 228 |
+
environment variable, the repository path `envs/{env_name}/README.md`, and the
|
| 229 |
+
container paths `/app/env/README.md` (where Space images copy the environment)
|
| 230 |
+
and `/app/README.md`.
|
| 231 |
"""
|
| 232 |
import os
|
| 233 |
from pathlib import Path
|
| 234 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 235 |
# Try environment variable path
|
| 236 |
custom_path = os.environ.get("ENV_README_PATH")
|
| 237 |
if custom_path and Path(custom_path).exists():
|
|
|
|
| 240 |
except Exception:
|
| 241 |
pass
|
| 242 |
|
| 243 |
+
candidates = [Path(f"envs/{env_name}/README.md")] if env_name else []
|
| 244 |
+
candidates += [Path("/app/env/README.md"), Path("/app/README.md")]
|
| 245 |
+
for readme in candidates:
|
| 246 |
+
if readme.exists():
|
| 247 |
try:
|
| 248 |
+
return readme.read_text(encoding="utf-8")
|
| 249 |
except Exception:
|
| 250 |
pass
|
| 251 |
|
|
|
|
| 297 |
observation_cls: Type[Observation],
|
| 298 |
metadata: Optional[EnvironmentMetadata] = None,
|
| 299 |
):
|
| 300 |
+
# Anything that is not an Environment instance is a factory, instantiate it
|
| 301 |
+
self.env = env if isinstance(env, Environment) else env()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 302 |
self.action_cls = action_cls
|
| 303 |
self.observation_cls = observation_cls
|
| 304 |
self.metadata = metadata or EnvironmentMetadata(
|
|
|
|
| 391 |
"""Reset the environment and update state."""
|
| 392 |
reset_kwargs = reset_kwargs or {}
|
| 393 |
|
| 394 |
+
is_async = overrides_method(self.env.reset_async, Environment.reset_async)
|
| 395 |
sig = inspect.signature(self.env.reset_async if is_async else self.env.reset)
|
| 396 |
valid_kwargs = self._get_valid_kwargs(sig, reset_kwargs)
|
| 397 |
|
|
|
|
| 477 |
max_concurrent_envs: Optional[int] = None,
|
| 478 |
concurrency_config: Optional[Any] = None,
|
| 479 |
gradio_builder: Optional[Callable[..., Any]] = None,
|
| 480 |
+
custom_tab_name: str = "Custom",
|
| 481 |
+
custom_tab_primary: bool = False,
|
| 482 |
+
show_default_tab: bool = True,
|
| 483 |
+
title_override: Optional[str] = None,
|
| 484 |
+
state_cls: Type[State] = State,
|
| 485 |
+
*,
|
| 486 |
+
mode: Optional[Any] = None,
|
| 487 |
+
reset_observation_cls: Optional[Type[Observation]] = None,
|
| 488 |
) -> FastAPI:
|
| 489 |
"""
|
| 490 |
Create a FastAPI application with web interface for the given environment.
|
|
|
|
| 492 |
Args:
|
| 493 |
env: The Environment instance to serve
|
| 494 |
action_cls: The Action subclass this environment expects
|
| 495 |
+
observation_cls: The Observation subclass returned by step
|
| 496 |
+
reset_observation_cls: The reset observation model published in /schema.
|
| 497 |
+
Defaults to observation_cls.
|
| 498 |
env_name: Optional environment name for README loading
|
| 499 |
max_concurrent_envs: Maximum concurrent WebSocket sessions
|
| 500 |
concurrency_config: Optional ConcurrencyConfig for advanced concurrency settings
|
| 501 |
gradio_builder: Optional callable (web_manager, action_fields, metadata,
|
| 502 |
is_chat_env, title, quick_start_md) -> gr.Blocks to use instead of the
|
| 503 |
default Gradio UI. Lets envs replace or customize the /web interface.
|
| 504 |
+
custom_tab_name: Label shown on the env-specific tab when ``gradio_builder``
|
| 505 |
+
is provided. Defaults to ``"Custom"`` for backwards compatibility; envs
|
| 506 |
+
that ship a rich custom UI should pass a descriptive name
|
| 507 |
+
(e.g. ``"REPL"``). Ignored when ``show_default_tab=False`` (no tab chrome
|
| 508 |
+
is rendered).
|
| 509 |
+
custom_tab_primary: When True, the env-specific tab is rendered first and
|
| 510 |
+
selected by default; the auto-generated Playground becomes secondary.
|
| 511 |
+
Use this for envs whose custom tab is the real interaction surface
|
| 512 |
+
(so visitors don't land on a less informative schema form). Ignored
|
| 513 |
+
when ``show_default_tab=False``.
|
| 514 |
+
show_default_tab: When False, the auto-generated Playground tab is not
|
| 515 |
+
rendered and the env's ``gradio_builder`` output is mounted directly
|
| 516 |
+
(single-view UI, no tab chrome). Only meaningful when
|
| 517 |
+
``gradio_builder`` is provided.
|
| 518 |
+
title_override: If set, used verbatim as the Gradio app/browser-tab
|
| 519 |
+
title instead of the default ``"OpenEnv Agentic Environment: {name}"``.
|
| 520 |
+
state_cls: The State subclass this environment reports. Used for the /state
|
| 521 |
+
response model and the state entry of /schema. Defaults to State.
|
| 522 |
+
mode: Server mode (``ServerMode`` or string). When ``None``, resolved
|
| 523 |
+
from the ``OPENENV_MODE`` environment variable, defaulting to
|
| 524 |
+
simulation.
|
| 525 |
|
| 526 |
Returns:
|
| 527 |
FastAPI application instance with web interface
|
|
|
|
| 530 |
|
| 531 |
# Create the base environment app
|
| 532 |
app = create_fastapi_app(
|
| 533 |
+
env,
|
| 534 |
+
action_cls,
|
| 535 |
+
observation_cls,
|
| 536 |
+
max_concurrent_envs,
|
| 537 |
+
concurrency_config,
|
| 538 |
+
env_name=env_name,
|
| 539 |
+
state_cls=state_cls,
|
| 540 |
+
reset_observation_cls=reset_observation_cls,
|
| 541 |
+
mode=mode,
|
| 542 |
)
|
| 543 |
|
| 544 |
# Load environment metadata
|
|
|
|
| 567 |
async def websocket_ui_endpoint(websocket: WebSocket):
|
| 568 |
"""WebSocket endpoint for web UI real-time updates.
|
| 569 |
|
| 570 |
+
Uses `/ws/ui` to avoid conflict with `/ws` in `http_server.py`,
|
| 571 |
which is used for concurrent environment sessions.
|
| 572 |
"""
|
| 573 |
await web_manager.connect_websocket(websocket)
|
|
|
|
| 617 |
is_chat_env = _is_chat_env(action_cls)
|
| 618 |
quick_start_md = get_quick_start_markdown(metadata, action_cls, observation_cls)
|
| 619 |
|
| 620 |
+
display_title = title_override or get_gradio_display_title(metadata)
|
| 621 |
+
gradio_blocks = _build_gradio_blocks(
|
| 622 |
web_manager,
|
| 623 |
action_fields,
|
| 624 |
metadata,
|
| 625 |
is_chat_env,
|
| 626 |
+
quick_start_md,
|
| 627 |
+
display_title=display_title,
|
| 628 |
+
gradio_builder=gradio_builder,
|
| 629 |
+
custom_tab_name=custom_tab_name,
|
| 630 |
+
custom_tab_primary=custom_tab_primary,
|
| 631 |
+
show_default_tab=show_default_tab,
|
| 632 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 633 |
app = gr.mount_gradio_app(
|
| 634 |
app,
|
| 635 |
gradio_blocks,
|
|
|
|
| 641 |
return app
|
| 642 |
|
| 643 |
|
| 644 |
+
def _build_gradio_blocks(
|
| 645 |
+
web_manager: WebInterfaceManager,
|
| 646 |
+
action_fields: List[Dict[str, Any]],
|
| 647 |
+
metadata: EnvironmentMetadata,
|
| 648 |
+
is_chat_env: bool,
|
| 649 |
+
quick_start_md: str,
|
| 650 |
+
*,
|
| 651 |
+
display_title: str,
|
| 652 |
+
gradio_builder: Optional[Callable[..., Any]],
|
| 653 |
+
custom_tab_name: str,
|
| 654 |
+
custom_tab_primary: bool,
|
| 655 |
+
show_default_tab: bool,
|
| 656 |
+
) -> gr.Blocks:
|
| 657 |
+
"""Build the Gradio block tree mounted at `/web`."""
|
| 658 |
+
default_blocks = build_gradio_app(
|
| 659 |
+
web_manager,
|
| 660 |
+
action_fields,
|
| 661 |
+
metadata,
|
| 662 |
+
is_chat_env,
|
| 663 |
+
title=metadata.name,
|
| 664 |
+
quick_start_md=quick_start_md,
|
| 665 |
+
)
|
| 666 |
+
default_blocks.title = display_title
|
| 667 |
+
|
| 668 |
+
if gradio_builder is None:
|
| 669 |
+
return default_blocks
|
| 670 |
+
|
| 671 |
+
custom_blocks = gradio_builder(
|
| 672 |
+
web_manager,
|
| 673 |
+
action_fields,
|
| 674 |
+
metadata,
|
| 675 |
+
is_chat_env,
|
| 676 |
+
display_title,
|
| 677 |
+
quick_start_md,
|
| 678 |
+
)
|
| 679 |
+
if not isinstance(custom_blocks, gr.Blocks):
|
| 680 |
+
raise TypeError(
|
| 681 |
+
f"gradio_builder must return a gr.Blocks instance, "
|
| 682 |
+
f"got {type(custom_blocks).__name__}"
|
| 683 |
+
)
|
| 684 |
+
|
| 685 |
+
if not show_default_tab:
|
| 686 |
+
# No TabbedInterface wrapper to carry the app title.
|
| 687 |
+
custom_blocks.title = display_title
|
| 688 |
+
return custom_blocks
|
| 689 |
+
|
| 690 |
+
if custom_tab_primary:
|
| 691 |
+
tab_blocks = [custom_blocks, default_blocks]
|
| 692 |
+
tab_labels = [custom_tab_name, "Playground"]
|
| 693 |
+
else:
|
| 694 |
+
tab_blocks = [default_blocks, custom_blocks]
|
| 695 |
+
tab_labels = ["Playground", custom_tab_name]
|
| 696 |
+
|
| 697 |
+
# The playground has its own header, so the title only names the browser tab.
|
| 698 |
+
tabbed = gr.TabbedInterface(tab_blocks, tab_names=tab_labels)
|
| 699 |
+
tabbed.title = display_title
|
| 700 |
+
return tabbed
|
| 701 |
+
|
| 702 |
+
|
| 703 |
def _is_chat_env(action_cls: Type[Action]) -> bool:
|
| 704 |
"""Return True if the action class is a chat-style env (tokens field)."""
|
| 705 |
if hasattr(action_cls, "model_fields"):
|
src/core/evals/__init__.py
CHANGED
|
@@ -1,8 +1,4 @@
|
|
| 1 |
-
#
|
| 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 |
"""Evaluation harness support for OpenEnv."""
|
| 8 |
|
|
|
|
| 1 |
+
# SPDX-License-Identifier: BSD-3-Clause
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
"""Evaluation harness support for OpenEnv."""
|
| 4 |
|
src/core/evals/base.py
CHANGED
|
@@ -1,8 +1,4 @@
|
|
| 1 |
-
#
|
| 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 class for evaluation harnesses."""
|
| 8 |
|
|
|
|
| 1 |
+
# SPDX-License-Identifier: BSD-3-Clause
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
"""Base class for evaluation harnesses."""
|
| 4 |
|