From 35069805e782c4aa778940295b89d8c64cb9ed3a Mon Sep 17 00:00:00 2001 From: burtenshaw Date: Mon, 21 Sep 2026 14:29:21 +0200 Subject: [PATCH 1/8] docs: refresh navigation and examples --- docs/README.md | 2 +- docs/source/_toctree.yml | 70 ++-- docs/source/environments.md | 2 +- docs/source/environments/agent_world_model.md | 2 +- docs/source/environments/calendar.md | 2 +- docs/source/environments/carla.md | 14 +- docs/source/environments/finrl.md | 2 +- docs/source/environments/git.md | 2 +- docs/source/environments/opencode.md | 1 - docs/source/environments/repl.md | 26 +- docs/source/environments/wildfire.md | 2 +- docs/source/getting-started.md | 22 +- .../getting_started/environment-builder.md | 5 +- docs/source/guides/concepts.md | 2 +- docs/source/reference/cli.md | 9 +- docs/source/tutorials/evaluation-inspect.md | 2 +- docs/source/tutorials/index.md | 34 +- docs/source/tutorials/openenv-tutorial.md | 385 +++++++++++------- envs/git_env/README.md | 2 +- envs/opencode_env/README.md | 1 - envs/wildfire_env/README.md | 2 +- scripts/sync_env_docs.py | 87 +++- tests/scripts/test_sync_env_docs.py | 90 ++++ 23 files changed, 510 insertions(+), 256 deletions(-) create mode 100644 tests/scripts/test_sync_env_docs.py diff --git a/docs/README.md b/docs/README.md index 6ec7e921be..afbab3d9ef 100644 --- a/docs/README.md +++ b/docs/README.md @@ -24,7 +24,7 @@ From the repo root: doc-builder preview openenv docs/source ``` -Then open the URL printed in the terminal (usually `http://localhost:5173/openenv/main/en/index`). +Then open the URL printed in the terminal and navigate to `/index` (usually `http://localhost:5173/index`). ## Adding an Environment to the Docs diff --git a/docs/source/_toctree.yml b/docs/source/_toctree.yml index c5e973ef44..707920b2fc 100644 --- a/docs/source/_toctree.yml +++ b/docs/source/_toctree.yml @@ -5,8 +5,6 @@ title: Getting Started title: Get Started - sections: - - local: guides/first-environment - title: Your First Environment - local: getting_started/environment-builder title: Deploying an Env - local: guides/auto-discovery @@ -16,11 +14,15 @@ - local: guides/rl-integration title: RL Training - local: guides/harbor-provider-qualification - title: Harbor Provider Qualification + title: Harbor Spec - local: guides/rewards title: Rewards - local: guides/concepts - title: Concepts + title: Core Concepts + - local: tutorials/mcp-environment + title: MCP Environments + - local: tutorials/rubrics + title: Rubrics - isExpanded: false sections: - local: guides/customizing-web-ui @@ -37,34 +39,40 @@ title: Connecting to Servers - local: guides/runtime-providers title: Runtime Providers - title: Advanced Guides - title: Guides + title: Advanced Concepts + title: Concepts - sections: - - local: tutorials/index - title: Overview - - local: tutorials/openenv-tutorial - title: Hello World - - local: tutorials/end-to-end-walkthrough - title: Train a reasoning model with TRL - - local: tutorials/mcp-environment - title: MCP Environments - - local: tutorials/rubrics - title: Rubrics - - local: tutorials/wordle-grpo - title: RL Training with TRL - - local: tutorials/rl-training-2048 - title: RL Training with Unsloth - - local: tutorials/evaluation-inspect - title: Evaluating with Inspect AI - - local: tutorials/browsergym-harness - title: RL Training with an Agentic Harness - - local: tutorials/opencode-agent-grpo - title: Coding Agent Training with TRL - - local: tutorials/sft-warmup - title: SFT Training with Environments - - local: tutorials/pi-agent-grpo - title: Training a Real Coding Agent (Pi) - title: Tutorials + - sections: + - local: tutorials/index + title: Overview + - local: tutorials/openenv-tutorial + title: Hello World + - local: guides/first-environment + title: Your First Environment + title: Basics + - sections: + - local: tutorials/end-to-end-walkthrough + title: Train a Reasoning Model + - local: tutorials/wordle-grpo + title: RL Training with TRL + - local: tutorials/rl-training-2048 + title: RL Training with Unsloth + - local: tutorials/sft-warmup + title: SFT Training with Environments + title: Training + - sections: + - local: tutorials/browsergym-harness + title: Agentic Harness + - local: tutorials/opencode-agent-grpo + title: OpenCode + - local: tutorials/pi-agent-grpo + title: Pi + title: Harnesses + - sections: + - local: tutorials/evaluation-inspect + title: Evaluating with Environments + title: Evals + title: Learn - isExpanded: false sections: - local: environments diff --git a/docs/source/environments.md b/docs/source/environments.md index a1256485b8..12ee6085a8 100644 --- a/docs/source/environments.md +++ b/docs/source/environments.md @@ -115,7 +115,7 @@ The OpenEnv community has built a catalog of ready-to-run environments that cove
Web Search
-

Web search environment for RL research with configurable grids, partial observability, and customizable rewards.

+

Web search environment that lets agents query Google Search through the Serper.dev API and receive formatted results.

šŸ“„ Docs šŸ¤— HF diff --git a/docs/source/environments/agent_world_model.md b/docs/source/environments/agent_world_model.md index 03d1c92aee..cf504eca4d 100644 --- a/docs/source/environments/agent_world_model.md +++ b/docs/source/environments/agent_world_model.md @@ -184,7 +184,7 @@ A background cleanup daemon automatically kills sessions idle longer than `MAX_I ## Full Agent Interaction Example -See [`examples/agent_world_model/example_usage.py`](../../examples/agent_world_model/example_usage.py) for a complete example of an LLM-powered agent that: +See [`examples/agent_world_model/example_usage.py`](https://github.com/huggingface/OpenEnv/blob/main/examples/agent_world_model/example_usage.py) for a complete example of an LLM-powered agent that: 1. Discovers available tools via `list_tools` 2. Iteratively calls tools to accomplish the task diff --git a/docs/source/environments/calendar.md b/docs/source/environments/calendar.md index 9fa695f707..d9648fda0a 100644 --- a/docs/source/environments/calendar.md +++ b/docs/source/environments/calendar.md @@ -63,4 +63,4 @@ python client.py --scenario scenario_config.json Output will be saved to `response_output/` folder with execution details, tool calls, and verification results. **Notebook Evaluation:** -For interactive evaluation and testing, use the: [`Jupyter notebook`](client_notebooks/OpenEnv_and_mcp_Single_Gym_Client_Meta_Turing.ipynb) \ No newline at end of file +For interactive evaluation and testing, use the: [`Jupyter notebook`](https://github.com/huggingface/OpenEnv/blob/main/envs/calendar_env/client_notebooks/OpenEnv_and_mcp_Single_Gym_Client_Meta_Turing.ipynb) \ No newline at end of file diff --git a/docs/source/environments/carla.md b/docs/source/environments/carla.md index 2b9a5fab60..2095e69245 100644 --- a/docs/source/environments/carla.md +++ b/docs/source/environments/carla.md @@ -152,11 +152,11 @@ result = await env.reset(scenario_config={ ## Examples -The [`examples/carla_env/`](../../examples/carla_env/) directory contains inference scripts. All connect to `http://localhost:8000` by default — pass `--base-url https://sergiopaniego-carla-env.hf.space` for the live Space. +The [`examples/carla_env/`](https://github.com/huggingface/OpenEnv/tree/main/examples/carla_env) directory contains inference scripts. All connect to `http://localhost:8000` by default — pass `--base-url https://sergiopaniego-carla-env.hf.space` for the live Space. ### Trolley Problems -**[trolley_problems.py](../../examples/carla_env/trolley_problems.py)** — LLM evaluation across all trolley scenarios. +**[trolley_problems.py](https://github.com/huggingface/OpenEnv/blob/main/examples/carla_env/trolley_problems.py)** — LLM evaluation across all trolley scenarios. ```bash uv run python trolley_problems.py --model qwen3-235b --scenario classic-3v1 @@ -168,7 +168,7 @@ Available keys: `equal-1v1`, `saves-3v0`, `deadzone-3v1`, `classic-3v1`, `classi ### Maze Navigation -**[maze_navigation.py](../../examples/carla_env/maze_navigation.py)** — LLM navigation with rolling action history. +**[maze_navigation.py](https://github.com/huggingface/OpenEnv/blob/main/examples/carla_env/maze_navigation.py)** — LLM navigation with rolling action history. ```bash uv run python maze_navigation.py --model qwen3-235b --scenario maze-1 @@ -177,7 +177,7 @@ uv run python maze_navigation.py --model gpt-5.2 --scenario maze-1 --save-images ### Free-Roam Navigation -**[free_roam_navigation.py](../../examples/carla_env/free_roam_navigation.py)** — LLM navigation in open traffic. +**[free_roam_navigation.py](https://github.com/huggingface/OpenEnv/blob/main/examples/carla_env/free_roam_navigation.py)** — LLM navigation in open traffic. ```bash uv run python free_roam_navigation.py --model qwen3-235b @@ -186,7 +186,7 @@ uv run python free_roam_navigation.py --model qwen3-235b --scenario free-roam-tr ### Autopilot Baseline (No LLM) -**[autopilot_navigation.py](../../examples/carla_env/autopilot_navigation.py)** — CARLA's built-in navigation agent. +**[autopilot_navigation.py](https://github.com/huggingface/OpenEnv/blob/main/examples/carla_env/autopilot_navigation.py)** — CARLA's built-in navigation agent. ```bash uv run python autopilot_navigation.py --scenario maze-1 @@ -195,7 +195,7 @@ uv run python autopilot_navigation.py --scenario free-roam-default --behavior ca ### Rubric Reward Demo (No LLM) -**[rubric_autopilot_example.py](../../examples/carla_env/rubric_autopilot_example.py)** — Raw vs rubric rewards side-by-side. +**[rubric_autopilot_example.py](https://github.com/huggingface/OpenEnv/blob/main/examples/carla_env/rubric_autopilot_example.py)** — Raw vs rubric rewards side-by-side. ```bash uv run python rubric_autopilot_example.py --scenario free-roam-default @@ -222,7 +222,7 @@ Hugging Face models use [Inference Providers](https://huggingface.co/docs/infere ## Rubrics for RL Training -The environment includes rubrics following the [OpenEnv rubric system](../../rfcs/004-rubrics.md). Rubrics are automatically selected based on the scenario type and populate `obs.rubric_reward` alongside the raw `obs.reward` on each step. +The environment includes rubrics following the [OpenEnv rubric system](https://github.com/huggingface/OpenEnv/blob/main/rfcs/004-rubrics.md). Rubrics are automatically selected based on the scenario type and populate `obs.rubric_reward` alongside the raw `obs.reward` on each step. **CarlaTrolleyRubric** — For trolley/action-bias scenarios. Returns 0.0 on intermediate steps, then the terminal reward at episode end. Supports temporal discounting (`gamma`) for credit assignment. diff --git a/docs/source/environments/finrl.md b/docs/source/environments/finrl.md index de940bccaf..d4ec065bcf 100644 --- a/docs/source/environments/finrl.md +++ b/docs/source/environments/finrl.md @@ -347,4 +347,4 @@ BSD 3-Clause License (see LICENSE file in repository root) - [FinRL Paper](https://arxiv.org/abs/2011.09607) - [FinRL GitHub](https://github.com/AI4Finance-Foundation/FinRL) -- [OpenEnv Documentation](README.md) +- [OpenEnv Documentation](https://github.com/huggingface/OpenEnv/blob/main/envs/finrl_env/README.md) diff --git a/docs/source/environments/git.md b/docs/source/environments/git.md index 1b386c672f..df4018b232 100644 --- a/docs/source/environments/git.md +++ b/docs/source/environments/git.md @@ -178,7 +178,7 @@ docker run \ Then in your training code, environments automatically reset to the configured state. -See [`examples/local_git_env.py`](../../../examples/local_git_env.py) for complete working example. +See [`examples/local_git_env.py`](https://github.com/huggingface/OpenEnv/blob/main/examples/local_git_env.py) for complete working example. ## Project Structure diff --git a/docs/source/environments/opencode.md b/docs/source/environments/opencode.md index b936ea3e46..8804eee01d 100644 --- a/docs/source/environments/opencode.md +++ b/docs/source/environments/opencode.md @@ -304,4 +304,3 @@ opencode_env/ - [OpenEnv docs](https://huggingface.co/docs/openenv) - [OpenCode CLI](https://opencode.ai/docs/cli/) - [E2B Python SDK](https://e2b.dev/docs) -- [HF Inference Providers logprob matrix](../../../DOCS/HF/hf_inference_providers_logprobs.md) diff --git a/docs/source/environments/repl.md b/docs/source/environments/repl.md index 0407205da3..fd0ec5a35b 100644 --- a/docs/source/environments/repl.md +++ b/docs/source/environments/repl.md @@ -28,14 +28,14 @@ Inside the REPL, the model can: Main modules: -- [`client.py`](client.py): remote async OpenEnv client -- [`local.py`](local.py): explicit in-process local env helper -- [`runner.py`](runner.py): local RLM orchestration loop -- [`recursive_backends.py`](recursive_backends.py): direct and recursive backend implementations -- [`recursive_controller.py`](recursive_controller.py): server-side backend/broker composition -- [`rubrics.py`](rubrics.py): reward rubrics (OpenEnv RFC 004) -- [`server/repl_environment.py`](server/repl_environment.py): server-side execution environment -- [`server/app.py`](server/app.py): OpenEnv HTTP server app and env factory +- [`client.py`](https://github.com/huggingface/OpenEnv/blob/main/envs/repl_env/client.py): remote async OpenEnv client +- [`local.py`](https://github.com/huggingface/OpenEnv/blob/main/envs/repl_env/local.py): explicit in-process local env helper +- [`runner.py`](https://github.com/huggingface/OpenEnv/blob/main/envs/repl_env/runner.py): local RLM orchestration loop +- [`recursive_backends.py`](https://github.com/huggingface/OpenEnv/blob/main/envs/repl_env/recursive_backends.py): direct and recursive backend implementations +- [`recursive_controller.py`](https://github.com/huggingface/OpenEnv/blob/main/envs/repl_env/recursive_controller.py): server-side backend/broker composition +- [`rubrics.py`](https://github.com/huggingface/OpenEnv/blob/main/envs/repl_env/rubrics.py): reward rubrics (OpenEnv RFC 004) +- [`server/repl_environment.py`](https://github.com/huggingface/OpenEnv/blob/main/envs/repl_env/server/repl_environment.py): server-side execution environment +- [`server/app.py`](https://github.com/huggingface/OpenEnv/blob/main/envs/repl_env/server/app.py): OpenEnv HTTP server app and env factory ## What Works Today @@ -256,7 +256,7 @@ Run the local server: PYTHONPATH=src:envs uvicorn envs.repl_env.server.app:app --host 127.0.0.1 --port 8000 ``` -The server uses a proper OpenEnv environment factory in [`server/app.py`](server/app.py). +The server uses a proper OpenEnv environment factory in [`server/app.py`](https://github.com/huggingface/OpenEnv/blob/main/envs/repl_env/server/app.py). ## API Surface @@ -355,7 +355,7 @@ result = env.execute("answer['ready'] = True") ## Prompt Utilities -[`prompts.py`](prompts.py) contains the current message-building and parsing helpers used by the examples and runner. +[`prompts.py`](https://github.com/huggingface/OpenEnv/blob/main/envs/repl_env/prompts.py) contains the current message-building and parsing helpers used by the examples and runner. Important exports: @@ -371,14 +371,14 @@ These prompts were updated to reflect the actual helper surface the environment ## Examples -- [`examples/repl_with_llm.py`](../../examples/repl_with_llm.py) -- [`examples/repl_oolong_simple.py`](../../examples/repl_oolong_simple.py) +- [`examples/repl_with_llm.py`](https://github.com/huggingface/OpenEnv/blob/main/examples/repl_with_llm.py) +- [`examples/repl_oolong_simple.py`](https://github.com/huggingface/OpenEnv/blob/main/examples/repl_oolong_simple.py) Default hosted model in the examples is currently `Qwen/Qwen3.5-9B`, but real hosted inference still depends on provider availability and token access. ## Environment Variables -Server-side configuration in [`server/app.py`](server/app.py): +Server-side configuration in [`server/app.py`](https://github.com/huggingface/OpenEnv/blob/main/envs/repl_env/server/app.py): - `LLM_MODEL` - `HF_TOKEN` diff --git a/docs/source/environments/wildfire.md b/docs/source/environments/wildfire.md index 678dcb964e..b46b47690e 100644 --- a/docs/source/environments/wildfire.md +++ b/docs/source/environments/wildfire.md @@ -7,7 +7,7 @@ Agents must contain spreading fires using **water**, **firebreaks**, and **timin [![Docker](https://img.shields.io/badge/docker-ready-blue)](https://hub.docker.com/) [![Python](https://img.shields.io/badge/python-3.10+-green)](https://www.python.org/) [![FastAPI](https://img.shields.io/badge/backend-fastapi-teal)](https://fastapi.tiangolo.com/) -[![License](https://img.shields.io/badge/license-MIT-lightgrey)](LICENSE) +[![License](https://img.shields.io/badge/license-BSD--3--Clause-lightgrey)](https://github.com/huggingface/OpenEnv/blob/main/LICENSE) --- diff --git a/docs/source/getting-started.md b/docs/source/getting-started.md index 65f7ced3dd..18eac18b80 100644 --- a/docs/source/getting-started.md +++ b/docs/source/getting-started.md @@ -25,26 +25,30 @@ A few integrations ship as optional extras. Install them with ## Try an Environment -Use `AutoEnv` and `AutoAction` when you want OpenEnv to find the matching client -and action classes for an installed or discoverable environment. +Use `AutoEnv` to load the Echo client from its Hugging Face Space. It asks for +confirmation before installing the environment package, then connects to the +running Space. Echo exposes its actions as MCP tools. ```python -from openenv import AutoAction, AutoEnv +from openenv import AutoEnv -env = AutoEnv.from_env("echo") -EchoAction = AutoAction.from_env("echo") +env = AutoEnv.from_env("openenv/echo-env") with env.sync() as client: result = client.reset() - print(result.observation.echoed_message) # "Echo environment ready!" + print(result.observation.metadata["message"]) # "Echo environment ready!" - result = client.step(EchoAction(message="Hello, OpenEnv!")) - print(result.observation.echoed_message) # "Hello, OpenEnv!" + message = client.call_tool("echo_message", message="Hello, OpenEnv!") + print(message) # "Hello, OpenEnv!" ``` -`AutoEnv.from_env()` accepts the common name forms: +For installed environment packages, `AutoEnv.from_env()` also accepts the common +name forms below. Without a `base_url`, these start a local Docker container and +require Docker and the environment image: ```python +from openenv import AutoEnv + AutoEnv.from_env("echo") AutoEnv.from_env("echo-env") AutoEnv.from_env("echo_env") diff --git a/docs/source/getting_started/environment-builder.md b/docs/source/getting_started/environment-builder.md index e6a1c6e023..73348e2493 100644 --- a/docs/source/getting_started/environment-builder.md +++ b/docs/source/getting_started/environment-builder.md @@ -200,9 +200,10 @@ app = create_app(create_my_environment, MyAction, MyObservation, env_name="my_en # client.py from openenv.core.env_client import EnvClient from openenv.core.client_types import StepResult -from .models import MyAction, MyObservation, MyState +from openenv.core.env_server.types import State +from .models import MyAction, MyObservation -class MyEnv(EnvClient[MyAction, MyObservation, MyState]): +class MyEnv(EnvClient[MyAction, MyObservation, State]): def _step_payload(self, action: MyAction) -> dict: return {"command": action.command, "parameters": action.parameters} diff --git a/docs/source/guides/concepts.md b/docs/source/guides/concepts.md index 32ebf7d69b..bb6f373b0a 100644 --- a/docs/source/guides/concepts.md +++ b/docs/source/guides/concepts.md @@ -82,7 +82,7 @@ with env.sync() as client: with env.sync() as client: result = client.reset() - while not result.terminated: + while not result.done: obs = result.observation action = decide_action(obs) result = client.step(action) diff --git a/docs/source/reference/cli.md b/docs/source/reference/cli.md index a6a968ab11..5eadeb876d 100644 --- a/docs/source/reference/cli.md +++ b/docs/source/reference/cli.md @@ -1,6 +1,6 @@ # CLI -The `openenv` CLI provides a set of commands for building, validating, and pushing environments to Hugging Face Spaces or a custom Docker registry. For an end-to-end tutorial on building environments with OpenEnv, see the [building an environment](../getting_started/environment-builder.md) guide. +The `openenv` CLI provides a set of commands for building, validating, and pushing environments to Hugging Face Spaces or a custom Docker registry. For an end-to-end tutorial on building environments with OpenEnv, see the [building an environment](../getting_started/environment-builder) guide. ## `openenv init` @@ -27,12 +27,7 @@ openenv import path/to/source --name my_env --output-dir ./envs openenv import path/to/source --name my_env --output-dir ./envs --env-class MyEnv ``` -```{eval-rst} -.. automodule:: openenv.cli.commands.import_env - :members: - :undoc-members: - :show-inheritance: -``` +[[autodoc]] openenv.cli.commands.import_env.import_env ## `openenv build` diff --git a/docs/source/tutorials/evaluation-inspect.md b/docs/source/tutorials/evaluation-inspect.md index 7713044e78..54d4557d6a 100644 --- a/docs/source/tutorials/evaluation-inspect.md +++ b/docs/source/tutorials/evaluation-inspect.md @@ -1,4 +1,4 @@ -# Evaluating agents with Inspect AI +# Evaluating with Environments [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/huggingface/OpenEnv/blob/main/examples/evaluation_inspect.ipynb) diff --git a/docs/source/tutorials/index.md b/docs/source/tutorials/index.md index 0d839c6259..c434e29a9a 100644 --- a/docs/source/tutorials/index.md +++ b/docs/source/tutorials/index.md @@ -1,4 +1,13 @@ -# Tutorials +# Learn + +Choose a learning path from the sidebar: + +- **Basics:** start with [Hello World](openenv-tutorial) and build [Your First Environment](../guides/first-environment). +- **Training:** train a [reasoning model](end-to-end-walkthrough), use [TRL](wordle-grpo) or [Unsloth](rl-training-2048), or collect rollouts for [SFT](sft-warmup). +- **Harnesses:** run an [agentic harness](browsergym-harness) or train coding agents with [OpenCode](opencode-agent-grpo) and [Pi](pi-agent-grpo). +- **Evals:** follow [Evaluating with Environments](evaluation-inspect). + +For [MCP environments](mcp-environment) and [rubrics](rubrics), see Concepts in the sidebar. ## New to OpenEnv? Start Here @@ -18,15 +27,14 @@ Already familiar with the basics? These tutorials cover specific workflows in de | Tutorial | What it covers | GPU | Notebook | |----------|---------------|-----|----------| -| [OpenEnv Tutorial](openenv-tutorial.md) | Full introduction to OpenEnv: install, connect to a hosted environment, step through an episode, define a reward function, and run a basic training loop. | No | [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/huggingface/OpenEnv/blob/main/examples/OpenEnv_Tutorial.ipynb) | -| [End-to-end walkthrough](end-to-end-walkthrough.md) | The full pipeline: connect to `reasoning_gym`, wire it into TRL via `environment_factory`, fine-tune with GRPO, and push the checkpoint to the Hub. | Yes | [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/huggingface/OpenEnv/blob/main/examples/end_to_end_walkthrough.ipynb) | -| [Building and using MCP environments](mcp-environment.md) | Consume and build MCP-backed environments: list and call tools through `step()`, register Python functions as tools with FastMCP. | No | [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/huggingface/OpenEnv/blob/main/examples/mcp_environment.ipynb) | -| [Rubrics](rubrics.md) | Compose reward functions from reusable pieces using `Gate`, `WeightedSum`, `LLMJudge`, and `TrajectoryRubric`. | No | [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/huggingface/OpenEnv/blob/main/examples/rubrics.ipynb) | -| [Wordle GRPO](wordle-grpo.md) | Train an agent to play Wordle using GRPO via TRL's `environment_factory`. | Yes | [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/huggingface/trl/blob/main/examples/grpo_wordle/grpo_wordle.ipynb) | -| [RL Training with 2048](rl-training-2048.md) | Train a language model to play 2048 using GRPO. Covers game-state representation and reward shaping. | Yes | — | -| [Evaluating agents with Inspect AI](evaluation-inspect.md) | Wrap an OpenEnv environment in an Inspect AI `Task`, run it via `InspectAIHarness`, and get a structured `EvalResult`. | No | [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/huggingface/OpenEnv/blob/main/examples/evaluation_inspect.ipynb) | -| [BrowserGym Harness Rollouts](browsergym-harness.md) | Drive BrowserGym through the OpenEnv harness runtime when a trainer needs token sampling, logprobs, and reward assignment inside the training loop. | Yes | — | -| [Training a Real Coding Agent](opencode-agent-grpo.md) | Train the actual OpenCode agent (black-box, loop-owning) with TRL's `AsyncGRPOTrainer`: a transparent proxy captures each turn's token ids and logprobs while the agent runs its own tool loop. | Yes | — | -| [Collecting rollouts for supervised training](sft-warmup.md) | Run a teacher model to collect reward-labeled rollouts, filter them, and fine-tune a student with TRL's `SFTTrainer` as a warm-start for GRPO. | Yes | [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/huggingface/OpenEnv/blob/main/examples/sft_warmup.ipynb) | -| [Training a Real Coding Agent (Pi)](pi-agent-grpo.md) | Train the actual Pi agent (black-box, loop-owning) with TRL's AsyncGRPOTrainer: a transparent proxy captures each turn's token ids and logprobs while the agent runs its own tool loop. | Yes | — | - +| [OpenEnv Tutorial](openenv-tutorial) | Full introduction to OpenEnv: install, connect to a hosted environment, step through an episode, define a reward function, and run a basic training loop. | No | [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/huggingface/OpenEnv/blob/main/examples/OpenEnv_Tutorial.ipynb) | +| [End-to-end walkthrough](end-to-end-walkthrough) | The full pipeline: connect to `reasoning_gym`, wire it into TRL via `environment_factory`, fine-tune with GRPO, and push the checkpoint to the Hub. | Yes | [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/huggingface/OpenEnv/blob/main/examples/end_to_end_walkthrough.ipynb) | +| [Building and using MCP environments](mcp-environment) | Consume and build MCP-backed environments: list and call tools through `step()`, register Python functions as tools with FastMCP. | No | [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/huggingface/OpenEnv/blob/main/examples/mcp_environment.ipynb) | +| [Rubrics](rubrics) | Compose reward functions from reusable pieces using `Gate`, `WeightedSum`, `LLMJudge`, and `TrajectoryRubric`. | No | [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/huggingface/OpenEnv/blob/main/examples/rubrics.ipynb) | +| [Wordle GRPO](wordle-grpo) | Train an agent to play Wordle using GRPO via TRL's `environment_factory`. | Yes | [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/huggingface/trl/blob/main/examples/grpo_wordle/grpo_wordle.ipynb) | +| [RL Training with 2048](rl-training-2048) | Train a language model to play 2048 using GRPO. Covers game-state representation and reward shaping. | Yes | — | +| [Evaluating with Environments](evaluation-inspect) | Wrap an OpenEnv environment in an Inspect AI `Task`, run it via `InspectAIHarness`, and get a structured `EvalResult`. | No | [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/huggingface/OpenEnv/blob/main/examples/evaluation_inspect.ipynb) | +| [BrowserGym Harness Rollouts](browsergym-harness) | Drive BrowserGym through the OpenEnv harness runtime when a trainer needs token sampling, logprobs, and reward assignment inside the training loop. | Yes | — | +| [Training a Real Coding Agent](opencode-agent-grpo) | Train the actual OpenCode agent (black-box, loop-owning) with TRL's `AsyncGRPOTrainer`: a transparent proxy captures each turn's token ids and logprobs while the agent runs its own tool loop. | Yes | — | +| [Collecting rollouts for supervised training](sft-warmup) | Run a teacher model to collect reward-labeled rollouts, filter them, and fine-tune a student with TRL's `SFTTrainer` as a warm-start for GRPO. | Yes | [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/huggingface/OpenEnv/blob/main/examples/sft_warmup.ipynb) | +| [Training a Real Coding Agent (Pi)](pi-agent-grpo) | Train the actual Pi agent (black-box, loop-owning) with TRL's AsyncGRPOTrainer: a transparent proxy captures each turn's token ids and logprobs while the agent runs its own tool loop. | Yes | — | diff --git a/docs/source/tutorials/openenv-tutorial.md b/docs/source/tutorials/openenv-tutorial.md index 0a7a6f8a9b..a2689585e8 100644 --- a/docs/source/tutorials/openenv-tutorial.md +++ b/docs/source/tutorials/openenv-tutorial.md @@ -233,14 +233,14 @@ Think of it like this: You don't run your database in the same process as your w ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” │ YOUR TRAINING CODE │ │ │ -│ env = OpenSpielEnv(...) ← Import the client │ +│ env = OpenSpielEnv(...).sync() ← Synchronous client │ │ result = env.reset() ← Type-safe! │ │ result = env.step(action) ← Type-safe! │ │ │ ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¬ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ │ - │ HTTP/JSON (Language-Agnostic) - │ POST /reset, POST /step, GET /state + │ WebSocket/JSON (Language-Agnostic) + │ reset, step, state messages on /ws │ ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā–¼ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” │ DOCKER CONTAINER │ @@ -256,12 +256,12 @@ Think of it like this: You don't run your database in the same process as your w ``` > [!NOTE] -> You never see HTTP details - just clean Python methods! +> You never see WebSocket details - just clean Python methods! > > ```python -> env.reset() # Under the hood: HTTP POST to /reset -> env.step(...) # Under the hood: HTTP POST to /step -> env.state() # Under the hood: HTTP GET to /state +> env.reset() # Under the hood: reset message over /ws +> env.step(...) # Under the hood: step message over /ws +> env.state() # Under the hood: state message over /ws > ``` > > The magic? OpenEnv handles all the plumbing. You focus on RL! ✨ @@ -272,46 +272,50 @@ Think of it like this: You don't run your database in the same process as your w **Running in Colab?** This cell will clone OpenEnv and install dependencies automatically. -**Running locally?** Make sure you're in the OpenEnv directory. +**Running locally?** From the OpenEnv directory, install the dependencies into +your active environment before running the cells: + +```bash +uv pip install -e . open_spiel +``` ```ipython3 -# Detect environment +import os +import subprocess +import sys +from pathlib import Path + try: import google.colab IN_COLAB = True - print("🌐 Running in Google Colab - Perfect!") except ImportError: IN_COLAB = False - print("šŸ’» Running locally - Nice!") if IN_COLAB: - print("\nšŸ“¦ Cloning OpenEnv repository...") - !git clone https://github.com/huggingface/OpenEnv.git > /dev/null 2>&1 - %cd OpenEnv + if not Path("OpenEnv").exists(): + subprocess.check_call(["git", "clone", "https://github.com/huggingface/OpenEnv.git"]) + os.chdir("OpenEnv") - print("šŸ“š Installing dependencies (this takes ~10 seconds)...") - !pip install -q fastapi uvicorn requests +# Run from the repository root, or its examples/ directory. +work_dir = Path.cwd() +if not (work_dir / "pyproject.toml").exists(): + work_dir = work_dir.parent +if not (work_dir / "envs" / "openspiel_env").is_dir(): + raise RuntimeError("Run this tutorial from the OpenEnv repository root.") - import sys - sys.path.insert(0, './src') - print("\nāœ… Setup complete! Everything is ready to go! šŸŽ‰") -else: - import sys - from pathlib import Path - sys.path.insert(0, str(Path.cwd().parent / 'src')) - print("āœ… Using local OpenEnv installation") +if IN_COLAB: + subprocess.check_call([ + sys.executable, "-m", "pip", "install", "-q", "-e", str(work_dir), "open_spiel" + ]) +for directory in (work_dir / "src", work_dir / "envs"): + sys.path.insert(0, str(directory)) -print("\nšŸš€ Ready to explore OpenEnv and build amazing things!") -print("šŸ’” Tip: Run cells top-to-bottom for the best experience.\n") +print("āœ… OpenEnv and OpenSpiel are ready") ``` **Output:** ``` -šŸ’» Running locally - Nice! -āœ… Using local OpenEnv installation - -šŸš€ Ready to explore OpenEnv and build amazing things! -šŸ’” Tip: Run cells top-to-bottom for the best experience. +āœ… OpenEnv and OpenSpiel are ready ``` --- @@ -321,12 +325,12 @@ print("šŸ’” Tip: Run cells top-to-bottom for the best experience.\n") ### Every OpenEnv Environment Has 3 Components: ``` -src/envs/your_env/ +envs/your_env/ ā”œā”€ā”€ šŸ“ models.py ← Type-safe contracts │ (Action, Observation, State) │ ā”œā”€ā”€ šŸ“± client.py ← What YOU import -│ (HTTPEnvClient implementation) +│ (EnvClient implementation) │ └── šŸ–„ļø server/ ā”œā”€ā”€ environment.py ← Game/simulation logic @@ -338,8 +342,8 @@ Let's explore the actual OpenEnv code to see how this works: ```python # Import OpenEnv's core abstractions -from core.env_server import Environment, Action, Observation, State -from core.http_env_client import HTTPEnvClient +from openenv.core.env_server import Environment, Action, Observation, State +from openenv.core.env_client import EnvClient print("="*70) print(" 🧩 OPENENV CORE ABSTRACTIONS") @@ -365,21 +369,21 @@ print(""" šŸ“± CLIENT SIDE (your training code): - class HTTPEnvClient(ABC): - '''Base class for HTTP clients''' + class EnvClient(ABC): + '''Base class for environment clients''' def reset(self) -> StepResult: - # HTTP POST /reset + # WebSocket reset message def step(self, action) -> StepResult: - # HTTP POST /step + # WebSocket step message def state(self) -> State: - # HTTP GET /state + # WebSocket state message """) print("="*70) -print("\n✨ Same interface on both sides - communication via HTTP!") +print("\n✨ Same interface on both sides - communication via WebSocket!") print("šŸŽÆ You focus on RL, OpenEnv handles the infrastructure.\n") ``` @@ -408,21 +412,21 @@ print("šŸŽÆ You focus on RL, OpenEnv handles the infrastructure.\n") šŸ“± CLIENT SIDE (your training code): - class HTTPEnvClient(ABC): - '''Base class for HTTP clients''' + class EnvClient(ABC): + '''Base class for environment clients''' def reset(self) -> StepResult: - # HTTP POST /reset + # WebSocket reset message def step(self, action) -> StepResult: - # HTTP POST /step + # WebSocket step message def state(self) -> State: - # HTTP GET /state + # WebSocket state message ====================================================================== -✨ Same interface on both sides - communication via HTTP! +✨ Same interface on both sides - communication via WebSocket! šŸŽÆ You focus on RL, OpenEnv handles the infrastructure. ``` @@ -448,24 +452,24 @@ We've wrapped **6 OpenSpiel games** following the OpenEnv pattern: This shows how OpenEnv can wrap **any** existing RL library! ```python -from envs.openspiel_env.client import OpenSpielEnv +from openspiel_env.client import OpenSpielEnv print("="*70) print(" šŸ”Œ HOW OPENENV WRAPS OPENSPIEL") print("="*70) print(""" -class OpenSpielEnv(HTTPEnvClient[OpenSpielAction, OpenSpielObservation]): +class OpenSpielEnv(EnvClient[OpenSpielAction, OpenSpielObservation, OpenSpielState]): def _step_payload(self, action: OpenSpielAction) -> dict: - '''Convert typed action to JSON for HTTP''' + '''Convert typed action to JSON for WebSocket''' return { "action_id": action.action_id, "game_name": action.game_name, } def _parse_result(self, payload: dict) -> StepResult: - '''Parse HTTP JSON response into typed observation''' + '''Parse JSON response into typed observation''' return StepResult( observation=OpenSpielObservation(...), reward=payload['reward'], @@ -477,7 +481,7 @@ class OpenSpielEnv(HTTPEnvClient[OpenSpielAction, OpenSpielObservation]): print("─" * 70) print("\n✨ Usage (works for ALL OpenEnv environments):") print(""" - env = OpenSpielEnv(base_url="http://localhost:8000") + env = OpenSpielEnv(base_url="http://localhost:8000").sync() result = env.reset() # Returns StepResult[OpenSpielObservation] - Type safe! @@ -499,17 +503,17 @@ print("\nšŸŽÆ This pattern works for ANY environment you want to wrap!\n") šŸ”Œ HOW OPENENV WRAPS OPENSPIEL ====================================================================== -class OpenSpielEnv(HTTPEnvClient[OpenSpielAction, OpenSpielObservation]): +class OpenSpielEnv(EnvClient[OpenSpielAction, OpenSpielObservation, OpenSpielState]): def _step_payload(self, action: OpenSpielAction) -> dict: - '''Convert typed action to JSON for HTTP''' + '''Convert typed action to JSON for WebSocket''' return { "action_id": action.action_id, "game_name": action.game_name, } def _parse_result(self, payload: dict) -> StepResult: - '''Parse HTTP JSON response into typed observation''' + '''Parse JSON response into typed observation''' return StepResult( observation=OpenSpielObservation(...), reward=payload['reward'], @@ -521,7 +525,7 @@ class OpenSpielEnv(HTTPEnvClient[OpenSpielAction, OpenSpielObservation]): ✨ Usage (works for ALL OpenEnv environments): - env = OpenSpielEnv(base_url="http://localhost:8000") + env = OpenSpielEnv(base_url="http://localhost:8000").sync() result = env.reset() # Returns StepResult[OpenSpielObservation] - Type safe! @@ -541,12 +545,11 @@ class OpenSpielEnv(HTTPEnvClient[OpenSpielAction, OpenSpielObservation]): ```python # Import OpenSpiel integration models -from envs.openspiel_env.models import ( +from openspiel_env.models import ( OpenSpielAction, OpenSpielObservation, OpenSpielState ) -from dataclasses import fields print("="*70) print(" šŸŽ® OPENSPIEL INTEGRATION - TYPE-SAFE MODELS") @@ -554,18 +557,18 @@ print("="*70) print("\nšŸ“¤ OpenSpielAction (what you send):") print(" " + "─" * 64) -for field in fields(OpenSpielAction): - print(f" • {field.name:20s} : {field.type}") +for name, field in OpenSpielAction.model_fields.items(): + print(f" • {name:20s} : {field.annotation}") print("\nšŸ“„ OpenSpielObservation (what you receive):") print(" " + "─" * 64) -for field in fields(OpenSpielObservation): - print(f" • {field.name:20s} : {field.type}") +for name, field in OpenSpielObservation.model_fields.items(): + print(f" • {name:20s} : {field.annotation}") print("\nšŸ“Š OpenSpielState (episode metadata):") print(" " + "─" * 64) -for field in fields(OpenSpielState): - print(f" • {field.name:20s} : {field.type}") +for name, field in OpenSpielState.model_fields.items(): + print(f" • {name:20s} : {field.annotation}") print("\n" + "="*70) print("\nšŸ’” Type safety means:") @@ -584,30 +587,30 @@ print(" āœ… Self-documenting code\n") šŸ“¤ OpenSpielAction (what you send): ──────────────────────────────────────────────────────────────── • metadata : typing.Dict[str, typing.Any] - • action_id : int - • game_name : str - • game_params : Dict[str, Any] + • action_id : + • game_name : + • game_params : typing.Dict[str, typing.Any] šŸ“„ OpenSpielObservation (what you receive): ──────────────────────────────────────────────────────────────── • done : - • reward : typing.Union[bool, int, float, NoneType] + • reward : bool | int | float | None • metadata : typing.Dict[str, typing.Any] - • info_state : List[float] - • legal_actions : List[int] - • game_phase : str - • current_player_id : int - • opponent_last_action : Optional[int] + • info_state : typing.List[float] + • legal_actions : typing.List[int] + • game_phase : + • current_player_id : + • opponent_last_action : typing.Optional[int] šŸ“Š OpenSpielState (episode metadata): ──────────────────────────────────────────────────────────────── • episode_id : typing.Optional[str] • step_count : - • game_name : str - • agent_player : int - • opponent_policy : str - • game_params : Dict[str, Any] - • num_players : int + • game_name : + • agent_player : + • opponent_policy : + • game_params : typing.Dict[str, typing.Any] + • num_players : ====================================================================== @@ -620,13 +623,13 @@ print(" āœ… Self-documenting code\n") ### How the Client Works -The client **inherits from HTTPEnvClient** and implements 3 methods: +The client **inherits from EnvClient** and implements 3 methods: 1. `_step_payload()` - Convert action → JSON 2. `_parse_result()` - Parse JSON → typed observation 3. `_parse_state()` - Parse JSON → state -That's it! The base class handles all HTTP communication. +That's it! The base class handles the WebSocket session. --- @@ -680,11 +683,11 @@ This is a REAL environment running in production at companies! **Reward:** - `+1` if caught šŸŽ‰ -- `0` if missed 😢 +- `-1` if missed 😢 > [!NOTE] > - Simple rules (easy to understand) -> - Fast episodes (~5 steps) +> - Fast episodes (~9 steps) > - Clear success/failure > - Part of OpenSpiel's 70+ games! > @@ -692,34 +695,33 @@ This is a REAL environment running in production at companies! > Instead of building this from scratch, we'll USE OpenEnv's existing OpenSpiel integration. Same interface, but production-ready! ```python -from envs.openspiel_env import OpenSpielEnv -from envs.openspiel_env.models import ( +from openspiel_env import OpenSpielEnv +from openspiel_env.models import ( OpenSpielAction, OpenSpielObservation, OpenSpielState ) -from dataclasses import fields print("šŸŽ® " + "="*64 + " šŸŽ®") print(" āœ… Importing Real OpenSpiel Environment!") print("šŸŽ® " + "="*64 + " šŸŽ®\n") print("šŸ“¦ What we just imported:") -print(" • OpenSpielEnv - HTTP client for OpenSpiel games") +print(" • OpenSpielEnv - WebSocket client for OpenSpiel games") print(" • OpenSpielAction - Type-safe actions") print(" • OpenSpielObservation - Type-safe observations") print(" • OpenSpielState - Episode metadata\n") print("šŸ“‹ OpenSpielObservation fields:") print(" " + "─" * 60) -for field in fields(OpenSpielObservation): - print(f" • {field.name:25s} : {field.type}") +for name, field in OpenSpielObservation.model_fields.items(): + print(f" • {name:25s} : {field.annotation}") print("\n" + "="*70) print("\nšŸ’” This is REAL OpenEnv code - used in production!") print(" • Wraps 6 OpenSpiel games (Catch, Tic-Tac-Toe, Poker, etc.)") print(" • Type-safe actions and observations") -print(" • Works via HTTP (we'll see that next!)\n") +print(" • Works via WebSocket (we'll see that next!)\n") ``` **Output:** @@ -729,7 +731,7 @@ print(" • Works via HTTP (we'll see that next!)\n") šŸŽ® ================================================================ šŸŽ® šŸ“¦ What we just imported: - • OpenSpielEnv - HTTP client for OpenSpiel games + • OpenSpielEnv - WebSocket client for OpenSpiel games • OpenSpielAction - Type-safe actions • OpenSpielObservation - Type-safe observations • OpenSpielState - Episode metadata @@ -737,20 +739,76 @@ print(" • Works via HTTP (we'll see that next!)\n") šŸ“‹ OpenSpielObservation fields: ──────────────────────────────────────────────────────────── • done : - • reward : typing.Union[bool, int, float, NoneType] + • reward : bool | int | float | None • metadata : typing.Dict[str, typing.Any] - • info_state : List[float] - • legal_actions : List[int] - • game_phase : str - • current_player_id : int - • opponent_last_action : Optional[int] + • info_state : typing.List[float] + • legal_actions : typing.List[int] + • game_phase : + • current_player_id : + • opponent_last_action : typing.Optional[int] ====================================================================== šŸ’” This is REAL OpenEnv code - used in production! • Wraps 6 OpenSpiel games (Catch, Tic-Tac-Toe, Poker, etc.) • Type-safe actions and observations - • Works via HTTP (we'll see that next!) + • Works via WebSocket (we'll see that next!) +``` + +### Start the local server + +Run this cell before evaluating policies. It starts Catch and waits for the +health endpoint. Set `PORT` to an unused local port (8000 by default). Server +output is written to `openspiel-server.log`. +The synchronous wrapper also works in notebooks with an active event loop. + +```python +import socket +import time +import requests + +PORT = 8000 +BASE_URL = f"http://127.0.0.1:{PORT}" + +server_env = { + **os.environ, + "PYTHONPATH": os.pathsep.join( + [str(work_dir / "src"), str(work_dir / "envs"), os.environ.get("PYTHONPATH", "")] + ), + "OPENSPIEL_GAME": "catch", +} + +def start_server(game_name="catch"): + # Fail clearly if another service already owns this port. + with socket.socket() as probe: + if probe.connect_ex(("127.0.0.1", PORT)) == 0: + raise RuntimeError(f"Port {PORT} is already in use; choose another PORT.") + with (work_dir / "openspiel-server.log").open("w") as log: + process = subprocess.Popen( + [sys.executable, "-m", "uvicorn", "openspiel_env.server.app:app", + "--host", "127.0.0.1", "--port", str(PORT)], + cwd=work_dir, + env={**server_env, "OPENSPIEL_GAME": game_name}, + stdout=log, + stderr=subprocess.STDOUT, + ) + for _ in range(100): + if process.poll() is not None: + raise RuntimeError("Server exited; check openspiel-server.log") + try: + if requests.get(f"{BASE_URL}/health", timeout=1).ok: + return process + except requests.RequestException: + pass + time.sleep(0.1) + process.terminate() + process.wait(timeout=10) + raise RuntimeError("Server did not become ready; check openspiel-server.log") + +server_process = start_server() +client = OpenSpielEnv(base_url=BASE_URL).sync() +client.connect() +print(client.reset().observation) ``` --- @@ -766,7 +824,9 @@ Let's test 4 different AI strategies: | **🧠 Smart** | Move paddle toward ball | 100% (optimal!) | | **šŸ“ˆ Learning** | Start random, learn smart strategy | ~85% (improves over time) | -**šŸ’” These policies work with ANY OpenSpiel game!** +**These policies use the OpenSpiel observation type. The movement heuristics below are specific to Catch.** + +`LearningPolicy` illustrates decaying exploration with a supplied heuristic; it does not learn from rewards. ```python import random @@ -859,7 +919,7 @@ for i, policy in enumerate(policies, 1): print("\nšŸ’” These policies work with OpenSpielObservation!") print(" • Read info_state (flattened grid)") print(" • Use legal_actions") -print(" • Work with ANY OpenSpiel game that exposes these!\n") +print(" • Use Catch-specific movement and grid assumptions\n") ``` **Output:** @@ -876,7 +936,7 @@ print(" • Work with ANY OpenSpiel game that exposes these!\n") šŸ’” These policies work with OpenSpielObservation! • Read info_state (flattened grid) • Use legal_actions - • Work with ANY OpenSpiel game that exposes these! + • Use Catch-specific movement and grid assumptions ``` --- @@ -885,9 +945,22 @@ print(" • Work with ANY OpenSpiel game that exposes these!\n") Let's run **50 episodes** for each policy against **REAL OpenSpiel** and see who wins! -This is production code - every action is an HTTP call to the OpenSpiel server! +This is production code - every action is a WebSocket message to the OpenSpiel server! ```python +def run_episode(env, policy, visualize=False): + """Play one Catch episode and return whether the ball was caught.""" + result = env.reset() + for _ in range(100): + if result.done: + return float(result.reward or 0) > 0 + action_id = policy.select_action(result.observation) + result = env.step(OpenSpielAction(action_id=action_id, game_name="catch")) + if visualize: + print(result.observation.info_state) + raise RuntimeError("Catch episode exceeded 100 steps") + + def evaluate_policies(env, num_episodes=50): """Compare all policies over many episodes using real OpenSpiel.""" policies = [ @@ -934,8 +1007,8 @@ def evaluate_policies(env, num_episodes=50): print(" • Learning (~85%): Improves over time šŸ“ˆ") print("\nšŸŽ“ This is Reinforcement Learning + OpenEnv in action:") print(" 1. We USED existing OpenSpiel environment (didn't build it)") - print(" 2. Type-safe communication over HTTP") - print(" 3. Same code works for ANY OpenSpiel game") + print(" 2. Type-safe communication over WebSocket") + print(" 3. Other games use the same client with game-specific policies") print(" 4. Production-ready architecture\n") # Run the epic competition! @@ -955,10 +1028,10 @@ In Parts 6-8, we **USED** the existing OpenSpiel Catch environment: |-------------|--------------| | **Imported** | OpenSpielEnv client (pre-built) | | **Started** | OpenSpiel server via uvicorn | -| **Connected** | HTTP client to server | +| **Connected** | WebSocket client to server | | **Played** | Real OpenSpiel Catch game | -**šŸŽÆ This is production code!** Every action was an HTTP call to a real OpenSpiel environment. +**šŸŽÆ This is production code!** Every action was a WebSocket message to a real OpenSpiel environment. ### šŸŽ® 6 Games Available - Same Interface! @@ -966,7 +1039,7 @@ The beauty of OpenEnv? **Same code, different games!** ```python # We just used Catch -env = OpenSpielEnv(base_url="http://localhost:8000") +env = OpenSpielEnv(base_url=BASE_URL).sync() # game_name="catch" was set via environment variable # Want Tic-Tac-Toe instead? Just change the game! @@ -988,56 +1061,55 @@ env = OpenSpielEnv(base_url="http://localhost:8000") ### Try Another Game (Optional): ```python -# Stop the current server (kill the server_process) -# Then start a new game: - -server_process = subprocess.Popen( - [sys.executable, "-m", "uvicorn", - "envs.openspiel_env.server.app:app", - "--host", "0.0.0.0", - "--port", "8000"], - env={**os.environ, - "PYTHONPATH": f"{work_dir}/src", - "OPENSPIEL_GAME": "tic_tac_toe", # Changed! - "OPENSPIEL_AGENT_PLAYER": "0", - "OPENSPIEL_OPPONENT_POLICY": "random"}, - # ... rest of config -) - -# Same client works! -client = OpenSpielEnv(base_url="http://localhost:8000") -result = client.reset() # Now playing Tic-Tac-Toe! +client.close() +server_process.terminate() +server_process.wait(timeout=10) + +server_process = start_server("tic_tac_toe") +client = OpenSpielEnv(base_url=BASE_URL).sync() +client.connect() +result = client.reset() +result = client.step(OpenSpielAction( + action_id=result.observation.legal_actions[0], game_name="tic_tac_toe" +)) ``` **šŸ’” Key Insight**: You don't rebuild anything - you just USE different games with the same client! +### Clean up + +After the competition (and the optional game switch), close the client and stop +the local server: + +```python +client.close() +server_process.terminate() +server_process.wait(timeout=10) +``` + --- ## Part 10: Create Your Own Integration šŸ› ļø ### The 5-Step Pattern -Want to wrap your own environment in OpenEnv? Here's how: +Want to wrap your own environment in OpenEnv? These skeletons show the structure; replace the `...` placeholders with your environment logic. For a complete runnable example, follow [Your First Environment](../guides/first-environment). ### Step 1: Define Types (`models.py`) ```python -from dataclasses import dataclass -from core.env_server import Action, Observation, State +from openenv.core.env_server import Action, Observation, State -@dataclass class YourAction(Action): action_value: int # Add your action fields -@dataclass class YourObservation(Observation): - state_data: List[float] + state_data: list[float] done: bool reward: float # Add your observation fields -@dataclass class YourState(State): episode_id: str step_count: int @@ -1047,29 +1119,31 @@ class YourState(State): ### Step 2: Implement Environment (`server/environment.py`) ```python -from core.env_server import Environment +from openenv.core.env_server import Environment +from ..models import YourAction, YourObservation, YourState -class YourEnvironment(Environment): - def reset(self) -> Observation: +class YourEnvironment(Environment[YourAction, YourObservation, YourState]): + def reset(self) -> YourObservation: # Initialize your game/simulation return YourObservation(...) - def step(self, action: Action) -> Observation: + def step(self, action: YourAction) -> YourObservation: # Execute action, update state return YourObservation(...) @property - def state(self) -> State: + def state(self) -> YourState: return self._state ``` ### Step 3: Create Client (`client.py`) ```python -from core.http_env_client import HTTPEnvClient -from core.types import StepResult +from openenv.core.env_client import EnvClient +from openenv.core.client_types import StepResult +from .models import YourAction, YourObservation, YourState -class YourEnv(HTTPEnvClient[YourAction, YourObservation]): +class YourEnv(EnvClient[YourAction, YourObservation, YourState]): def _step_payload(self, action: YourAction) -> dict: """Convert action to JSON""" return {"action_value": action.action_value} @@ -1089,17 +1163,22 @@ class YourEnv(HTTPEnvClient[YourAction, YourObservation]): ### Step 4: Create Server (`server/app.py`) ```python -from core.env_server import create_fastapi_app -from .your_environment import YourEnvironment +from openenv.core.env_server import create_app +from ..models import YourAction, YourObservation, YourState +from .environment import YourEnvironment -env = YourEnvironment() -app = create_fastapi_app(env) +app = create_app( + YourEnvironment, YourAction, YourObservation, state_cls=YourState +) # That's it! OpenEnv creates all endpoints for you. ``` ### Step 5: Dockerize (`server/Dockerfile`) +Build from a project directory containing the `your_env` package and a +`requirements.txt` that includes `openenv` plus your environment dependencies. + ```dockerfile FROM python:3.11-slim @@ -1108,23 +1187,23 @@ COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY . . -CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"] +CMD ["uvicorn", "your_env.server.app:app", "--host", "0.0.0.0", "--port", "8000"] ``` ### šŸŽ“ Examples to Study OpenEnv includes 3 complete examples: -1. **`src/envs/echo_env/`** +1. **`envs/echo_env/`** - Simplest possible environment - Great for testing and learning -2. **`src/envs/openspiel_env/`** +2. **`envs/openspiel_env/`** - Wraps external library (OpenSpiel) - Shows integration pattern - 6 games in one integration -3. **`src/envs/coding_env/`** +3. **`envs/coding_env/`** - Python code execution environment - Shows complex use case - Security considerations @@ -1154,7 +1233,7 @@ OpenEnv includes 3 complete examples: - Client-server separation - Type-safe contracts -- HTTP communication layer +- WebSocket communication layer āœ… **Production Patterns** @@ -1177,7 +1256,7 @@ OpenEnv includes 3 complete examples: - Define type-safe models - Implement Environment class -- Create HTTPEnvClient +- Create EnvClient āœ… **Testing & Debugging** @@ -1193,7 +1272,7 @@ OpenEnv includes 3 complete examples: | Feature | Traditional (Gym) | OpenEnv | Winner | |---------|------------------|---------|--------| -| **Type Safety** | āŒ Arrays, dicts | āœ… Dataclasses | šŸ† OpenEnv | +| **Type Safety** | āŒ Arrays, dicts | āœ… Pydantic models | šŸ† OpenEnv | | **Isolation** | āŒ Same process | āœ… Docker | šŸ† OpenEnv | | **Deployment** | āŒ Manual setup | āœ… K8s-ready | šŸ† OpenEnv | | **Language** | āŒ Python only | āœ… Any (HTTP) | šŸ† OpenEnv | @@ -1223,8 +1302,8 @@ OpenEnv includes 3 complete examples: ### šŸ“– Documentation Deep Dives -- **Environment Creation Guide**: `src/envs/README.md` -- **OpenSpiel Integration**: `src/envs/openspiel_env/README.md` +- **Environment Creation Guide**: `envs/README.md` +- **OpenSpiel Integration**: `envs/openspiel_env/README.md` - **Example Scripts**: `examples/` - **RFC 001**: [Baseline API Specs](https://github.com/huggingface/OpenEnv/pull/26) diff --git a/envs/git_env/README.md b/envs/git_env/README.md index 9e3dbecbfe..4047d039ed 100644 --- a/envs/git_env/README.md +++ b/envs/git_env/README.md @@ -177,7 +177,7 @@ docker run \ Then in your training code, environments automatically reset to the configured state. -See [`examples/local_git_env.py`](../../../examples/local_git_env.py) for complete working example. +See [`examples/local_git_env.py`](../../examples/local_git_env.py) for complete working example. ## Project Structure diff --git a/envs/opencode_env/README.md b/envs/opencode_env/README.md index 36ec3afb65..ade85b182f 100644 --- a/envs/opencode_env/README.md +++ b/envs/opencode_env/README.md @@ -317,4 +317,3 @@ opencode_env/ - [OpenEnv docs](https://huggingface.co/docs/openenv) - [OpenCode CLI](https://opencode.ai/docs/cli/) - [E2B Python SDK](https://e2b.dev/docs) -- [HF Inference Providers logprob matrix](../../../DOCS/HF/hf_inference_providers_logprobs.md) diff --git a/envs/wildfire_env/README.md b/envs/wildfire_env/README.md index 98a8f474ef..7da1cfd07c 100644 --- a/envs/wildfire_env/README.md +++ b/envs/wildfire_env/README.md @@ -22,7 +22,7 @@ Agents must contain spreading fires using **water**, **firebreaks**, and **timin [![Docker](https://img.shields.io/badge/docker-ready-blue)](https://hub.docker.com/) [![Python](https://img.shields.io/badge/python-3.10+-green)](https://www.python.org/) [![FastAPI](https://img.shields.io/badge/backend-fastapi-teal)](https://fastapi.tiangolo.com/) -[![License](https://img.shields.io/badge/license-MIT-lightgrey)](LICENSE) +[![License](https://img.shields.io/badge/license-BSD--3--Clause-lightgrey)](../../LICENSE) --- diff --git a/scripts/sync_env_docs.py b/scripts/sync_env_docs.py index 8b29969781..dac6ea1f61 100644 --- a/scripts/sync_env_docs.py +++ b/scripts/sync_env_docs.py @@ -24,6 +24,8 @@ import os import re import sys +from pathlib import Path +from urllib.parse import quote, unquote, urlsplit, urlunsplit ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) ENVS_DIR = os.path.join(ROOT, "envs") @@ -31,6 +33,7 @@ TOCTREE_PATH = os.path.join(ROOT, "docs", "source", "_toctree.yml") CATALOG_PATH = os.path.join(ROOT, "docs", "source", "environments.md") GITHUB_RAW_BASE = "https://raw.githubusercontent.com/huggingface/OpenEnv/main" +GITHUB_BASE = "https://github.com/huggingface/OpenEnv" SKIP_DIRS = {"README.md"} @@ -102,6 +105,69 @@ def _strip_frontmatter(text): return text +def _rewrite_relative_links(text, env_dir): + """Keep README links working after moving their content into the docs site.""" + root = Path(ROOT).resolve() + source_dir = Path(ENVS_DIR) / env_dir + pattern = re.compile( + r"(?P`+).*?(?P=code)" + r"|(?P!\[[^\]\n]*\]\()(?P[^)\s]+)" + r"|(?P\]\()(?P<[^>\n]+>|[^)\s]+)" + r"|(?P(?:href|src)=[\"'])(?P[^\"'\n]+)" + ) + + def rewrite(match): + if match.group("code"): + return match.group(0) + kind = next(k for k in ("image", "link", "attribute") if match.group(k)) + original = match.group(f"{kind}_url") + url = original.strip("<>") + parsed = urlsplit(url) + if parsed.scheme or parsed.netloc or not parsed.path or url.startswith("/"): + return match.group(0) + target = (source_dir / unquote(parsed.path)).resolve() + try: + relative = target.relative_to(root) + except ValueError: + return match.group(0) + if not target.exists(): + return match.group(0) + is_image = kind == "image" or match.group(kind).startswith("src=") + if is_image: + base = GITHUB_RAW_BASE + else: + base = f"{GITHUB_BASE}/{'tree' if target.is_dir() else 'blob'}/main" + destination = f"{base}/{quote(relative.as_posix())}" + destination = urlunsplit( + (*urlsplit(destination)[:3], parsed.query, parsed.fragment) + ) + if original.startswith("<"): + destination = f"<{destination}>" + return match.group(kind) + destination + + lines = [] + fence = None + for line in text.splitlines(keepends=True): + marker = re.match(r"^ {0,3}(`{3,}|~{3,})(.*)$", line) + if fence: + if ( + marker + and marker[1][0] == fence[0] + and len(marker[1]) >= len(fence) + and not marker[2].strip() + ): + fence = None + lines.append(line) + elif marker: + fence = marker[1] + lines.append(line) + elif line.startswith((" ", "\t")): + lines.append(line) + else: + lines.append(pattern.sub(rewrite, line)) + return "".join(lines) + + def generate_stub(env_dir): """Return the doc stub content for an environment. @@ -113,10 +179,7 @@ def generate_stub(env_dir): with open(readme_path) as f: content = f.read() content = _strip_frontmatter(content) - # Rewrite relative assets/ paths to absolute GitHub raw URLs so images - # render correctly when the README is inlined into the doc-builder site. - base_url = f"{GITHUB_RAW_BASE}/envs/{env_dir}" - content = re.sub(r'(src=["\'])assets/', rf'\1{base_url}/assets/', content) + content = _rewrite_relative_links(content, env_dir) return f"\n{content}" @@ -299,9 +362,15 @@ def main(): description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter ) group = parser.add_mutually_exclusive_group(required=True) - group.add_argument("--check", action="store_true", help="Check sync status (CI mode)") - group.add_argument("--fix", action="store_true", help="Fix missing, stale, and orphaned stubs") - group.add_argument("--dry-run", action="store_true", help="Preview --fix without writing") + group.add_argument( + "--check", action="store_true", help="Check sync status (CI mode)" + ) + group.add_argument( + "--fix", action="store_true", help="Fix missing, stale, and orphaned stubs" + ) + group.add_argument( + "--dry-run", action="store_true", help="Preview --fix without writing" + ) args = parser.parse_args() env_dirs = get_env_dirs() @@ -338,7 +407,9 @@ def main(): manual_toctree = sorted(set(unlisted_toctree) | {slug for _, slug in missing}) manual_catalog = sorted(set(unlisted_catalog) | {slug for _, slug in missing}) if manual_toctree: - print("\nāš ļø Add these to docs/source/_toctree.yml manually (- local: environments/):") + print( + "\nāš ļø Add these to docs/source/_toctree.yml manually (- local: environments/):" + ) for slug in manual_toctree: print(f" {slug}") if manual_catalog: diff --git a/tests/scripts/test_sync_env_docs.py b/tests/scripts/test_sync_env_docs.py new file mode 100644 index 0000000000..053d722c0a --- /dev/null +++ b/tests/scripts/test_sync_env_docs.py @@ -0,0 +1,90 @@ +"""Regression checks for repository links in generated environment docs.""" + +import importlib.util +from pathlib import Path + +import pytest + + +spec = importlib.util.spec_from_file_location( + "sync_env_docs", Path(__file__).parents[2] / "scripts" / "sync_env_docs.py" +) +sync_env_docs = importlib.util.module_from_spec(spec) +spec.loader.exec_module(sync_env_docs) + + +@pytest.fixture +def repo(tmp_path, monkeypatch): + root = tmp_path / "repo" + env = root / "envs" / "example_env" + env.mkdir(parents=True) + (env / "client.py").touch() + (env / "image.png").touch() + (root / "LICENSE").touch() + (root / "examples").mkdir() + (root / "examples" / "with spaces.py").touch() + monkeypatch.setattr(sync_env_docs, "ROOT", str(root)) + monkeypatch.setattr(sync_env_docs, "ENVS_DIR", str(root / "envs")) + return root + + +def test_rewrites_files_directories_images_and_preserves_url_suffixes(repo): + source = """[client](client.py?plain=1#L2 "source") +[examples](../../examples/) +[spaced](<../../examples/with spaces.py>) +![image](image.png) +sample +client +[![License](https://img.shields.io/badge/license-BSD-blue)](../../LICENSE) +""" + result = sync_env_docs._rewrite_relative_links(source, "example_env") + github = "https://github.com/huggingface/OpenEnv" + raw = "https://raw.githubusercontent.com/huggingface/OpenEnv/main" + assert ( + result + == f"""[client]({github}/blob/main/envs/example_env/client.py?plain=1#L2 "source") +[examples]({github}/tree/main/examples) +[spaced](<{github}/blob/main/examples/with%20spaces.py>) +![image]({raw}/envs/example_env/image.png) +sample +client +[![License](https://img.shields.io/badge/license-BSD-blue)]({github}/blob/main/LICENSE) +""" + ) + + +def test_leaves_code_anchors_external_missing_and_outside_paths_unchanged(repo): + (repo.parent / "outside.txt").touch() + (repo / "envs" / "example_env" / "outside-link").symlink_to( + repo.parent / "outside.txt" + ) + source = """`[client](client.py)` + [client](client.py) +````markdown +```python +[client](client.py) +``` +```` +~~~markdown + +~~~ +[anchor](#usage) +[external](https://example.com/client.py) +[protocol-relative](//example.com/client.py) +[absolute](/client.py) +[missing](missing.py) +[outside](../../../outside.txt) +[symlink](outside-link) +""" + assert sync_env_docs._rewrite_relative_links(source, "example_env") == source + + +def test_generate_stub_keeps_inline_code_labels_and_strips_frontmatter(repo): + readme = repo / "envs" / "example_env" / "README.md" + readme.write_text( + "---\ntitle: Example\n---\n# Example\n\n[`client.py`](client.py)\n" + ) + assert sync_env_docs.generate_stub("example_env") == ( + "\n# Example\n\n" + "[`client.py`](https://github.com/huggingface/OpenEnv/blob/main/envs/example_env/client.py)\n" + ) From 96e990fdf4b32068175bac9d9c5a97506b3b85e3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 21 Sep 2026 12:35:28 +0000 Subject: [PATCH 2/8] docs: make Colab setup cell rerunnable --- docs/source/tutorials/openenv-tutorial.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/source/tutorials/openenv-tutorial.md b/docs/source/tutorials/openenv-tutorial.md index a2689585e8..7762a133b6 100644 --- a/docs/source/tutorials/openenv-tutorial.md +++ b/docs/source/tutorials/openenv-tutorial.md @@ -291,7 +291,7 @@ try: except ImportError: IN_COLAB = False -if IN_COLAB: +if IN_COLAB and Path.cwd().name != "OpenEnv": if not Path("OpenEnv").exists(): subprocess.check_call(["git", "clone", "https://github.com/huggingface/OpenEnv.git"]) os.chdir("OpenEnv") From 1694071a3ae0ee18d870b9832d954bc2b49463a8 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 21 Sep 2026 13:14:17 +0000 Subject: [PATCH 3/8] fix: address docs navigation review feedback Co-authored-by: burtenshaw --- docs/source/getting-started.md | 5 ++- scripts/sync_env_docs.py | 65 +++++++++++++++++++++++++---- tests/envs/test_auto_env.py | 31 ++++++++++++++ tests/scripts/test_sync_env_docs.py | 52 +++++++++++++++++++++++ 4 files changed, 144 insertions(+), 9 deletions(-) diff --git a/docs/source/getting-started.md b/docs/source/getting-started.md index 18eac18b80..f40fe8a444 100644 --- a/docs/source/getting-started.md +++ b/docs/source/getting-started.md @@ -32,7 +32,10 @@ running Space. Echo exposes its actions as MCP tools. ```python from openenv import AutoEnv -env = AutoEnv.from_env("openenv/echo-env") +env = AutoEnv.from_env( + "openenv/echo_env", + base_url="https://openenv-echo-env.hf.space", +) with env.sync() as client: result = client.reset() diff --git a/scripts/sync_env_docs.py b/scripts/sync_env_docs.py index dac6ea1f61..30b4cf96b6 100644 --- a/scripts/sync_env_docs.py +++ b/scripts/sync_env_docs.py @@ -24,6 +24,7 @@ import os import re import sys +import tempfile from pathlib import Path from urllib.parse import quote, unquote, urlsplit, urlunsplit @@ -122,7 +123,10 @@ def rewrite(match): kind = next(k for k in ("image", "link", "attribute") if match.group(k)) original = match.group(f"{kind}_url") url = original.strip("<>") - parsed = urlsplit(url) + try: + parsed = urlsplit(url) + except ValueError: + return match.group(0) if parsed.scheme or parsed.netloc or not parsed.path or url.startswith("/"): return match.group(0) target = (source_dir / unquote(parsed.path)).resolve() @@ -147,6 +151,7 @@ def rewrite(match): lines = [] fence = None + list_content_indents = [] for line in text.splitlines(keepends=True): marker = re.match(r"^ {0,3}(`{3,}|~{3,})(.*)$", line) if fence: @@ -161,10 +166,39 @@ def rewrite(match): elif marker: fence = marker[1] lines.append(line) - elif line.startswith((" ", "\t")): - lines.append(line) else: - lines.append(pattern.sub(rewrite, line)) + indentation = re.match(r"^[ \t]*", line)[0] + indent_width = len(indentation.expandtabs(4)) + list_marker = re.match( + r"^(?P[ \t]*)(?:[-+*]|\d{1,9}[.)])(?P[ \t]+)", + line, + ) + + if list_marker: + while list_content_indents and indent_width < list_content_indents[-1]: + list_content_indents.pop() + content_indent = len(list_marker.group(0).expandtabs(4)) + if ( + not list_content_indents + or content_indent != list_content_indents[-1] + ): + list_content_indents.append(content_indent) + lines.append(pattern.sub(rewrite, line)) + elif not line.strip(): + lines.append(line) + else: + while list_content_indents and indent_width < list_content_indents[-1]: + list_content_indents.pop() + is_list_continuation = ( + list_content_indents + and indent_width < list_content_indents[-1] + 4 + ) + if line.startswith((" ", "\t")) and not is_list_continuation: + lines.append(line) + else: + if not indentation: + list_content_indents.clear() + lines.append(pattern.sub(rewrite, line)) return "".join(lines) @@ -329,13 +363,29 @@ def run_fix(missing, orphaned, stale, dry_run=False): def label(action): return f"[dry-run] Would {action}" if dry_run else action + def write_stub(stub_path, env_dir): + content = generate_stub(env_dir) + temporary_path = None + try: + with tempfile.NamedTemporaryFile( + "w", + dir=os.path.dirname(stub_path), + prefix=f".{os.path.basename(stub_path)}.", + delete=False, + ) as temporary: + temporary_path = temporary.name + temporary.write(content) + os.replace(temporary_path, stub_path) + finally: + if temporary_path and os.path.exists(temporary_path): + os.remove(temporary_path) + for env_dir, slug in missing: stub_path = os.path.join(DOCS_ENVS_DIR, f"{slug}.md") if dry_run: print(f" {label('create')} {os.path.relpath(stub_path, ROOT)}") else: - with open(stub_path, "w") as f: - f.write(generate_stub(env_dir)) + write_stub(stub_path, env_dir) print(f" āœ… Created {os.path.relpath(stub_path, ROOT)}") for env_dir, slug in stale: @@ -343,8 +393,7 @@ def label(action): if dry_run: print(f" {label('refresh')} {os.path.relpath(stub_path, ROOT)}") else: - with open(stub_path, "w") as f: - f.write(generate_stub(env_dir)) + write_stub(stub_path, env_dir) print(f" šŸ”„ Refreshed {os.path.relpath(stub_path, ROOT)}") for env_dir, slug in orphaned: diff --git a/tests/envs/test_auto_env.py b/tests/envs/test_auto_env.py index 8e40975f79..58ca54785e 100644 --- a/tests/envs/test_auto_env.py +++ b/tests/envs/test_auto_env.py @@ -254,6 +254,37 @@ def test_from_hub_with_base_url(self, mock_discovery, mock_env_info): class TestAutoEnvHubDetection: """Test AutoEnv Hub URL detection and handling.""" + def test_documented_echo_space_path(self, mock_discovery, mock_env_info): + """Test the canonical Echo Space ID with its explicit runtime URL.""" + mock_discovery.get_environment_by_name.return_value = mock_env_info + mock_client_class = Mock() + mock_client_instance = Mock() + mock_client_class.return_value = mock_client_instance + mock_env_info.get_client_class = Mock(return_value=mock_client_class) + base_url = "https://openenv-echo-env.hf.space" + + with ( + patch("openenv.auto.auto_env.get_discovery", return_value=mock_discovery), + patch.object(AutoEnv, "_check_space_availability", return_value=False), + patch.object( + AutoEnv, + "_ensure_package_from_hub", + return_value="echo", + ) as ensure_package, + patch.object(AutoEnv, "_check_server_availability", return_value=True), + ): + result = AutoEnv.from_env( + "openenv/echo_env", + base_url=base_url, + ) + + assert result is mock_client_instance + ensure_package.assert_called_once_with( + "openenv/echo_env", + trust_remote_code=False, + ) + mock_client_class.assert_called_once_with(base_url=base_url, provider=None) + def test_resolve_space_url(self): """Test resolving HuggingFace Space URL.""" url = AutoEnv._resolve_space_url("wukaixingxp/coding-env-test") diff --git a/tests/scripts/test_sync_env_docs.py b/tests/scripts/test_sync_env_docs.py index 053d722c0a..1b1af77d04 100644 --- a/tests/scripts/test_sync_env_docs.py +++ b/tests/scripts/test_sync_env_docs.py @@ -2,6 +2,7 @@ import importlib.util from pathlib import Path +from unittest.mock import Mock import pytest @@ -23,8 +24,14 @@ def repo(tmp_path, monkeypatch): (root / "LICENSE").touch() (root / "examples").mkdir() (root / "examples" / "with spaces.py").touch() + (root / "docs" / "source" / "environments").mkdir(parents=True) monkeypatch.setattr(sync_env_docs, "ROOT", str(root)) monkeypatch.setattr(sync_env_docs, "ENVS_DIR", str(root / "envs")) + monkeypatch.setattr( + sync_env_docs, + "DOCS_ENVS_DIR", + str(root / "docs" / "source" / "environments"), + ) return root @@ -79,6 +86,21 @@ def test_leaves_code_anchors_external_missing_and_outside_paths_unchanged(repo): assert sync_env_docs._rewrite_relative_links(source, "example_env") == source +def test_rewrites_links_in_deeply_indented_list_items(repo): + source = """ - Client files: + [client](client.py) + + [client](client.py) +""" + github = "https://github.com/huggingface/OpenEnv" + assert sync_env_docs._rewrite_relative_links(source, "example_env") == ( + " - Client files:\n" + f" [client]({github}/blob/main/envs/example_env/client.py)\n" + "\n" + " [client](client.py)\n" + ) + + def test_generate_stub_keeps_inline_code_labels_and_strips_frontmatter(repo): readme = repo / "envs" / "example_env" / "README.md" readme.write_text( @@ -88,3 +110,33 @@ def test_generate_stub_keeps_inline_code_labels_and_strips_frontmatter(repo): "\n# Example\n\n" "[`client.py`](https://github.com/huggingface/OpenEnv/blob/main/envs/example_env/client.py)\n" ) + + +def test_run_fix_handles_malformed_url_without_truncating_stub(repo): + readme = repo / "envs" / "example_env" / "README.md" + readme.write_text("# Example\n\n[bad](http://[)\n") + stub = repo / "docs" / "source" / "environments" / "example.md" + stub.write_text("previous content\n") + + sync_env_docs.run_fix([], [], [("example_env", "example")]) + + assert stub.read_text() == ( + "\n" + "# Example\n\n" + "[bad](http://[)\n" + ) + + +def test_run_fix_preserves_stub_when_generation_fails(repo, monkeypatch): + stub = repo / "docs" / "source" / "environments" / "example.md" + stub.write_text("previous content\n") + monkeypatch.setattr( + sync_env_docs, + "generate_stub", + Mock(side_effect=ValueError("invalid README")), + ) + + with pytest.raises(ValueError, match="invalid README"): + sync_env_docs.run_fix([], [], [("example_env", "example")]) + + assert stub.read_text() == "previous content\n" From be69d876c31c176378dd9fb610149c7f737b1f64 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 21 Sep 2026 13:17:30 +0000 Subject: [PATCH 4/8] style: format docs sync review fixes Co-authored-by: burtenshaw --- scripts/sync_env_docs.py | 5 ++--- tests/scripts/test_sync_env_docs.py | 4 +--- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/scripts/sync_env_docs.py b/scripts/sync_env_docs.py index 30b4cf96b6..99d0b408ea 100644 --- a/scripts/sync_env_docs.py +++ b/scripts/sync_env_docs.py @@ -189,10 +189,9 @@ def rewrite(match): else: while list_content_indents and indent_width < list_content_indents[-1]: list_content_indents.pop() - is_list_continuation = ( + is_list_continuation = bool( list_content_indents - and indent_width < list_content_indents[-1] + 4 - ) + ) and indent_width < list_content_indents[-1] + 4 if line.startswith((" ", "\t")) and not is_list_continuation: lines.append(line) else: diff --git a/tests/scripts/test_sync_env_docs.py b/tests/scripts/test_sync_env_docs.py index 1b1af77d04..b68cf89b6b 100644 --- a/tests/scripts/test_sync_env_docs.py +++ b/tests/scripts/test_sync_env_docs.py @@ -121,9 +121,7 @@ def test_run_fix_handles_malformed_url_without_truncating_stub(repo): sync_env_docs.run_fix([], [], [("example_env", "example")]) assert stub.read_text() == ( - "\n" - "# Example\n\n" - "[bad](http://[)\n" + "\n# Example\n\n[bad](http://[)\n" ) From 8cc6a9cda8f95dc713e0cb58d631af0f8f37272f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 21 Sep 2026 13:18:09 +0000 Subject: [PATCH 5/8] style: apply final docs sync formatting Co-authored-by: burtenshaw --- scripts/sync_env_docs.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/scripts/sync_env_docs.py b/scripts/sync_env_docs.py index 99d0b408ea..eb20ec6bdd 100644 --- a/scripts/sync_env_docs.py +++ b/scripts/sync_env_docs.py @@ -189,9 +189,10 @@ def rewrite(match): else: while list_content_indents and indent_width < list_content_indents[-1]: list_content_indents.pop() - is_list_continuation = bool( - list_content_indents - ) and indent_width < list_content_indents[-1] + 4 + is_list_continuation = ( + bool(list_content_indents) + and indent_width < list_content_indents[-1] + 4 + ) if line.startswith((" ", "\t")) and not is_list_continuation: lines.append(line) else: From d6216bb5f6f1c044958e1c42f085548ab8fdc2a5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 21 Sep 2026 13:22:34 +0000 Subject: [PATCH 6/8] fix: address follow-up docs review feedback Co-authored-by: burtenshaw --- docs/source/environments/carla.md | 4 +-- docs/source/environments/git.md | 5 ++-- envs/carla_env/README.md | 4 +-- envs/git_env/README.md | 5 ++-- examples/local_git_env.py | 4 ++- scripts/sync_env_docs.py | 43 +++++++++++++++++++++++++---- tests/scripts/test_sync_env_docs.py | 5 ++++ 7 files changed, 55 insertions(+), 15 deletions(-) diff --git a/docs/source/environments/carla.md b/docs/source/environments/carla.md index 2095e69245..494655a9c2 100644 --- a/docs/source/environments/carla.md +++ b/docs/source/environments/carla.md @@ -171,8 +171,8 @@ Available keys: `equal-1v1`, `saves-3v0`, `deadzone-3v1`, `classic-3v1`, `classi **[maze_navigation.py](https://github.com/huggingface/OpenEnv/blob/main/examples/carla_env/maze_navigation.py)** — LLM navigation with rolling action history. ```bash -uv run python maze_navigation.py --model qwen3-235b --scenario maze-1 -uv run python maze_navigation.py --model gpt-5.2 --scenario maze-1 --save-images +uv run python maze_navigation.py --model qwen3-235b +uv run python maze_navigation.py --model gpt-5.2 --save-images ``` ### Free-Roam Navigation diff --git a/docs/source/environments/git.md b/docs/source/environments/git.md index df4018b232..f70edfd83a 100644 --- a/docs/source/environments/git.md +++ b/docs/source/environments/git.md @@ -32,7 +32,7 @@ The Git Environment connects to a **shared external Gitea service** for optimal from envs.git_env import GitAction, GitEnv # Create environment from Docker image -git_env = GitEnv.from_docker_image("git-env:latest") +git_env = GitEnv.from_docker_image("git-env:latest").sync() # Reset environment result = git_env.reset() @@ -178,7 +178,8 @@ docker run \ Then in your training code, environments automatically reset to the configured state. -See [`examples/local_git_env.py`](https://github.com/huggingface/OpenEnv/blob/main/examples/local_git_env.py) for complete working example. +See [`examples/local_git_env.py`](https://github.com/huggingface/OpenEnv/blob/main/examples/local_git_env.py) for a complete +synchronous example that resolves the Docker bootstrap handle with `.sync()`. ## Project Structure diff --git a/envs/carla_env/README.md b/envs/carla_env/README.md index f322cdde65..177a5244e1 100644 --- a/envs/carla_env/README.md +++ b/envs/carla_env/README.md @@ -187,8 +187,8 @@ Available keys: `equal-1v1`, `saves-3v0`, `deadzone-3v1`, `classic-3v1`, `classi **[maze_navigation.py](../../examples/carla_env/maze_navigation.py)** — LLM navigation with rolling action history. ```bash -uv run python maze_navigation.py --model qwen3-235b --scenario maze-1 -uv run python maze_navigation.py --model gpt-5.2 --scenario maze-1 --save-images +uv run python maze_navigation.py --model qwen3-235b +uv run python maze_navigation.py --model gpt-5.2 --save-images ``` ### Free-Roam Navigation diff --git a/envs/git_env/README.md b/envs/git_env/README.md index 4047d039ed..f7553747cb 100644 --- a/envs/git_env/README.md +++ b/envs/git_env/README.md @@ -31,7 +31,7 @@ The Git Environment connects to a **shared external Gitea service** for optimal from envs.git_env import GitAction, GitEnv # Create environment from Docker image -git_env = GitEnv.from_docker_image("git-env:latest") +git_env = GitEnv.from_docker_image("git-env:latest").sync() # Reset environment result = git_env.reset() @@ -177,7 +177,8 @@ docker run \ Then in your training code, environments automatically reset to the configured state. -See [`examples/local_git_env.py`](../../examples/local_git_env.py) for complete working example. +See [`examples/local_git_env.py`](../../examples/local_git_env.py) for a complete +synchronous example that resolves the Docker bootstrap handle with `.sync()`. ## Project Structure diff --git a/examples/local_git_env.py b/examples/local_git_env.py index 34f930cda8..2dbafa4466 100644 --- a/examples/local_git_env.py +++ b/examples/local_git_env.py @@ -50,7 +50,9 @@ def main(): print() # Create client using from_docker_image factory method - client = GitEnv.from_docker_image("git-env:latest", env_vars=env_vars) + client = GitEnv.from_docker_image( + "git-env:latest", env_vars=env_vars + ).sync() print("āœ“ Client created and container started!\n") diff --git a/scripts/sync_env_docs.py b/scripts/sync_env_docs.py index eb20ec6bdd..98fd33a788 100644 --- a/scripts/sync_env_docs.py +++ b/scripts/sync_env_docs.py @@ -117,10 +117,34 @@ def _rewrite_relative_links(text, env_dir): r"|(?P(?:href|src)=[\"'])(?P[^\"'\n]+)" ) + def is_nested_image_destination(match): + """Return whether a generic link match belongs to an unsupported image.""" + prefix = match.string[: match.start()] + image_start = prefix.rfind("![") + if image_start < 0: + return False + + depth = 1 + escaped = False + for character in prefix[image_start + 2 :]: + if escaped: + escaped = False + elif character == "\\": + escaped = True + elif character == "[": + depth += 1 + elif character == "]": + depth -= 1 + if depth == 0: + return False + return depth == 1 + def rewrite(match): if match.group("code"): return match.group(0) kind = next(k for k in ("image", "link", "attribute") if match.group(k)) + if kind == "link" and is_nested_image_destination(match): + return match.group(0) original = match.group(f"{kind}_url") url = original.strip("<>") try: @@ -129,7 +153,10 @@ def rewrite(match): return match.group(0) if parsed.scheme or parsed.netloc or not parsed.path or url.startswith("/"): return match.group(0) - target = (source_dir / unquote(parsed.path)).resolve() + try: + target = (source_dir / unquote(parsed.path)).resolve() + except (OSError, ValueError): + return match.group(0) try: relative = target.relative_to(root) except ValueError: @@ -153,18 +180,22 @@ def rewrite(match): fence = None list_content_indents = [] for line in text.splitlines(keepends=True): - marker = re.match(r"^ {0,3}(`{3,}|~{3,})(.*)$", line) + marker = re.match( + r"^(?:[ ]{0,3}>[ \t]?)*[ ]{0,3}" + r"(?P`{3,}|~{3,})(?P.*)$", + line, + ) if fence: if ( marker - and marker[1][0] == fence[0] - and len(marker[1]) >= len(fence) - and not marker[2].strip() + and marker.group("fence")[0] == fence[0] + and len(marker.group("fence")) >= len(fence) + and not marker.group("rest").strip() ): fence = None lines.append(line) elif marker: - fence = marker[1] + fence = marker.group("fence") lines.append(line) else: indentation = re.match(r"^[ \t]*", line)[0] diff --git a/tests/scripts/test_sync_env_docs.py b/tests/scripts/test_sync_env_docs.py index b68cf89b6b..2a082ac701 100644 --- a/tests/scripts/test_sync_env_docs.py +++ b/tests/scripts/test_sync_env_docs.py @@ -75,10 +75,15 @@ def test_leaves_code_anchors_external_missing_and_outside_paths_unchanged(repo): ~~~markdown ~~~ +> ```markdown +> [client](client.py) +> ``` +![plot [v2]](image.png) [anchor](#usage) [external](https://example.com/client.py) [protocol-relative](//example.com/client.py) [absolute](/client.py) +[null](%00) [missing](missing.py) [outside](../../../outside.txt) [symlink](outside-link) From 7d5ce7dfb3fef0d229f27fcbec3036870e55bf6f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 21 Sep 2026 13:24:45 +0000 Subject: [PATCH 7/8] fix: preserve fenced code nested in lists Co-authored-by: burtenshaw --- scripts/sync_env_docs.py | 10 +++++++++- tests/scripts/test_sync_env_docs.py | 4 ++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/scripts/sync_env_docs.py b/scripts/sync_env_docs.py index 98fd33a788..087c6bacdd 100644 --- a/scripts/sync_env_docs.py +++ b/scripts/sync_env_docs.py @@ -181,10 +181,18 @@ def rewrite(match): list_content_indents = [] for line in text.splitlines(keepends=True): marker = re.match( - r"^(?:[ ]{0,3}>[ \t]?)*[ ]{0,3}" + r"^(?:[ ]{0,3}>[ \t]?)*(?P[ \t]*)" r"(?P`{3,}|~{3,})(?P.*)$", line, ) + if marker: + fence_indent = len(marker.group("indent").expandtabs(4)) + in_list = any( + content_indent <= fence_indent <= content_indent + 3 + for content_indent in list_content_indents + ) + if fence_indent > 3 and not in_list: + marker = None if fence: if ( marker diff --git a/tests/scripts/test_sync_env_docs.py b/tests/scripts/test_sync_env_docs.py index 2a082ac701..6f45af4b10 100644 --- a/tests/scripts/test_sync_env_docs.py +++ b/tests/scripts/test_sync_env_docs.py @@ -78,6 +78,10 @@ def test_leaves_code_anchors_external_missing_and_outside_paths_unchanged(repo): > ```markdown > [client](client.py) > ``` +- Example: + ```markdown + [client](client.py) + ``` ![plot [v2]](image.png) [anchor](#usage) [external](https://example.com/client.py) From 753b3fd70507c4f7bf6265a906b5949e6eed9346 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 21 Sep 2026 13:24:44 +0000 Subject: [PATCH 8/8] fix: preserve links in list-nested fences --- tests/scripts/test_sync_env_docs.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/scripts/test_sync_env_docs.py b/tests/scripts/test_sync_env_docs.py index 6f45af4b10..3117fbf172 100644 --- a/tests/scripts/test_sync_env_docs.py +++ b/tests/scripts/test_sync_env_docs.py @@ -81,6 +81,8 @@ def test_leaves_code_anchors_external_missing_and_outside_paths_unchanged(repo): - Example: ```markdown [client](client.py) + client + ``` ![plot [v2]](image.png) [anchor](#usage)