Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ openenv push my_env # deploy it to Hugging Face Spaces

For local development, run `openenv serve my_env` after installing its dependencies. This runs on the host and binds to `127.0.0.1`; use Docker or Spaces for isolated training and deployment.

See [Your First Environment](https://huggingface.co/docs/openenv/guides/first-environment) and [Deploying an Environment](https://huggingface.co/docs/openenv/getting_started/environment-builder). `openenv import` wraps an existing environment from ORS/OpenReward or Verifiers.
See [Your First Environment](https://huggingface.co/docs/openenv/guides/first-environment) and [Deploying an Environment](https://huggingface.co/docs/openenv/getting_started/environment-builder). `openenv import` wraps an existing environment from ORS/OpenReward or Verifiers, or an AgentEnv bundle.

## Environments

Expand Down
2 changes: 1 addition & 1 deletion docs/source/guides/first-environment.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ my_env/
Class names come from the environment name, with a trailing `_env` dropped: `my_env` gives `MyAction`, `MyObservation`, `MyEnvironment` and `MyEnv`. The steps below go through each file. Edit them to replace the echo logic with yours. If you're working inside the OpenEnv repo, move the folder under `envs/`.

> [!TIP]
> If you already have an ORS/OpenReward or Prime Intellect Verifiers environment, run `openenv import SOURCE --name my_env --output-dir DIR` instead. It detects the source type, vendors the source under the generated package and emits an OpenEnv wrapper with task/split and MCP-style tool actions. Non-secret data files and portable dependencies from the source tree are carried over.
> If you already have an ORS/OpenReward or Prime Intellect Verifiers environment, or an AgentEnv bundle, run `openenv import SOURCE --name my_env --output-dir DIR` instead. It detects the source type and vendors non-secret data files and portable dependencies into the generated package. ORS/OpenReward and Verifiers use generated wrappers; AgentEnv delegates serving to `agent_env.openenv.build_app` and vendors only the selected bundle. AgentEnv requires local Docker, and the generated Dockerfile alone cannot provide a working deployment. Its generated client is an `MCPToolClient`. See the generated README and [CLI reference](../reference/cli#openenv-import) for prerequisites.

## 2. Define the models

Expand Down
22 changes: 20 additions & 2 deletions docs/source/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ The `openenv` CLI provides a set of commands for building, validating, and pushi

Import a supported third-party source environment into a generated OpenEnv
wrapper package. The command detects the source format from the directory
contents, so ORS/OpenReward and Prime Intellect Verifiers sources do not
require `--type` in the common case.
contents, so ORS/OpenReward, Prime Intellect Verifiers and AgentEnv sources do
not require `--type` in the common case.

The generated wrapper vendors the source tree into the package and includes
vendored files as package data, so non-secret fixture/data files are available to
Expand All @@ -27,6 +27,24 @@ 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
```

An [AgentEnv](https://github.com/scaleapi/agentenv-framework) bundle is a folder
whose `tasks/` holds task files. When a source holds several bundles, pick one
with `--env-class` and its folder name; only that folder is vendored. The
generated server runs it with `build_app` from `agentenv-framework`, which by
default deploys each episode using local Docker. The server needs a Docker CLI
and a running daemon accessible for image builds and episode containers. The
generated `server/Dockerfile` does not supply that runtime, so `openenv build`
and `openenv push` alone do not produce a working deployment. The generated
README describes local startup and these prerequisites. The generated client is
an `MCPToolClient`, since the served environment takes MCP actions. See
[AgentEnv's OpenEnv page](https://www.agentenvframework.com/docs/openenv) for
what it serves and what it needs.

```bash
openenv import path/to/bundle --name my_env --output-dir ./envs
openenv import path/to/plugin --name my_env --output-dir ./envs --env-class my-bundle
```

[[autodoc]] openenv.cli.commands.import_env.import_env

## `openenv build`
Expand Down
4 changes: 3 additions & 1 deletion src/openenv/cli/importers/__init__.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
"""Deterministic source importers for OpenEnv environments."""

from .agentenv import AgentEnvImporter
from .base import DetectedEnvironment, EnvironmentImporter, ImporterRegistry
from .ors import ORSImporter
from .verifiers import VerifiersImporter

DEFAULT_IMPORTERS = [ORSImporter(), VerifiersImporter()]
DEFAULT_IMPORTERS = [ORSImporter(), VerifiersImporter(), AgentEnvImporter()]

__all__ = [
"AgentEnvImporter",
"DEFAULT_IMPORTERS",
"DetectedEnvironment",
"EnvironmentImporter",
Expand Down
163 changes: 163 additions & 0 deletions src/openenv/cli/importers/agentenv.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
"""AgentEnv bundle source importer."""

from __future__ import annotations

import json
from pathlib import Path

from packaging.requirements import InvalidRequirement, Requirement
from packaging.specifiers import SpecifierSet
from packaging.utils import canonicalize_name

from .._cli_utils import console
from .base import (
append_dependency_files,
collect_source_dependencies,
DetectedEnvironment,
iter_directories,
prepare_import_package,
render_importer_template,
write_text,
)


_FRAMEWORK = "agentenv-framework"
_FRAMEWORK_EXTRA = "openenv"
_FRAMEWORK_VERSION = "0.9.1366"
Comment thread
burtenshaw marked this conversation as resolved.
_FRAMEWORK_FLOOR = f">={_FRAMEWORK_VERSION}"
_ADAPTER_SOURCE = (
"https://github.com/scaleapi/agentenv-framework/blob/"
f"v{_FRAMEWORK_VERSION}/src/agent_env/openenv"
)
_REQUIRES_PYTHON = ">=3.11.4"


def _is_task_file(path: Path) -> bool:
try:
steps = json.loads(path.read_text(encoding="utf-8"))
except (OSError, UnicodeDecodeError, ValueError):
return False
return (
isinstance(steps, list)
and bool(steps)
and all(
isinstance(step, dict)
and isinstance(step.get("id"), str)
and isinstance(step.get("type"), str)
for step in steps
)
)


def detect_agentenv_bundles(source: Path) -> list[DetectedEnvironment]:
"""Detect AgentEnv bundles, folders whose tasks/ holds task files, without importing source."""
source = source.resolve()
matches: list[DetectedEnvironment] = []

for tasks_dir in iter_directories(source, "tasks"):
if not any(_is_task_file(path) for path in sorted(tasks_dir.glob("*.json"))):
continue
bundle = tasks_dir.parent
matches.append(
DetectedEnvironment(
source_type="agentenv",
class_name=bundle.name,
module_path=""
if bundle == source
else bundle.relative_to(source).as_posix(),
file_path=bundle,
)
)

return matches


def _with_framework_floor(dependencies: list[str]) -> list[str]:
"""The bundle's dependencies, with agentenv-framework's openenv extra at least the version that serves bundles."""
for index, dependency in enumerate(dependencies):
try:
requirement = Requirement(dependency)
except InvalidRequirement:
continue
if canonicalize_name(requirement.name) == _FRAMEWORK:
if requirement.url is not None:
raise ValueError(
f"Cannot verify {_FRAMEWORK}{_FRAMEWORK_FLOOR} from a direct "
"reference. In the bundle's pyproject.toml or requirements.txt, "
"replace the framework direct reference with a version constraint "
f"such as {_FRAMEWORK}[{_FRAMEWORK_EXTRA}]{_FRAMEWORK_FLOOR}, "
"preserving any additional extras and environment marker."
)
requirement.extras.add(_FRAMEWORK_EXTRA)
requirement.specifier &= SpecifierSet(_FRAMEWORK_FLOOR)
dependencies[index] = str(requirement)
return dependencies
return [*dependencies, f"{_FRAMEWORK}[{_FRAMEWORK_EXTRA}]{_FRAMEWORK_FLOOR}"]


class AgentEnvImporter:
"""Importer for AgentEnv bundles, served by agentenv-framework's OpenEnv server."""

source_type = "agentenv"

def detect(self, source: Path) -> list[DetectedEnvironment]:
return detect_agentenv_bundles(source)

def generate(
self,
*,
source: Path,
destination: Path,
env_name: str,
detected: DetectedEnvironment,
) -> None:
bundle = detected.file_path
dependencies = _with_framework_floor(collect_source_dependencies(bundle))
package = prepare_import_package(
bundle, destination, env_name, vendor_dir=bundle.name
)
client = f"{package.class_name_prefix}Env"
(destination / "server" / f"{env_name}_environment.py").unlink()
(destination / "models.py").unlink()
for name, template in (
("client.py", "agentenv_client.py.tpl"),
("__init__.py", "agentenv_init.py.tpl"),
):
write_text(
destination / name,
render_importer_template(template, env_name=env_name, client=client),
)
write_text(
destination / "server" / "__init__.py",
f'"""{env_name} environment server: an AgentEnv bundle."""\n',
)
write_text(
destination / "server" / "app.py",
render_importer_template(
"agentenv_app.py.tpl", bundle=json.dumps(bundle.name)
),
)
write_text(
destination / "README.md",
render_importer_template(
"agentenv_README.md.tpl",
env_name=env_name,
bundle=bundle.name,
framework=f"{_FRAMEWORK}[{_FRAMEWORK_EXTRA}]{_FRAMEWORK_FLOOR}",
adapter=_ADAPTER_SOURCE,
client=client,
),
)
console.print(
"[yellow]Warning:[/yellow] AgentEnv requires local Docker: a Docker CLI "
"and a running daemon accessible to the server for image builds and episode "
"containers. The generated server/Dockerfile does not provide this "
"runtime; openenv build / openenv push alone do not produce a working "
"deployment. See README.md for prerequisites."
)
append_dependency_files(
destination,
env_name,
dependencies,
requires_python=_REQUIRES_PYTHON,
)
15 changes: 13 additions & 2 deletions src/openenv/cli/importers/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,14 @@ def iter_python_files(source: Path) -> list[Path]:
]


def iter_directories(source: Path, name: str) -> list[Path]:
return [
path
for path in sorted(source.rglob(name))
if path.is_dir() and not _is_excluded(path.relative_to(source))
]


def module_path(source: Path, file_path: Path) -> str:
rel = file_path.relative_to(source).with_suffix("")
parts = list(rel.parts)
Expand Down Expand Up @@ -196,6 +204,7 @@ def append_dependency_files(
env_dir: Path,
env_name: str,
dependencies: list[str],
requires_python: str | None = None,
) -> None:
requirements = env_dir / "server" / "requirements.txt"
if requirements.exists():
Expand Down Expand Up @@ -231,6 +240,8 @@ def append_dependency_files(
project_dependencies.append(dependency)
existing.add(_dependency_name(dependency))
project["dependencies"] = project_dependencies
if requires_python:
project["requires-python"] = requires_python

tool = data.setdefault("tool", {})
setuptools = tool.setdefault("setuptools", {})
Expand Down Expand Up @@ -266,7 +277,7 @@ class ImportPackageContext:


def prepare_import_package(
source: Path, destination: Path, env_name: str
source: Path, destination: Path, env_name: str, vendor_dir: str | None = None
) -> ImportPackageContext:
from openenv.cli.commands.init import (
_copy_template_directory,
Expand All @@ -282,7 +293,7 @@ def prepare_import_package(
env_name,
)

vendor_dir = safe_vendor_dir_name(source)
vendor_dir = vendor_dir or safe_vendor_dir_name(source)
vendor_path = destination / "vendor" / vendor_dir
copy_source_tree(source, vendor_path)
ensure_vendor_package(vendor_path)
Expand Down
82 changes: 82 additions & 0 deletions src/openenv/cli/importers/templates/agentenv_README.md.tpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
# ${env_name}: AgentEnv bundle

This package serves the vendored `${bundle}` bundle through
`agent_env.openenv.build_app`. The bundle keeps its original folder name at
`vendor/${bundle}`; the generated package name is `${env_name}`.

## Prerequisites

- Python >=3.11.4 and `${framework}` (declared in
`pyproject.toml` and `server/requirements.txt`).
- Local Docker: a Docker CLI and a running daemon accessible to the server.
The delegated server builds the bundle's environment images at startup and
deploys episode containers using local Docker by default.
- A self-contained bundle: tasks, environment definitions, verifiers, data and
dependencies must be included in the bundle or provided by agentenv-framework.
Only the selected bundle is vendored; external plugin registrations and files
elsewhere in the source repository are not included.

## Run locally

From this generated package directory, after setting up Docker:

```bash
uv sync
MAX_CONCURRENT_ENVS=8 uv run server --host 127.0.0.1 --port 8000
```

`MAX_CONCURRENT_ENVS` (default 8) caps the concurrent sessions. Each session
plays its own episode on its own deployment.

The `server` entry point runs `server/app.py`, which passes
`vendor/${bundle}` to `build_app`. Task selection, splits, tools and rewards
are defined by the bundle and agentenv-framework. The served environment is
`agent_env.openenv`'s `AgentEnvEnvironment`; read it, and `build_app`, at the
minimum supported version: [environment.py](${adapter}/environment.py) and
[app.py](${adapter}/app.py). Follow
[AgentEnv's OpenEnv documentation](https://www.agentenvframework.com/docs/openenv)
for its API and client usage.

## Client

`client.py` defines `${client}`, an `MCPToolClient`: the served environment
takes OpenEnv's MCP actions (`ListToolsAction`, `CallToolAction`), which
`__init__.py` also exports.

```python
from ${env_name} import ${client}

async with ${client}(base_url="http://127.0.0.1:8000") as env:
await env.reset(split="all", index=0)
tools = await env.list_tools()
ended = await env.call_tool("end_turn", response="Done.")
print(ended["done"], ended["reward"])
```

The tools are the bundle environment's MCP tools plus `end_turn`, which ends
the turn with its `response`. Its result carries the next turn's prompt, or,
once `done`, the task's reward. A step before `reset` raises.

`list_tools()` discovers fresh tools by default because tools can change after
reset, `end_turn`, or a bundle tool call. `get_tool()` and `has_tool()` also use
fresh discovery. Pass `list_tools(use_cache=True)` explicitly to reuse the last
discovery result.

## Deployment limitations

**Warning:** `server/Dockerfile` does not provide the Docker runtime required
by the delegated server. `openenv build` / `openenv push` alone do not produce
a working deployment, including on Hugging Face Spaces. Deployment requires
separately provisioning the server's image-build and episode-container runtime;
this importer does not configure it. Review AgentEnv's runtime requirements
before choosing a deployment host.

## Generated files

- `server/app.py`: delegated application and local server entry point.
- `vendor/${bundle}`: the selected bundle and its data.
- `pyproject.toml`: package dependencies and the `server` entry point.
- `server/requirements.txt`: server dependencies.
- `openenv.yaml`: generated OpenEnv manifest.
- `server/Dockerfile`: shared image template with the limitation described above.
- `client.py`, `__init__.py`: the client and the MCP action types it sends.
29 changes: 29 additions & 0 deletions src/openenv/cli/importers/templates/agentenv_app.py.tpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
from __future__ import annotations

import os
from pathlib import Path

from agent_env.openenv import build_app


BUNDLE = Path(__file__).resolve().parents[1] / "vendor" / ${bundle}

app = build_app(
BUNDLE, max_concurrent_envs=int(os.getenv("MAX_CONCURRENT_ENVS", "8"))
)


def main():
import argparse

import uvicorn

parser = argparse.ArgumentParser()
parser.add_argument("--host", default="0.0.0.0")
parser.add_argument("--port", type=int, default=8000)
args = parser.parse_args()
uvicorn.run(app, host=args.host, port=args.port)


if __name__ == "__main__":
main()
Loading
Loading