sergiopaniego HF Staff commited on
Commit
45a291e
·
verified ·
1 Parent(s): f818586

Upload folder using huggingface_hub

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. Dockerfile +5 -5
  2. README.md +95 -189
  3. client.py +2 -1
  4. envs/repl_env/README.md +73 -172
  5. envs/repl_env/client.py +2 -1
  6. envs/repl_env/pyproject.toml +4 -2
  7. envs/repl_env/runner.py +12 -4
  8. envs/repl_env/server/Dockerfile +3 -3
  9. envs/repl_env/server/app.py +26 -7
  10. envs/repl_env/server/gradio_ui.py +330 -77
  11. envs/repl_env/server/repl_environment.py +39 -9
  12. pyproject.toml +4 -2
  13. runner.py +12 -4
  14. server/Dockerfile +3 -3
  15. server/app.py +26 -7
  16. server/gradio_ui.py +330 -77
  17. server/repl_environment.py +39 -9
  18. src/__init__.py +1 -5
  19. src/core/README.md +46 -21
  20. src/core/__init__.py +1 -5
  21. src/core/client_types.py +10 -4
  22. src/core/containers/__init__.py +1 -5
  23. src/core/containers/images/Dockerfile +1 -5
  24. src/core/containers/runtime/__init__.py +6 -5
  25. src/core/containers/runtime/_server_config.py +53 -0
  26. src/core/containers/runtime/aca_provider.py +690 -0
  27. src/core/containers/runtime/daytona_provider.py +31 -56
  28. src/core/containers/runtime/hf_sandbox_provider.py +342 -0
  29. src/core/containers/runtime/modal_provider.py +697 -0
  30. src/core/containers/runtime/novita_provider.py +1216 -0
  31. src/core/containers/runtime/providers.py +128 -70
  32. src/core/containers/runtime/uv_provider.py +128 -43
  33. src/core/containers/test_local_docker_provider.py +1 -1
  34. src/core/env_client.py +914 -201
  35. src/core/env_server/__init__.py +11 -6
  36. src/core/env_server/_utils.py +10 -0
  37. src/core/env_server/base_transforms.py +1 -5
  38. src/core/env_server/exceptions.py +1 -5
  39. src/core/env_server/gradio_theme.py +105 -91
  40. src/core/env_server/gradio_ui.py +642 -172
  41. src/core/env_server/http_server.py +737 -160
  42. src/core/env_server/interfaces.py +239 -65
  43. src/core/env_server/mcp_environment.py +64 -55
  44. src/core/env_server/mcp_types.py +3 -8
  45. src/core/env_server/route_config.py +17 -17
  46. src/core/env_server/serialization.py +64 -50
  47. src/core/env_server/types.py +45 -6
  48. src/core/env_server/web_interface.py +265 -139
  49. src/core/evals/__init__.py +1 -5
  50. 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/meta-pytorch/openenv-base:latest
14
- FROM ghcr.io/meta-pytorch/openenv-base:latest AS builder
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-core is already in the pyproject.toml dependencies
26
- # For standalone builds, openenv-core 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,7 +59,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
59
  fi
60
 
61
  # Final runtime stage
62
- FROM ghcr.io/meta-pytorch/openenv-base:latest
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.2.3
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 an OpenEnv-native Python REPL environment for Recursive Language Model style execution. It now follows the current OpenEnv client/server conventions:
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 code across multiple turns with persistent state
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
- ## Current Architecture
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
- Custom rubrics can be injected at construction:
 
 
115
 
116
- ```python
117
- from repl_env import LocalREPLEnv, CustomMetricRubric, REPLRubric
118
 
119
- def my_metric(expected, predicted):
120
- return 1.0 if expected.strip() == predicted.strip() else 0.0
121
 
122
- env = LocalREPLEnv(rubric=REPLRubric(outcome=CustomMetricRubric(my_metric)))
 
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
- ### Local Environment Usage
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
- ### Local Recursive RLM Usage
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- ### Using Different Models for Outer and Inner Loops
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
- ## Server
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
- ## API Surface
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
- ## Injected REPL Helpers
343
 
344
  When configured, the REPL namespace exposes:
345
 
346
- - `llm_query(prompt, model=None)`
347
- - `llm_query_batched(prompts, model=None)`
348
- - `rlm_query(prompt, model=None)`
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
- ## Prompt Utilities
386
-
387
- [`prompts.py`](prompts.py) contains the current message-building and parsing helpers used by the examples and runner.
388
 
389
- Important exports:
390
 
391
- - `RLM_SYSTEM_PROMPT`
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 an OpenEnv-native Python REPL environment for Recursive Language Model style execution. It now follows the current OpenEnv client/server conventions:
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 code across multiple turns with persistent state
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
- ## Current Architecture
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
- - **Outcome reward** (on terminal steps): compares `final_answer` against
79
- `expected_answer` if provided. Returns 1.0 for match, 0.0 otherwise.
80
- - **Process reward** (on non-terminal steps): returns -0.05 for code
81
- execution errors, 0.0 for successful steps.
82
- - **Failure reward**: returns -0.1 when max iterations exhausted without an answer.
83
 
84
- For RL training (GRPO, etc.), pass `expected_answer` at reset time:
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
- def my_metric(expected, predicted):
103
- return 1.0 if expected.strip() == predicted.strip() else 0.0
104
 
105
- env = LocalREPLEnv(rubric=REPLRubric(outcome=CustomMetricRubric(my_metric)))
 
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
- ### Local Environment Usage
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
- ### Local Recursive RLM Usage
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- ### Using Different Models for Outer and Inner Loops
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
- ## Server
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
- ### Actions and Observations
302
 
303
  `REPLAction`
304
 
@@ -322,25 +261,13 @@ reward: float | None
322
  metadata: dict
323
  ```
324
 
325
- ## Injected REPL Helpers
326
 
327
  When configured, the REPL namespace exposes:
328
 
329
- - `llm_query(prompt, model=None)`
330
- - `llm_query_batched(prompts, model=None)`
331
- - `rlm_query(prompt, model=None)`
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
- ## Prompt Utilities
369
-
370
- [`prompts.py`](prompts.py) contains the current message-building and parsing helpers used by the examples and runner.
371
 
372
- Important exports:
373
 
374
- - `RLM_SYSTEM_PROMPT`
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-core[core] @ git+https://github.com/meta-pytorch/OpenEnv.git@v0.2.3",
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>=8.0.0",
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
- match = re.search(r"FINAL\((.*?)\)", response, re.DOTALL)
248
- if match:
249
- return match.group(1).strip()
 
 
 
 
 
 
 
 
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/meta-pytorch/openenv-base:latest
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-core is already in the pyproject.toml dependencies
26
- # For standalone builds, openenv-core 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)
 
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
- env_name="repl_env",
102
- max_concurrent_envs=8,
103
- gradio_builder=build_repl_gradio_app,
104
  )
105
  else:
106
  _logger.warning(
107
- "Installed openenv-core does not support gradio_builder; "
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=8,
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(data: Dict[str, Any]) -> str:
25
- """Render REPL observations in a compact Markdown view."""
 
 
 
 
 
 
 
 
 
26
  observation = data.get("observation", {})
27
  result = observation.get("result", {})
28
- sections: List[str] = ["# REPL Session"]
29
 
30
- context_preview = observation.get("context_preview")
31
- if context_preview:
32
- sections.append(_code_block("Context Preview", context_preview))
33
 
34
- task_prompt = observation.get("task_prompt")
35
- if task_prompt:
36
- sections.append(_code_block("Task Prompt", task_prompt))
 
37
 
38
- available_variables = observation.get("available_variables") or []
39
- if available_variables:
40
- sections.append(
41
- "**Available Variables:** " + ", ".join(f"`{name}`" for name in available_variables)
42
- )
43
 
44
- if result.get("locals_snapshot"):
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
- stdout = result.get("stdout", "")
52
- stderr = result.get("stderr", "")
53
- sections.append(_code_block("Stdout", stdout))
54
- sections.append(_code_block("Stderr", stderr))
55
 
 
56
  reward = data.get("reward")
 
 
57
  done = data.get("done")
58
- sections.append(f"**Reward:** `{reward}`")
59
- sections.append(f"**Done:** `{done}`")
 
 
 
 
 
 
 
 
60
 
61
- return "\n\n".join(section for section in sections if section)
 
 
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, metadata, quick_start_md
 
 
 
 
 
 
74
 
75
  async def reset_repl(
76
- context: str,
77
- task_prompt: str,
78
- hf_token: str,
79
- llm_model: str,
 
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 hf_token.strip():
87
- reset_kwargs["hf_token"] = hf_token
88
- if llm_model.strip():
 
 
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
- "REPL reset complete.",
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
- with gr.Blocks(title=f"{title} - REPL") as blocks:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
126
  gr.Markdown(
127
- "# REPL Control Panel\n\n"
128
- "Load a problem into the REPL, execute Python, and inspect state without "
129
- "leaving the Space."
130
  )
 
131
  with gr.Row():
132
  with gr.Column(scale=2):
133
- context = gr.Textbox(
134
- label="Context",
135
- placeholder="Problem context or source text...",
136
- lines=8,
137
- )
 
 
 
 
 
 
138
  task_prompt = gr.Textbox(
139
  label="Task Prompt",
140
  placeholder="What should the agent solve?",
141
- lines=3,
142
  )
143
- with gr.Accordion("Optional Model Settings", open=False):
 
 
 
 
 
 
 
144
  hf_token = gr.Textbox(
145
- label="Hugging Face Token",
146
- placeholder="Used only for this reset; not persisted in state",
147
  type="password",
148
  )
 
 
 
 
149
  llm_model = gr.Textbox(
150
  label="LLM Model",
151
- placeholder="Optional override for llm_query / rlm_query",
 
 
 
 
152
  )
 
 
 
 
 
 
 
 
 
153
  code = gr.Textbox(
154
  label="Python Code",
155
  placeholder="count = len(context.split())",
156
- lines=10,
157
  )
158
  with gr.Row():
159
- reset_btn = gr.Button("Reset", variant="secondary")
160
- run_btn = gr.Button("Run", variant="primary")
161
- state_btn = gr.Button("Get state", variant="secondary")
162
- status = gr.Textbox(label="Status", interactive=False)
163
- with gr.Column(scale=3):
164
- session_view = gr.Markdown(
165
- value="# REPL Session\n\nReset the environment to start."
166
- )
167
- raw_json = gr.Code(
168
- label="Raw JSON response",
169
- language="json",
170
- interactive=False,
171
- )
172
- state_json = gr.Code(
173
- label="Session state",
174
- language="json",
175
- interactive=False,
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 CodeBlockResult, REPLAction, REPLObservation, REPLState
 
 
 
 
 
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 = llm_model or os.environ.get("LLM_MODEL", "Qwen/Qwen3.5-9B")
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
- if not self.llm_query_fn:
 
 
 
 
 
 
 
 
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
- final_match = re.search(r"FINAL\((.*?)\)", stdout, re.DOTALL)
519
- if final_match:
520
- return final_match.group(1).strip()
 
 
 
 
 
 
 
 
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-core[core] @ git+https://github.com/meta-pytorch/OpenEnv.git@v0.2.3",
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>=8.0.0",
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
- match = re.search(r"FINAL\((.*?)\)", response, re.DOTALL)
248
- if match:
249
- return match.group(1).strip()
 
 
 
 
 
 
 
 
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/meta-pytorch/openenv-base:latest
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-core is already in the pyproject.toml dependencies
26
- # For standalone builds, openenv-core 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)
 
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
- env_name="repl_env",
102
- max_concurrent_envs=8,
103
- gradio_builder=build_repl_gradio_app,
104
  )
105
  else:
106
  _logger.warning(
107
- "Installed openenv-core does not support gradio_builder; "
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=8,
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(data: Dict[str, Any]) -> str:
25
- """Render REPL observations in a compact Markdown view."""
 
 
 
 
 
 
 
 
 
26
  observation = data.get("observation", {})
27
  result = observation.get("result", {})
28
- sections: List[str] = ["# REPL Session"]
29
 
30
- context_preview = observation.get("context_preview")
31
- if context_preview:
32
- sections.append(_code_block("Context Preview", context_preview))
33
 
34
- task_prompt = observation.get("task_prompt")
35
- if task_prompt:
36
- sections.append(_code_block("Task Prompt", task_prompt))
 
37
 
38
- available_variables = observation.get("available_variables") or []
39
- if available_variables:
40
- sections.append(
41
- "**Available Variables:** " + ", ".join(f"`{name}`" for name in available_variables)
42
- )
43
 
44
- if result.get("locals_snapshot"):
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
- stdout = result.get("stdout", "")
52
- stderr = result.get("stderr", "")
53
- sections.append(_code_block("Stdout", stdout))
54
- sections.append(_code_block("Stderr", stderr))
55
 
 
56
  reward = data.get("reward")
 
 
57
  done = data.get("done")
58
- sections.append(f"**Reward:** `{reward}`")
59
- sections.append(f"**Done:** `{done}`")
 
 
 
 
 
 
 
 
60
 
61
- return "\n\n".join(section for section in sections if section)
 
 
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, metadata, quick_start_md
 
 
 
 
 
 
74
 
75
  async def reset_repl(
76
- context: str,
77
- task_prompt: str,
78
- hf_token: str,
79
- llm_model: str,
 
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 hf_token.strip():
87
- reset_kwargs["hf_token"] = hf_token
88
- if llm_model.strip():
 
 
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
- "REPL reset complete.",
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
- with gr.Blocks(title=f"{title} - REPL") as blocks:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
126
  gr.Markdown(
127
- "# REPL Control Panel\n\n"
128
- "Load a problem into the REPL, execute Python, and inspect state without "
129
- "leaving the Space."
130
  )
 
131
  with gr.Row():
132
  with gr.Column(scale=2):
133
- context = gr.Textbox(
134
- label="Context",
135
- placeholder="Problem context or source text...",
136
- lines=8,
137
- )
 
 
 
 
 
 
138
  task_prompt = gr.Textbox(
139
  label="Task Prompt",
140
  placeholder="What should the agent solve?",
141
- lines=3,
142
  )
143
- with gr.Accordion("Optional Model Settings", open=False):
 
 
 
 
 
 
 
144
  hf_token = gr.Textbox(
145
- label="Hugging Face Token",
146
- placeholder="Used only for this reset; not persisted in state",
147
  type="password",
148
  )
 
 
 
 
149
  llm_model = gr.Textbox(
150
  label="LLM Model",
151
- placeholder="Optional override for llm_query / rlm_query",
 
 
 
 
152
  )
 
 
 
 
 
 
 
 
 
153
  code = gr.Textbox(
154
  label="Python Code",
155
  placeholder="count = len(context.split())",
156
- lines=10,
157
  )
158
  with gr.Row():
159
- reset_btn = gr.Button("Reset", variant="secondary")
160
- run_btn = gr.Button("Run", variant="primary")
161
- state_btn = gr.Button("Get state", variant="secondary")
162
- status = gr.Textbox(label="Status", interactive=False)
163
- with gr.Column(scale=3):
164
- session_view = gr.Markdown(
165
- value="# REPL Session\n\nReset the environment to start."
166
- )
167
- raw_json = gr.Code(
168
- label="Raw JSON response",
169
- language="json",
170
- interactive=False,
171
- )
172
- state_json = gr.Code(
173
- label="Session state",
174
- language="json",
175
- interactive=False,
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 CodeBlockResult, REPLAction, REPLObservation, REPLState
 
 
 
 
 
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 = llm_model or os.environ.get("LLM_MODEL", "Qwen/Qwen3.5-9B")
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
- if not self.llm_query_fn:
 
 
 
 
 
 
 
 
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
- final_match = re.search(r"FINAL\((.*?)\)", stdout, re.DOTALL)
519
- if final_match:
520
- return final_match.group(1).strip()
 
 
 
 
 
 
 
 
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
- # 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
  """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
- # <img width="35" height="35" alt="image" src="https://github.com/user-attachments/assets/2700a971-e5d6-4036-b03f-2f89c9791609" /> OpenEnv: Agentic Execution Environments
2
 
3
  An e2e framework for creating, deploying and using isolated execution environments for agentic RL training, built using Gymnasium style simple APIs. OpenEnv provides a standard for interacting with agentic execution environments via simple Gymnasium style APIs - step(), reset(), state(). Users of agentic execution environments can interact with the environment during RL training loops using these simple APIs.
4
 
5
  In addition to making it easier for researchers and RL framework writers, we also provide tools for environment creators making it easier for them to create richer environments and make them available over familiar protocols like HTTP and packaged using canonical technologies like docker. Environment creators can use the OpenEnv framework to create environments that are isolated, secure, and easy to deploy and use.
6
 
 
 
7
 
8
  ## Overview
9
  `openenv.core` provides the foundational building blocks for creating and interacting with containerized environments over HTTP. It enables you to build agent environments that can be deployed as Docker containers and accessed via a simple HTTP API.
10
 
11
- > ⚠️ **Early Development Warning** OpenEnv is currently in an experimental
12
- > stage. You should expect bugs, incomplete features, and APIs that may change
13
- > in future versions. The project welcomes bugfixes, but to make sure things are
14
- > well coordinated you should discuss any significant change before starting the
15
- > work. It's recommended that you signal your intention to contribute in the
16
- > issue tracker, either by filing a new issue or by claiming an existing one.
17
-
18
-
19
  # OpenEnv Core
20
 
21
  Core components for OpenEnv - a framework for building HTTP-based agentic environments.
@@ -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[core]"
36
  ```
37
 
38
  For development:
39
  ```bash
40
- pip install "openenv[core]"
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
- response=f"Echo: {action.text}",
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-3-Clause License - see the LICENSE file for details.
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/meta-pytorch/OpenEnv
211
- - **Documentation**: https://github.com/meta-pytorch/OpenEnv/blob/main/README.md
212
- - **Bug Tracker**: https://github.com/meta-pytorch/OpenEnv/issues
 
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
- # Copyright (c) Meta Platforms, Inc. and affiliates.
2
- # All rights reserved.
3
- #
4
- # This source code is licensed under the BSD-style license found in the
5
- # LICENSE file in the root directory of this source tree.
6
 
7
  """Core components for agentic environments."""
8
 
 
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: The environment's observation after the action.
17
- reward: Scalar reward for this step (optional).
18
- done: Whether the episode is finished.
 
 
 
 
 
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
- # Copyright (c) Meta Platforms, Inc. and affiliates.
2
- # All rights reserved.
3
- #
4
- # This source code is licensed under the BSD-style license found in the
5
- # LICENSE file in the root directory of this source tree.
6
 
7
  """Container management for environment servers."""
 
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
- # Copyright (c) Meta Platforms, Inc. and affiliates.
2
- # All rights reserved.
3
- #
4
- # This source code is licensed under the BSD-style license found in the
5
- # LICENSE file in the root directory of this source tree.
6
 
7
  #
8
  # OpenEnv Base Image
 
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
- # Copyright (c) Meta Platforms, Inc. and affiliates.
2
- # All rights reserved.
3
- #
4
- # This source code is licensed under the BSD-style license found in the
5
- # LICENSE file in the root directory of this source tree.
6
 
7
  """Container runtime providers."""
8
 
@@ -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
- # Copyright (c) Meta Platforms, Inc. and affiliates.
2
- # All rights reserved.
3
- #
4
- # This source code is licensed under the BSD-style license found in the
5
- # LICENSE file in the root directory of this source tree.
6
 
7
  """
8
  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 yaml
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
- try:
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
- import re
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 env_vars:
399
- create_kwargs["env_vars"] = 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 image.startswith("snapshot:"):
406
  from daytona import CreateSandboxFromSnapshotParams
407
 
408
- snapshot_name = image[len("snapshot:") :]
409
  params = CreateSandboxFromSnapshotParams(
410
  snapshot=snapshot_name, **create_kwargs
411
  )
412
- elif image.startswith("dockerfile:"):
413
  from daytona import CreateSandboxFromImageParams, Image
414
 
415
- dockerfile_path = image[len("dockerfile:") :]
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": image, **create_kwargs}
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
- # Copyright (c) Meta Platforms, Inc. and affiliates.
2
- # All rights reserved.
3
- #
4
- # This source code is licensed under the BSD-style license found in the
5
- # LICENSE file in the root directory of this source tree.
6
 
7
  """
8
  Container provider abstractions for running environment servers.
@@ -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
- Example:
34
- >>> provider = LocalDockerProvider()
35
- >>> base_url = provider.start_container("echo-env:latest")
36
- >>> print(base_url) # http://localhost:8000
37
- >>> # Use the environment via base_url
38
- >>> provider.stop_container()
 
 
 
39
  """
40
 
41
  @abstractmethod
@@ -50,16 +51,23 @@ class ContainerProvider(ABC):
50
  Start a container from the specified image.
51
 
52
  Args:
53
- image: Container image name (e.g., "echo-env:latest")
54
- port: Port to expose (if None, provider chooses)
55
- env_vars: Environment variables to pass to container
56
- **kwargs: Provider-specific options
 
 
 
 
 
 
 
57
 
58
  Returns:
59
- Base URL to connect to the container (e.g., "http://localhost:8000")
60
 
61
  Raises:
62
- RuntimeError: If container fails to start
63
  """
64
  pass
65
 
@@ -80,14 +88,34 @@ class ContainerProvider(ABC):
80
  This typically polls the /health endpoint until it returns 200.
81
 
82
  Args:
83
- base_url: Base URL of the container
84
- timeout_s: Maximum time to wait
 
 
85
 
86
  Raises:
87
- TimeoutError: If container doesn't become ready in time
88
  """
89
  pass
90
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
91
 
92
  class LocalDockerProvider(ContainerProvider):
93
  """
@@ -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
- Example:
100
- >>> provider = LocalDockerProvider()
101
- >>> base_url = provider.start_container("echo-env:latest")
102
- >>> # Container running on http://localhost:<random-port>
103
- >>> provider.stop_container()
 
 
 
104
  """
105
 
106
  def __init__(self):
@@ -138,13 +169,17 @@ class LocalDockerProvider(ContainerProvider):
138
  Start a Docker container locally.
139
 
140
  Args:
141
- image: Docker image name
142
- port: Port to expose (if None, finds available port)
143
- env_vars: Environment variables for the container
144
- **kwargs: Additional Docker run options
 
 
 
 
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: Base URL of the container
230
- timeout_s: Maximum time to wait
 
 
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: Docker image name
 
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: Whether to call ``docker swarm init`` when Swarm
310
- is not active. Otherwise, user must manually initialize Swarm.
311
- overlay_network: Optional overlay network name for the service.
312
- When provided, the network is created with
313
- ``docker network create --driver overlay --attachable`` if it
314
- does not already exist.
 
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
- Supported kwargs:
338
- replicas (int): Number of container replicas (default: 2).
339
- cpu_limit (float | str): CPU limit passed to ``--limit-cpu``.
340
- memory_limit (str): Memory limit passed to ``--limit-memory``.
341
- constraints (Sequence[str]): Placement constraints.
342
- labels (Dict[str, str]): Service labels.
343
- command (Sequence[str] | str): Override container command.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- Note: With Swarm's load balancer, requests round-robin across replicas,
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
- Container provider for Kubernetes clusters.
596
 
597
- This provider creates pods in a Kubernetes cluster and exposes them
598
- via services or port-forwarding.
599
-
600
- Example:
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
- Example:
620
- >>> provider = UVProvider(project_path="/path/to/env")
621
- >>> base_url = provider.start()
622
- >>> print(base_url) # http://localhost:8000
623
- >>> provider.stop()
 
 
 
624
  """
625
 
626
  @abstractmethod
@@ -631,13 +684,18 @@ class RuntimeProvider(ABC):
631
  **kwargs: Any,
632
  ) -> str:
633
  """
634
- Start a runtime from the specified image.
635
 
636
  Args:
637
- image: Runtime image name
638
- port: Port to expose (if None, provider chooses)
639
- env_vars: Environment variables for the runtime
640
- **kwargs: Additional runtime options
 
 
 
 
 
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
- continue
 
 
 
 
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: Local path to a uv project (passed to ``uv run --project``)
87
- app: ASGI application path for uvicorn (defaults to ``server.app:app``)
88
- host: Host interface to bind to (defaults to ``0.0.0.0``)
89
- reload: Whether to enable uvicorn's reload mode
90
- env_vars: Environment variables to pass through to the spawned process
91
- context_timeout_s: How long to wait for the environment to become ready
92
-
93
- Example:
94
- >>> provider = UVProvider(project_path="/path/to/env")
95
- >>> base_url = provider.start()
96
- >>> print(base_url) # http://localhost:8000
97
- >>> # Use the environment via base_url
98
- >>> provider.stop()
 
 
 
 
 
 
 
 
 
 
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
- self.project_path = os.path.abspath(project_path)
 
 
 
 
 
 
 
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: The port to bind the environment to
134
- env_vars: Environment variables to pass to the environment
135
- workers: The number of workers to use
 
 
 
136
 
137
  Returns:
138
- The base URL of the environment
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=self.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 = 60.0) -> None:
174
  """
175
  Wait for the environment to become ready.
176
 
177
  Args:
178
- timeout_s: The timeout to wait for the environment to become ready
 
 
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
- return
199
-
200
- if self._process.poll() is None:
201
- self._process.terminate()
202
- try:
203
- self._process.wait(timeout=10.0)
204
- except subprocess.TimeoutExpired:
205
- self._process.kill()
206
- self._process.wait(timeout=5.0)
207
-
208
- self._process = None
209
- self._base_url = None
 
 
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
- The base URL of the environment
218
 
219
  Raises:
220
- RuntimeError: If the environment is not running
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 sicne this will be tested in e2e test for echo env
23
  def test_local_docker_provider():
24
  """Test LocalDockerProvider end-to-end."""
25
  print("=" * 60)
 
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
- # Copyright (c) Meta Platforms, Inc. and affiliates.
2
- # All rights reserved.
3
- #
4
- # This source code is licensed under the BSD-style license found in the
5
- # LICENSE file in the root directory of this source tree.
6
 
7
  """
8
  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
- Example (async):
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
- Example (sync wrapper):
23
- >>> env = GenericEnvClient(base_url="ws://localhost:8000").sync()
24
- >>> with env:
25
- ... result = env.reset()
26
- ... result = env.step({"code": "print('hello')"})
 
 
 
 
 
 
 
 
 
 
 
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 typing import Any, Dict, Generic, Optional, Type, TYPE_CHECKING, TypeVar
 
 
 
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
- Example (async):
72
- >>> from envs.coding_env.client import CodingEnv
73
- >>>
74
- >>> # Connect to a server using async context manager
75
- >>> async with CodingEnv(base_url="ws://localhost:8000") as env:
76
- ... result = await env.reset(seed=42)
77
- ... while not result.done:
78
- ... action = agent.predict(result.observation)
79
- ... result = await env.step(action)
80
-
81
- Example (sync wrapper):
82
- >>> env = CodingEnv(base_url="ws://localhost:8000").sync()
83
- >>> with env:
84
- ... result = env.reset(seed=42)
85
- ... result = env.step(action)
 
 
 
 
 
 
 
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: Base URL of the environment server (http:// or ws://).
102
- Will be converted to ws:// if http:// is provided.
103
- connect_timeout_s: Timeout for establishing WebSocket connection
104
- message_timeout_s: Timeout for receiving responses to messages
105
- max_message_size_mb: Maximum WebSocket message size in megabytes.
106
- Default 100MB to handle large observations (screenshots, DOM, etc.)
107
- provider: Optional container/runtime provider for lifecycle management.
108
- Can be a ContainerProvider (Docker) or RuntimeProvider (UV).
109
- mode: Communication mode: 'simulation' for Gym-style API (default) or
110
- 'production' for MCP JSON-RPC protocol. Can also be set via the
111
- OPENENV_CLIENT_MODE environment variable. Constructor parameter
112
- takes precedence over environment variable. Case-insensitive.
 
 
 
 
 
 
 
 
 
 
 
 
 
113
  """
114
- # Determine mode (constructor > env var > default)
115
- if mode is None:
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._ws_url = f"{ws_url}/ws"
 
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
- async def connect(self) -> "EnvClient":
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- return self
159
-
160
- # Bypass proxy for localhost connections
161
- ws_url_lower = self._ws_url.lower()
162
- is_localhost = "localhost" in ws_url_lower or "127.0.0.1" in ws_url_lower
163
-
164
- old_no_proxy = os.environ.get("NO_PROXY")
165
- if is_localhost:
166
- # Set NO_PROXY to bypass proxy for localhost
167
- current_no_proxy = old_no_proxy or ""
168
- if "localhost" not in current_no_proxy.lower():
169
- os.environ["NO_PROXY"] = (
170
- f"{current_no_proxy},localhost,127.0.0.1"
171
- if current_no_proxy
172
- else "localhost,127.0.0.1"
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
- async def disconnect(self) -> None:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
194
  """Close the WebSocket connection."""
195
  if self._ws is not None:
196
- try:
197
- # Send close message
198
- await self._send({"type": "close"})
199
- except Exception:
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
- if self._ws is None:
210
- await self.connect()
 
 
 
 
 
 
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 and parse a message from the WebSocket."""
 
 
 
 
220
  assert self._ws is not None
221
- raw = await asyncio.wait_for(self._ws.recv(), timeout=self._message_timeout)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- await self._send(message)
 
 
 
 
 
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
- async def from_docker_image(
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: Docker image name to run (e.g., "coding-env:latest")
251
- provider: Container provider to use (defaults to LocalDockerProvider)
252
- **kwargs: Additional arguments to pass to provider.start_container()
 
 
 
253
 
254
  Returns:
255
- Connected client instance
256
- """
257
- if provider is None:
258
- provider = LocalDockerProvider()
259
 
260
- # Start container
261
- base_url = provider.start_container(image, **kwargs)
262
 
263
- # Wait for server to be ready
264
- provider.wait_for_ready(base_url)
 
265
 
266
- # Create and connect client
267
- client = cls(base_url=base_url, provider=provider)
268
- await client.connect()
269
-
270
- return client
 
 
 
271
 
272
  @classmethod
273
- async def from_env(
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
- Args:
285
- repo_id: Hugging Face space identifier ``{org}/{space}``.
286
- use_docker: When ``True`` (default) pull from the HF registry and
287
- launch via :class:`LocalDockerProvider`. When ``False`` run the
288
- space locally with :class:`UVProvider`.
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/{repo_id.replace('/', '-')}:{tag}"
327
- base_url = docker_provider.start_container(
328
- image, **start_args, **provider_kwargs
329
- )
330
- docker_provider.wait_for_ready(base_url)
331
-
332
- client = cls(base_url=base_url, provider=docker_provider)
333
- await client.connect()
334
- return client
 
 
 
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
- base_url = provider.start(**start_args)
351
- provider.wait_for_ready()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
352
 
353
- client = cls(base_url=base_url, provider=provider)
354
- await client.connect()
355
- return client
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- async def reset(self, **kwargs: Any) -> StepResult[ObsT]:
 
 
 
373
  """
374
  Reset the environment with optional parameters.
375
 
376
  Args:
377
- **kwargs: Optional parameters passed to the environment's reset method.
378
- Common parameters include:
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
- async def step(self, action: ActT, **kwargs: Any) -> StepResult[ObsT]:
 
 
 
393
  """
394
  Execute an action in the environment.
395
 
396
  Args:
397
- action: The action to execute
398
- **kwargs: Optional parameters (currently ignored)
 
 
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
- async def state(self) -> StateT:
 
 
 
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
- async def close(self) -> None:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- await self.disconnect()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
429
 
430
- if self._provider is not None:
431
- # Handle both ContainerProvider and RuntimeProvider
432
- if hasattr(self._provider, "stop_container"):
433
- self._provider.stop_container()
434
- elif hasattr(self._provider, "stop"):
435
- self._provider.stop()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- """Sync context manager entry - raises error suggesting async usage."""
448
- raise TypeError(
449
- "EnvClient is async by default. Use 'async with' instead of 'with', "
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
- """Sync context manager exit - should not be reached."""
457
- pass # pragma: no cover
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
- Example:
473
- >>> # Create async client and get sync wrapper
474
- >>> async_client = GenericEnvClient(base_url="http://localhost:8000")
475
- >>> sync_client = async_client.sync()
476
- >>>
477
- >>> # Use synchronous API
478
- >>> with sync_client:
479
- ... result = sync_client.reset()
480
- ... result = sync_client.step({"code": "print('hello')"})
 
481
  """
482
  from .sync_client import SyncEnvClient
483
 
484
- return SyncEnvClient(self)
 
 
 
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
- # Copyright (c) Meta Platforms, Inc. and affiliates.
2
- # All rights reserved.
3
- #
4
- # This source code is licensed under the BSD-style license found in the
5
- # LICENSE file in the root directory of this source tree.
6
 
7
  """Core environment interfaces and types."""
8
 
@@ -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
- # Copyright (c) Meta Platforms, Inc. and affiliates.
2
- # All rights reserved.
3
- #
4
- # This source code is licensed under the BSD-style license found in the
5
- # LICENSE file in the root directory of this source tree.
6
 
7
  """Base transform implementations for composing environment-specific transforms."""
8
 
 
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
- # 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
  """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
- # 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
- """Unified terminal-style theme for OpenEnv Gradio UI (light/dark)."""
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="#f6f8fa",
56
- c100="#eaeef2",
57
- c200="#d0d7de",
58
- c300="#afb8c1",
59
- c400="#8c959f",
60
- c500="#6e7781",
61
- c600="#57606a",
62
- c700="#424a53",
63
- c800="#32383f",
64
- c900="#24292f",
65
- c950="#1b1f24",
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=_CORE_FONT,
73
- font_mono=_MONO_FONTS,
74
- radius_size=_ZERO_RADIUS,
75
  ).set(
76
  body_background_fill="#ffffff",
 
77
  background_fill_primary="#ffffff",
78
- background_fill_secondary="#f6f8fa",
79
  block_background_fill="#ffffff",
80
- block_border_color="#ffffff",
81
- block_label_text_color="#57606a",
82
- block_title_text_color="#24292f",
83
- border_color_primary="#d0d7de",
84
  input_background_fill="#ffffff",
85
- input_border_color="#d0d7de",
86
- button_primary_background_fill="#1a7f37",
87
- button_primary_background_fill_hover="#116329",
 
88
  button_primary_text_color="#ffffff",
89
- button_secondary_background_fill="#f6f8fa",
90
- button_secondary_background_fill_hover="#eaeef2",
91
- button_secondary_text_color="#24292f",
92
- button_secondary_border_color="#d0d7de",
93
- body_background_fill_dark="#0d1117",
94
- background_fill_primary_dark="#0d1117",
95
- background_fill_secondary_dark="#0d1117",
96
- block_background_fill_dark="#0d1117",
97
- block_border_color_dark="#0d1117",
98
- block_label_text_color_dark="#8b949e",
99
- block_title_text_color_dark="#c9d1d9",
100
- border_color_primary_dark="#30363d",
101
- input_background_fill_dark="#0d1117",
102
- input_border_color_dark="#30363d",
103
- button_primary_background_fill_dark="#30363d",
104
- button_primary_background_fill_hover_dark="#484f58",
105
- button_primary_text_color_dark="#c9d1d9",
106
- button_secondary_background_fill_dark="#21262d",
107
- button_secondary_background_fill_hover_dark="#30363d",
108
- button_secondary_text_color_dark="#c9d1d9",
109
- button_secondary_border_color_dark="#30363d",
 
 
 
 
 
 
 
 
 
110
  )
111
 
112
  OPENENV_GRADIO_CSS = """
113
- * { border-radius: 0 !important; }
114
- .col-left { padding: 16px !important; }
115
- .col-right { padding: 16px !important; }
116
- .prose, .markdown-text, .md,
117
- .prose > *, .markdown-text > * {
118
- background: transparent !important;
119
- border: none !important;
120
- box-shadow: none !important;
121
- }
122
- .dark .col-left {
123
- border-left-color: rgba(139, 148, 158, 0.4) !important;
124
- }
125
- .dark .col-right {
126
- border-left-color: rgba(201, 209, 217, 0.3) !important;
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
- # Copyright (c) Meta Platforms, Inc. and affiliates.
2
- # All rights reserved.
3
- #
4
- # This source code is licensed under the BSD-style license found in the
5
- # LICENSE file in the root directory of this source tree.
6
 
7
  """
8
  Gradio-based web UI for OpenEnv environments.
9
 
10
- Replaces the legacy HTML/JavaScript interface when ENABLE_WEB_INTERFACE is set.
11
- Mount at /web via gr.mount_gradio_app() from create_web_interface_app().
 
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
- def _escape_md(text: str) -> str:
26
- """Escape Markdown special characters in user-controlled content."""
27
- return re.sub(r"([\\`*_\{\}\[\]()#+\-.!|~>])", r"\\\1", str(text))
28
-
29
-
30
- def _format_observation(data: Dict[str, Any]) -> str:
31
- """Format reset/step response for Markdown display."""
32
- lines: List[str] = []
33
- obs = data.get("observation", {})
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 textbox; else form from action_fields.
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
- readme_content = _readme_section(metadata)
94
- display_title = get_gradio_display_title(metadata, fallback=title)
 
 
95
 
96
- async def reset_env():
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
97
  try:
98
  data = await web_manager.reset_environment()
99
- obs_md = _format_observation(data)
 
 
 
 
 
 
 
 
 
 
100
  return (
101
- obs_md,
102
- json.dumps(data, indent=2),
103
- "Environment reset successfully.",
 
 
 
 
 
104
  )
105
- except Exception as e:
106
- return ("", "", f"Error: {e}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
107
 
108
- def _step_with_action(action_data: Dict[str, Any]):
109
- async def _run():
110
  try:
111
- data = await web_manager.step_environment(action_data)
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 ("", "", f"Error: {e}")
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.get_state()
132
- return json.dumps(data, indent=2)
133
  except Exception as e:
134
- return f"Error: {e}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
135
 
136
- with gr.Blocks(title=display_title) as demo:
137
- with gr.Row():
138
- with gr.Column(scale=1, elem_classes="col-left"):
139
- if quick_start_md:
140
- with gr.Accordion("Quick Start", open=True):
141
- gr.Markdown(quick_start_md)
142
- with gr.Accordion("README", open=False):
143
- gr.Markdown(readme_content)
144
-
145
- with gr.Column(scale=2, elem_classes="col-right"):
146
- obs_display = gr.Markdown(
147
- value=("# Playground\n\nClick **Reset** to start a new episode."),
148
- )
149
- with gr.Group():
150
- if is_chat_env:
151
- action_input = gr.Textbox(
152
- label="Action message",
153
- placeholder="e.g. Enter your message...",
 
 
 
 
 
 
 
 
 
154
  )
155
- step_inputs = [action_input]
156
- step_fn = step_chat
157
  else:
158
- step_inputs = []
159
- for field in action_fields:
160
- name = field["name"]
161
- field_type = field.get("type", "text")
162
- label = name.replace("_", " ").title()
163
- placeholder = field.get("placeholder", "")
164
- if field_type == "checkbox":
165
- inp = gr.Checkbox(label=label)
166
- elif field_type == "number":
167
- inp = gr.Number(label=label)
168
- elif field_type == "select":
169
- choices = field.get("choices") or []
170
- inp = gr.Dropdown(
171
- choices=choices,
172
- label=label,
173
- allow_custom_value=False,
 
 
 
 
 
 
174
  )
175
- elif field_type in ("textarea", "tensor"):
176
- inp = gr.Textbox(
177
- label=label,
178
- placeholder=placeholder,
179
- lines=3,
 
 
 
 
 
 
 
 
180
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
181
  else:
182
- inp = gr.Textbox(
183
- label=label,
184
- placeholder=placeholder,
185
- )
186
- step_inputs.append(inp)
187
-
188
- async def step_form(*values):
189
- if not action_fields:
190
- return await _step_with_action({})()
191
- action_data = {}
192
- for i, field in enumerate(action_fields):
193
- if i >= len(values):
194
- break
195
- name = field["name"]
196
- val = values[i]
197
- if field.get("type") == "checkbox":
198
- action_data[name] = bool(val)
199
- elif val is not None and val != "":
200
- action_data[name] = val
201
- return await _step_with_action(action_data)()
202
-
203
- step_fn = step_form
204
-
205
- with gr.Row():
206
- step_btn = gr.Button("Step", variant="primary")
207
- reset_btn = gr.Button("Reset", variant="secondary")
208
- state_btn = gr.Button("Get state", variant="secondary")
209
- with gr.Row():
210
- status = gr.Textbox(
211
- label="Status",
212
- interactive=False,
 
 
 
 
 
 
 
 
 
 
213
  )
214
- raw_json = gr.Code(
215
- label="Raw JSON response",
216
- language="json",
217
- interactive=False,
218
- )
219
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
220
  reset_btn.click(
221
- fn=reset_env,
222
- outputs=[obs_display, raw_json, status],
223
- )
224
- step_btn.click(
225
- fn=step_fn,
 
 
226
  inputs=step_inputs,
227
- outputs=[obs_display, raw_json, status],
228
- )
229
- if is_chat_env:
230
- action_input.submit(
231
- fn=step_fn,
232
- inputs=step_inputs,
233
- outputs=[obs_display, raw_json, status],
234
- )
235
- state_btn.click(
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
- # Copyright (c) Meta Platforms, Inc. and affiliates.
2
- # All rights reserved.
3
- #
4
- # This source code is licensed under the BSD-style license found in the
5
- # LICENSE file in the root directory of this source tree.
6
 
7
  """
8
  HTTP server wrapper for Environment instances.
@@ -22,9 +18,20 @@ import time
22
  import uuid
23
  from concurrent.futures import ThreadPoolExecutor
24
  from contextlib import AsyncExitStack
25
- from typing import Any, AsyncContextManager, Callable, cast, Dict, Optional, Type
 
 
 
 
 
 
 
 
 
 
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: The object to convert
 
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
- Example:
128
- >>> from core.env_server import HTTPEnvServer
129
- >>> from envs.coding_env.server import CodeExecutionEnvironment
130
- >>> from envs.coding_env.models import CodeAction, CodeObservation
131
- >>>
132
- >>> # Pass environment class (factory pattern)
133
- >>> server = HTTPEnvServer(
134
- ... env=CodeExecutionEnvironment,
135
- ... action_cls=CodeAction,
136
- ... observation_cls=CodeObservation,
137
- ... max_concurrent_envs=4,
138
- ... )
139
- >>>
140
- >>> # Register routes with FastAPI
141
- >>> from fastapi import FastAPI
142
- >>> app = FastAPI()
143
- >>> server.register_routes(app)
 
 
 
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: Environment factory (callable) that creates new instances.
159
- Will be called to create a new environment for each WebSocket session.
160
- action_cls: The Action subclass this environment expects
161
- observation_cls: The Observation subclass this environment returns
162
- max_concurrent_envs: Maximum number of concurrent WebSocket sessions.
163
- Mutually exclusive with concurrency_config.
164
- concurrency_config: Optional ConcurrencyConfig for advanced concurrency settings.
165
- Mutually exclusive with max_concurrent_envs.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- environment that is not marked as SUPPORTS_CONCURRENT_SESSIONS.
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
- environment that is not marked as SUPPORTS_CONCURRENT_SESSIONS.
236
  """
 
 
237
  if self._max_concurrent_envs <= 1:
238
  return
239
 
240
- if inspect.isclass(self._env_factory):
241
- env_cls = self._env_factory
 
 
 
 
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
- executor = ThreadPoolExecutor(max_workers=1)
 
 
 
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.shutdown(wait=False)
 
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: The session ID to destroy
 
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
- # Shutdown executor after close is done
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: The session ID to update
436
- increment_step: If True, increment the step count
 
 
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 now - info.last_activity_at > timeout:
 
 
 
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 info is None or (now - info.last_activity_at) <= timeout:
 
 
 
 
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: The session ID to query
 
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: FastAPI application instance
545
- mode: Server mode - either SIMULATION or PRODUCTION (or string equivalents).
546
- In production mode, simulation control endpoints (/reset, /step, /state)
547
- are NOT registered. Only safe endpoints (/health, /schema, /metadata, /ws)
548
- are available. Defaults to SIMULATION for backwards compatibility.
 
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.__func__ is not Environment.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.__func__ is not Environment.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
- - openenv/session/create
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
- env = self._sessions.pop(target_session_id, _MISSING)
717
- if env is not _MISSING:
 
 
 
 
 
 
 
 
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
- else:
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
- if managed_session_id and mcp_client.is_connected():
800
- # Session-managed with live transport — call
801
- # directly, no redundant re-entry.
802
- tools = await mcp_client.list_tools()
803
- elif callable(mcp_session_factory):
804
- # Stateless request, or session-managed but the
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 mcp_client is None and mcp_server is None:
853
  return JsonRpcResponse.error_response(
854
- JsonRpcErrorCode.INTERNAL_ERROR,
855
- "Environment does not support MCP",
856
  request_id=request_id,
857
  )
858
 
859
- if not tool_name:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
860
  return JsonRpcResponse.error_response(
861
- JsonRpcErrorCode.INVALID_PARAMS,
862
- "Missing 'name' in params",
863
  request_id=request_id,
864
  )
865
 
866
  if mcp_client:
867
- if managed_session_id and mcp_client.is_connected():
868
- # Session-managed with live transport.
869
- result = await mcp_client.call_tool(
 
 
870
  name=tool_name, arguments=arguments
871
- )
872
- elif callable(mcp_session_factory):
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
- Message Protocol:
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=State,
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 this environment
 
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
- state=State.model_json_schema(),
 
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
- Each WebSocket connection gets its own environment instance.
1279
-
1280
- Message Protocol:
1281
- - Client sends: WSResetMessage | WSStepMessage | WSStateMessage | WSCloseMessage
1282
- - Server responds: WSObservationResponse | WSStateResponse | WSErrorResponse
1283
  """
1284
  await websocket.accept()
1285
 
1286
  session_id = None
1287
  session_env = None
 
 
1288
 
1289
  try:
1290
- # Create session with dedicated environment
1291
- session_id, session_env = await self._create_session()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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.__func__
1333
- is not Environment.reset_async
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.__func__
1371
- is not Environment.step_async
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: Environment factory (callable) that creates new instances
1506
- action_cls: The Action subclass this environment expects
1507
- observation_cls: The Observation subclass this environment returns
1508
- env_name: Optional environment name for README loading
1509
- max_concurrent_envs: Maximum concurrent WebSocket sessions.
1510
- Mutually exclusive with concurrency_config.
1511
- concurrency_config: Optional ConcurrencyConfig for advanced concurrency settings.
1512
- Mutually exclusive with max_concurrent_envs.
1513
- gradio_builder: Optional callable to build a custom Gradio UI at /web.
1514
- Signature: (web_manager, action_fields, metadata, is_chat_env, title,
1515
- quick_start_md) -> gr.Blocks. When None, the default Gradio app is used.
1516
- See docs/customizing-web-ui.md.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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, action_cls, observation_cls, max_concurrent_envs, concurrency_config
 
 
 
 
 
 
 
 
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: Environment factory (callable) that creates new instances
1561
- action_cls: The Action subclass this environment expects
1562
- observation_cls: The Observation subclass this environment returns
1563
- max_concurrent_envs: Maximum concurrent WebSocket sessions.
1564
- Mutually exclusive with concurrency_config.
1565
- concurrency_config: Optional ConcurrencyConfig for advanced concurrency settings.
1566
- Mutually exclusive with max_concurrent_envs.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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/meta-pytorch/OpenEnv",
1631
  },
1632
  license_info={
1633
  "name": "BSD-3-Clause",
1634
- "url": "https://github.com/meta-pytorch/OpenEnv/blob/main/LICENSE",
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
- server.register_routes(app)
 
 
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
- # 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
  import inspect
8
  from abc import ABC, abstractmethod
9
- from typing import Any, Generic, Optional, Protocol, TYPE_CHECKING, TypeVar
 
 
 
 
 
 
 
 
 
 
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: List of message dictionaries with 'role' and 'content'
52
- tokenize: Whether to tokenize the output
53
- return_tensors: Format for returned tensors ('pt' for PyTorch)
54
- **kwargs: Additional arguments
 
 
 
 
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: Token IDs to decode
68
- skip_special_tokens: Whether to skip special tokens in output
69
- **kwargs: Additional arguments
 
 
 
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: The input 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: Optional transform to apply to observations
103
- rubric: Optional rubric for reward computation. When provided, the
 
 
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
- rubric: Optional rubric for computing rewards. Environments can set this
119
- in __init__ and use it in step() to compute observation rewards.
120
- Training infrastructure can access it for introspection:
121
- for name, r in env.rubric.named_rubrics():
122
- print(f"{name}: {r.last_score}")
123
 
124
- See RFC 004 for rubric design: rfcs/004-rubrics.md
 
 
 
 
 
 
 
 
 
 
 
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: The action taken by the agent.
218
- observation: The resulting observation.
 
 
219
 
220
  Returns:
221
- Reward value from the rubric, or 0.0 if no rubric is set.
222
 
223
- Usage in step():
224
- def step(self, action: MyAction, ...) -> MyObservation:
225
- # ... execute action and create observation ...
226
- observation.reward = self._apply_rubric(action, observation)
227
- return observation
 
 
 
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: The action taken by the agent.
238
- observation: The resulting observation.
 
 
239
 
240
  Returns:
241
- Reward value from the rubric, or 0.0 if no rubric is set.
 
 
242
 
243
- Usage in step_async():
244
- async def step_async(self, action: MyAction, ...) -> MyObservation:
245
- # ... execute action and create observation ...
246
- observation.reward = await self._apply_rubric_async(action, observation)
247
- return observation
 
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
- Usage in reset():
263
- def reset(self, ...) -> MyObservation:
264
- self._reset_rubric()
265
- # ... create initial observation ...
266
- return observation
 
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
- Usage in reset_async():
277
- async def reset_async(self, ...) -> MyObservation:
278
- await self._reset_rubric_async()
279
- # ... create initial observation ...
280
- return observation
 
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
- # Copyright (c) Meta Platforms, Inc. and affiliates.
2
- # All rights reserved.
3
- #
4
- # This source code is licensed under the BSD-style license found in the
5
- # LICENSE file in the root directory of this source tree.
6
 
7
  """
8
  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
- Usage:
 
 
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
- Example:
131
- >>> from fastmcp import FastMCP
132
- >>> mcp = FastMCP("calculator")
133
- >>> @mcp.tool()
134
- ... def add(a: int, b: int) -> int:
135
- ... return a + b
136
- >>> env = MyMCPEnvironment(mcp)
137
- >>> obs = env.step(ListToolsAction())
138
- >>> obs.tools[0].name
139
- 'add'
 
 
 
 
 
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: The action to execute. Can be:
402
- - ListToolsAction: Returns available MCP tools
403
- - CallToolAction: Invokes a specific MCP tool
404
- - Any other Action: Delegated to _step_impl()
405
- timeout_s: Optional timeout in seconds for the action.
406
- Defaults to MCP_TOOL_CALL_TIMEOUT (30s) for MCP actions.
407
- **kwargs: Additional arguments passed to handlers.
 
 
408
 
409
  Returns:
410
- Observation appropriate to the action type:
411
- - ListToolsObservation for ListToolsAction
412
- - CallToolObservation for CallToolAction
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
- Tool(
470
- name=tool.name,
471
- description=tool.description or "",
472
- input_schema=tool.inputSchema
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
- # Copyright (c) Meta Platforms, Inc. and affiliates.
2
- # All rights reserved.
3
- #
4
- # This source code is licensed under the BSD-style license found in the
5
- # LICENSE file in the root directory of this source tree.
6
 
7
  """
8
  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
- # Copyright (c) Meta Platforms, Inc. and affiliates.
2
- # All rights reserved.
3
- #
4
- # This source code is licensed under the BSD-style license found in the
5
- # LICENSE file in the root directory of this source tree.
6
 
7
  """
8
  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: FastAPI application instance
39
- configs: List of GET endpoint configurations
 
 
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
- )(make_endpoint(config.handler))
 
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
- # Copyright (c) Meta Platforms, Inc. and affiliates.
2
- # All rights reserved.
3
- #
4
- # This source code is licensed under the BSD-style license found in the
5
- # LICENSE file in the root directory of this source tree.
6
 
7
  """
8
  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: Dictionary containing action data
44
- action_cls: The Action subclass to instantiate
 
 
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
- # Route MCP action types before falling through to the env action_cls.
56
- # Only intercept when action_cls is the generic Action base or itself an
57
- # MCP type (i.e. the server hosts an MCP environment). This avoids
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: Dictionary containing action data
82
- action_cls: The Action subclass to instantiate
 
 
83
 
84
  Returns:
85
- Action instance
86
 
87
  Raises:
88
- ValidationError: If action_data is invalid for the action class
89
  """
90
- # Route MCP action types before preprocessing (they don't need it).
91
- # Same guard as deserialize_action: only intercept when action_cls is
92
- # the generic Action base or itself an MCP type.
93
- action_type = action_data.get("type")
94
- if action_type in _MCP_ACTION_TYPES:
95
- mcp_cls = _MCP_ACTION_TYPES[action_type]
96
- if action_cls is Action or action_cls in _MCP_ACTION_TYPES.values():
97
- return mcp_cls.model_validate(action_data)
 
 
 
 
 
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: Observation instance
 
142
 
143
  Returns:
144
- Dictionary compatible with EnvClient._parse_result()
145
-
146
- The format matches what EnvClient expects:
147
- {
148
- "observation": {...}, # Observation fields
149
- "reward": float | None,
150
- "done": bool,
151
- }
152
  """
153
- # Use Pydantic's model_dump() for serialization
 
 
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 done directly from the observation
163
  reward = observation.reward
164
  done = observation.done
 
165
 
166
- # Return in EnvClient expected format
167
- return {
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
- # Copyright (c) Meta Platforms, Inc. and affiliates.
2
- # All rights reserved.
3
- #
4
- # This source code is licensed under the BSD-style license found in the
5
- # LICENSE file in the root directory of this source tree.
6
 
7
  from 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
- # Copyright (c) Meta Platforms, Inc. and affiliates.
2
- # All rights reserved.
3
- #
4
- # This source code is licensed under the BSD-style license found in the
5
- # LICENSE file in the root directory of this source tree.
6
 
7
  """
8
  Web interface for OpenEnv environments.
@@ -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
- Connect from Python using `__ENV_CLASS_NAME__Env`:
40
 
41
- ```python
42
- from __ENV_NAME__ import __ENV_CLASS_NAME__Action, __ENV_CLASS_NAME__Env
43
 
44
- with __ENV_CLASS_NAME__Env.from_env("<SPACE_ID>") as env:
45
- result = await env.step(__ENV_CLASS_NAME__Action(message="..."))
46
- ```
47
-
48
- Or connect directly to a running server:
49
-
50
- ```python
51
- env = __ENV_CLASS_NAME__Env(base_url="http://localhost:8000")
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
- For more information, see the [OpenEnv documentation](https://meta-pytorch.org/OpenEnv/).
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 with class names replaced from current env (init-style suffixes).
80
 
81
- Uses the same placeholder names as the init template so that __ENV_CLASS_NAME__Env,
82
- __ENV_CLASS_NAME__Action, __ENV_CLASS_NAME__Observation and __ENV_NAME__ are
83
- replaced with the actual class/package names.
84
  """
85
  import os
86
 
87
- # Prefix from action class (e.g. EchoAction -> Echo)
88
- action_name = getattr(action_cls, "__name__", "Action")
89
- if action_name.endswith("Action"):
90
- prefix = action_name[: -len("Action")]
91
- else:
92
- prefix = action_name.replace("Action", "").strip() or "Env"
93
-
94
- env_client_name = f"{prefix}Env"
95
- obs_name = getattr(observation_cls, "__name__", "Observation")
96
- pkg_name = (metadata.name if metadata else "env").replace(" ", "_").lower()
97
-
98
- space_id = os.environ.get("SPACE_ID", "<hf-username>/<hf-repo-name>")
 
 
 
 
 
 
 
99
 
100
- content = DEFAULT_QUICK_START_MARKDOWN
101
- content = content.replace("__ENV_CLASS_NAME__Env", env_client_name)
102
- content = content.replace("__ENV_CLASS_NAME__Action", action_name)
103
- content = content.replace("__ENV_CLASS_NAME__Observation", obs_name)
104
- content = content.replace("__ENV_CLASS_NAME__", prefix)
105
- content = content.replace("__ENV_NAME__", pkg_name)
106
- content = content.replace("<SPACE_ID>", space_id)
107
- return content.strip()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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, class, or factory function.
118
- - If a class: used as a factory, won't call instance methods
119
- - If a function: used as a factory, won't call instance methods
120
- - If an instance: may call get_metadata() if available
121
- env_name: Optional environment name for README file lookup
122
 
123
  Returns:
124
- EnvironmentMetadata with loaded information
125
  """
126
- import inspect
127
-
128
- # Determine what type of env we received:
129
- # 1. A class (used as factory) - e.g., PythonCodeActEnv
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 is_class:
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
- # env is an instance
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
- 1. Container filesystem: /app/README.md
172
- 2. Local development: src/envs/{env_name}/README.md
173
- 3. Environment variable: ENV_README_PATH
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
- # Try local development path
195
- if env_name:
196
- local_readme = Path(f"src/envs/{env_name}/README.md")
197
- if local_readme.exists():
198
  try:
199
- return local_readme.read_text(encoding="utf-8")
200
  except Exception:
201
  pass
202
 
@@ -248,13 +297,8 @@ class WebInterfaceManager:
248
  observation_cls: Type[Observation],
249
  metadata: Optional[EnvironmentMetadata] = None,
250
  ):
251
- import inspect
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.__func__ is not Environment.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 this environment returns
 
 
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, action_cls, observation_cls, max_concurrent_envs, concurrency_config
 
 
 
 
 
 
 
 
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
- Note: Uses /ws/ui to avoid conflict with /ws in http_server.py
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
- default_blocks = build_gradio_app(
 
538
  web_manager,
539
  action_fields,
540
  metadata,
541
  is_chat_env,
542
- title=metadata.name,
543
- quick_start_md=quick_start_md,
 
 
 
 
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
- # 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
  """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
- # Copyright (c) Meta Platforms, Inc. and affiliates.
2
- # All rights reserved.
3
- #
4
- # This source code is licensed under the BSD-style license found in the
5
- # LICENSE file in the root directory of this source tree.
6
 
7
  """Base class for evaluation harnesses."""
8
 
 
1
+ # SPDX-License-Identifier: BSD-3-Clause
 
 
 
 
2
 
3
  """Base class for evaluation harnesses."""
4