Skip to content

Commit 976c412

Browse files
authored
[feature] Import external envs (ors, verifiers) (#726)
* feat: import external environments * feat: add import integration demo * fix: address import wrapper review feedback * fix: satisfy ci formatting and tests * fix: address import review comments * fix: remove import demo example * fix: extract importer source templates * fix: satisfy lint formatting * fix: add imported environment example * fix: add qed math docs to toctree
1 parent d9ed15d commit 976c412

29 files changed

Lines changed: 3304 additions & 20 deletions

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,7 @@ See example scripts in `examples/` directory.
268268
The OpenEnv CLI provides commands to manage environments:
269269

270270
- **`openenv init <env_name>`** - Initialize a new environment from template
271+
- **`openenv import <source> --name <env_name> --output-dir <dir>`** - Wrap a supported third-party source environment, including ORS/OpenReward and Verifiers, as OpenEnv
271272
- **`openenv push [--repo-id <repo>] [--private]`** - Deploy environment to Hugging Face Spaces
272273
- **`openenv serve`** - Serve an environment locally with optional auto-reload
273274
- **`openenv build`** - Build the Docker image for an environment
@@ -280,6 +281,9 @@ The OpenEnv CLI provides commands to manage environments:
280281
# Create a new environment
281282
openenv init my_game_env
282283

284+
# Or import an ORS/OpenReward or Verifiers source environment
285+
openenv import path/to/source --name my_game_env --output-dir .
286+
283287
# Deploy to Hugging Face (will prompt for login if needed)
284288
cd my_game_env
285289
openenv push

docs/source/_toctree.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,8 @@
115115
title: Maze
116116
- local: environments/openapp
117117
title: OpenApp
118+
- local: environments/qed_math
119+
title: QED Math
118120
- local: environments/reasoning_gym
119121
title: Reasoning Gym
120122
- local: environments/tbench2

docs/source/getting_started/environment-builder.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ Already familiar with OpenEnv? Here's the 8-step process at a glance:
2727
| Command | Description |
2828
|---------|-------------|
2929
| `openenv init NAME` | Scaffold new environment |
30+
| `openenv import SOURCE --name NAME --output-dir DIR` | Wrap a supported third-party environment source tree |
3031
| `uv run --project . server` | Start local dev server |
3132
| `openenv build` | Build Docker image |
3233
| `openenv validate --verbose` | Validate environment structure |
@@ -96,6 +97,14 @@ my_env/
9697

9798
Python classes are generated for the action, observation, environment, and client. For example, you will find `MyEnvironment`, `MyAction`, `MyObservation`, and `MyEnv` (client) in the `my_env` directory based on the name you provided. The environment uses the core `State` class from `openenv.core.env_server.types`.
9899

100+
If you already have an ORS/OpenReward or Prime Intellect Verifiers environment
101+
source tree, use `openenv import SOURCE --name my_env --output-dir /Users/you/envs`
102+
instead. The importer detects the source type from the code, vendors the source
103+
under the generated package, and emits an OpenEnv wrapper with task/split and
104+
MCP-style tool actions. Non-secret data files in the source tree are included as
105+
package data, and portable dependencies declared in source `pyproject.toml` or
106+
`requirements.txt` files are added to the generated environment.
107+
99108
### 2. Define Models
100109

101110
Edit `models.py` to describe your action and observation using Pydantic:

docs/source/reference/cli.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,34 @@ The `openenv` CLI provides a set of commands for building, validating, and pushi
66

77
[[autodoc]] openenv.cli.commands.init.init
88

9+
## `openenv import`
10+
11+
Import a supported third-party source environment into a generated OpenEnv
12+
wrapper package. The command detects the source format from the directory
13+
contents, so ORS/OpenReward and Prime Intellect Verifiers sources do not
14+
require `--type` in the common case.
15+
16+
The generated wrapper vendors the source tree into the package and includes
17+
vendored files as package data, so non-secret fixture/data files are available to
18+
the environment server at runtime. The importer carries portable dependencies
19+
from source `pyproject.toml` and `requirements.txt` files into the generated
20+
environment, skips VCS/cache/build directories and common secret file patterns
21+
such as `.env`, `secrets.yaml`, and private key files, and excludes compiled
22+
binary artifacts; review the generated `vendor/` directory before publishing a
23+
wrapper.
24+
25+
```bash
26+
openenv import path/to/source --name my_env --output-dir ./envs
27+
openenv import path/to/source --name my_env --output-dir ./envs --env-class MyEnv
28+
```
29+
30+
```{eval-rst}
31+
.. automodule:: openenv.cli.commands.import_env
32+
:members:
33+
:undoc-members:
34+
:show-inheritance:
35+
```
36+
937
## `openenv build`
1038

1139
[[autodoc]] openenv.cli.commands.build.build
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# Imported Environment Usage
2+
3+
This example imports the tiny ORS/OpenReward-style source in `source/` and then
4+
uses the generated OpenEnv wrapper.
5+
6+
```bash
7+
uv run python examples/imported_environment/use_imported_environment.py
8+
```
9+
10+
To keep the generated wrapper for inspection:
11+
12+
```bash
13+
uv run python examples/imported_environment/use_imported_environment.py --output-dir /tmp/openenv-import-demo
14+
```
15+
16+
The script runs `openenv import`, imports the generated
17+
`ImportedOrsDemoEnvironment`, calls `reset()`, lists the generated MCP-style
18+
tools, and submits an answer with `CallToolAction`.
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
class Model:
2+
def __init__(self, **kwargs):
3+
self.__dict__.update(kwargs)
4+
5+
def model_dump(self):
6+
return dict(self.__dict__)
7+
8+
9+
class Split(Model):
10+
pass
11+
12+
13+
class ToolSpec(Model):
14+
pass
15+
16+
17+
class ListToolsOutput(Model):
18+
pass
19+
20+
21+
class TextBlock(Model):
22+
def __init__(self, text, detail=None, type="text"):
23+
super().__init__(text=text, detail=detail, type=type)
24+
25+
26+
class ToolOutput(Model):
27+
pass
28+
29+
30+
class RunToolSuccess:
31+
ok = True
32+
33+
def __init__(self, output):
34+
self.output = output
35+
36+
37+
class RunToolOutput:
38+
def __init__(self, output):
39+
self.root = RunToolSuccess(output)
40+
41+
42+
class Environment:
43+
def __init__(self, task_spec=None, secrets=None):
44+
self.task_spec = task_spec or {}
45+
self.secrets = secrets or {}
46+
47+
def setup(self):
48+
pass
49+
50+
def teardown(self):
51+
pass
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
from ors import (
2+
Environment,
3+
ListToolsOutput,
4+
RunToolOutput,
5+
Split,
6+
TextBlock,
7+
ToolOutput,
8+
ToolSpec,
9+
)
10+
11+
12+
class ToyMathEnvironment(Environment):
13+
@classmethod
14+
def list_splits(cls):
15+
return [Split(name="train", type="train")]
16+
17+
@classmethod
18+
def list_tasks(cls, split):
19+
return [
20+
{
21+
"id": "addition-1",
22+
"question": "What is 2 + 2?",
23+
"answer": "4",
24+
}
25+
]
26+
27+
@classmethod
28+
def num_tasks(cls, split):
29+
return len(cls.list_tasks(split))
30+
31+
@classmethod
32+
def get_task(cls, split, index):
33+
return cls.list_tasks(split)[index]
34+
35+
@classmethod
36+
def get_task_range(cls, split, start=None, stop=None):
37+
return cls.list_tasks(split)[slice(start, stop)]
38+
39+
@classmethod
40+
def list_tools(cls):
41+
return ListToolsOutput(
42+
tools=[
43+
ToolSpec(
44+
name="answer",
45+
description="Submit an answer for the math question",
46+
input_schema={
47+
"type": "object",
48+
"properties": {"value": {"type": "string"}},
49+
"required": ["value"],
50+
},
51+
)
52+
]
53+
)
54+
55+
def list_task_tools(self):
56+
return ListToolsOutput(tools=[])
57+
58+
def get_prompt(self):
59+
return [TextBlock(text=self.task_spec["question"])]
60+
61+
def _call_tool(self, name, input):
62+
guess = str(input.get("value", "")).strip()
63+
expected = self.task_spec["answer"]
64+
correct = guess == expected
65+
return RunToolOutput(
66+
ToolOutput(
67+
blocks=[TextBlock(text="correct" if correct else "incorrect")],
68+
metadata={"expected": expected, "received": guess},
69+
reward=1.0 if correct else 0.0,
70+
finished=True,
71+
)
72+
)
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
#!/usr/bin/env python3
2+
"""Import and use a tiny ORS/OpenReward-style environment."""
3+
4+
from __future__ import annotations
5+
6+
import argparse
7+
import shutil
8+
import subprocess
9+
import sys
10+
from pathlib import Path
11+
from tempfile import TemporaryDirectory
12+
from typing import Any
13+
14+
from openenv.core.env_server.mcp_types import CallToolAction, ListToolsAction
15+
16+
17+
ENV_NAME = "imported_ors_demo"
18+
EXAMPLE_DIR = Path(__file__).resolve().parent
19+
20+
21+
def import_environment(source_dir: Path, output_dir: Path) -> Path:
22+
"""Run the OpenEnv importer and return the generated package directory."""
23+
output_dir.mkdir(parents=True, exist_ok=True)
24+
generated_dir = output_dir / ENV_NAME
25+
if generated_dir.exists():
26+
shutil.rmtree(generated_dir)
27+
28+
subprocess.run(
29+
[
30+
sys.executable,
31+
"-m",
32+
"openenv.cli",
33+
"import",
34+
str(source_dir),
35+
"--name",
36+
ENV_NAME,
37+
"--output-dir",
38+
str(output_dir),
39+
],
40+
check=True,
41+
)
42+
return generated_dir
43+
44+
45+
def _clear_demo_modules() -> None:
46+
for module_name in list(sys.modules):
47+
if module_name == ENV_NAME or module_name.startswith(f"{ENV_NAME}."):
48+
sys.modules.pop(module_name, None)
49+
if module_name == "ors" or module_name.startswith("ors."):
50+
sys.modules.pop(module_name, None)
51+
52+
53+
def run_imported_environment(output_dir: Path) -> dict[str, Any]:
54+
"""Use the generated OpenEnv wrapper directly."""
55+
_clear_demo_modules()
56+
sys.path.insert(0, str(output_dir))
57+
try:
58+
from imported_ors_demo.server.imported_ors_demo_environment import ( # type: ignore
59+
ImportedOrsDemoEnvironment,
60+
)
61+
62+
env = ImportedOrsDemoEnvironment()
63+
reset_observation = env.reset(split="train", index=0)
64+
tools_observation = env.step(ListToolsAction())
65+
answer_observation = env.step(
66+
CallToolAction(tool_name="answer", arguments={"value": "4"})
67+
)
68+
69+
return {
70+
"prompt": reset_observation.metadata["prompt"][0]["text"],
71+
"tools": [tool.name for tool in tools_observation.tools],
72+
"result": answer_observation.result["blocks"][0]["text"],
73+
"reward": answer_observation.reward,
74+
"done": answer_observation.done,
75+
}
76+
finally:
77+
try:
78+
sys.path.remove(str(output_dir))
79+
except ValueError:
80+
pass
81+
_clear_demo_modules()
82+
83+
84+
def _print_summary(generated_dir: Path, summary: dict[str, Any]) -> None:
85+
print(f"Generated environment: {generated_dir}")
86+
print(f"Prompt: {summary['prompt']}")
87+
print(f"Tools: {', '.join(summary['tools'])}")
88+
print(f"Tool result: {summary['result']}")
89+
print(f"Reward: {summary['reward']}")
90+
print(f"Done: {summary['done']}")
91+
92+
93+
def main(argv: list[str] | None = None) -> None:
94+
parser = argparse.ArgumentParser()
95+
parser.add_argument(
96+
"--output-dir",
97+
type=Path,
98+
help="Directory for the generated OpenEnv package. Defaults to a temp dir.",
99+
)
100+
args = parser.parse_args(argv)
101+
102+
source_dir = EXAMPLE_DIR / "source"
103+
if args.output_dir is not None:
104+
output_dir = args.output_dir.resolve()
105+
generated_dir = import_environment(source_dir, output_dir)
106+
summary = run_imported_environment(output_dir)
107+
_print_summary(generated_dir, summary)
108+
return
109+
110+
with TemporaryDirectory() as tmp:
111+
output_dir = Path(tmp)
112+
generated_dir = import_environment(source_dir, output_dir)
113+
summary = run_imported_environment(output_dir)
114+
_print_summary(generated_dir, summary)
115+
116+
117+
if __name__ == "__main__":
118+
main()

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ include-package-data = true
6565

6666
[tool.setuptools.package-data]
6767
"openenv.cli" = ["templates/**/*"]
68+
"openenv.cli.importers" = ["templates/**/*"]
6869

6970
[tool.setuptools.exclude-package-data]
7071
"*" = ["*.pyc", "*.pyo", "__pycache__/*"]

src/openenv/cli/__main__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
build,
1515
collect,
1616
fork,
17+
import_env,
1718
init,
1819
push,
1920
serve,
@@ -30,6 +31,9 @@
3031

3132
# Register commands
3233
app.command(name="init", help="Initialize a new OpenEnv environment")(init.init)
34+
app.command(name="import", help="Import a third-party environment into OpenEnv")(
35+
import_env.import_env
36+
)
3337
app.command(name="build", help="Build Docker images for OpenEnv environments")(
3438
build.build
3539
)

0 commit comments

Comments
 (0)