diff --git a/README.md b/README.md index 51180e581..6bf4fcd49 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/source/guides/first-environment.md b/docs/source/guides/first-environment.md index 2adb451da..7954ccbf4 100644 --- a/docs/source/guides/first-environment.md +++ b/docs/source/guides/first-environment.md @@ -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 diff --git a/docs/source/reference/cli.md b/docs/source/reference/cli.md index e9abcdca3..f88ca8596 100644 --- a/docs/source/reference/cli.md +++ b/docs/source/reference/cli.md @@ -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 @@ -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` diff --git a/src/openenv/cli/importers/__init__.py b/src/openenv/cli/importers/__init__.py index 9519278cf..ba216e897 100644 --- a/src/openenv/cli/importers/__init__.py +++ b/src/openenv/cli/importers/__init__.py @@ -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", diff --git a/src/openenv/cli/importers/agentenv.py b/src/openenv/cli/importers/agentenv.py new file mode 100644 index 000000000..05d8d3694 --- /dev/null +++ b/src/openenv/cli/importers/agentenv.py @@ -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" +_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, + ) diff --git a/src/openenv/cli/importers/base.py b/src/openenv/cli/importers/base.py index f106af091..ab2209b05 100644 --- a/src/openenv/cli/importers/base.py +++ b/src/openenv/cli/importers/base.py @@ -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) @@ -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(): @@ -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", {}) @@ -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, @@ -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) diff --git a/src/openenv/cli/importers/templates/agentenv_README.md.tpl b/src/openenv/cli/importers/templates/agentenv_README.md.tpl new file mode 100644 index 000000000..f2ad1c69d --- /dev/null +++ b/src/openenv/cli/importers/templates/agentenv_README.md.tpl @@ -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. diff --git a/src/openenv/cli/importers/templates/agentenv_app.py.tpl b/src/openenv/cli/importers/templates/agentenv_app.py.tpl new file mode 100644 index 000000000..4ea4d0b60 --- /dev/null +++ b/src/openenv/cli/importers/templates/agentenv_app.py.tpl @@ -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() diff --git a/src/openenv/cli/importers/templates/agentenv_client.py.tpl b/src/openenv/cli/importers/templates/agentenv_client.py.tpl new file mode 100644 index 000000000..c98c527b1 --- /dev/null +++ b/src/openenv/cli/importers/templates/agentenv_client.py.tpl @@ -0,0 +1,29 @@ +"""${env_name} client. + +The served AgentEnv bundle takes OpenEnv's MCP actions, so this client is an +``MCPToolClient``. Reset to a task, call the bundle's tools, and end each turn +with ``end_turn``. Its result carries the next turn's prompt, or once ``done`` +the task's reward: + + >>> async with ${client}(base_url="http://localhost: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"]) +""" + +from openenv.core.env_server.mcp_types import Tool +from openenv.core.mcp_client import MCPToolClient + + +class ${client}(MCPToolClient): + """Client for the ${env_name} environment.""" + + async def list_tools(self, use_cache: bool = False) -> list[Tool]: + """Discover the current task and turn's tools. + + Args: + use_cache (`bool`, *optional*, defaults to `False`): + Set to `True` to reuse the last discovery result. + """ + return await super().list_tools(use_cache=use_cache) diff --git a/src/openenv/cli/importers/templates/agentenv_init.py.tpl b/src/openenv/cli/importers/templates/agentenv_init.py.tpl new file mode 100644 index 000000000..860105f5b --- /dev/null +++ b/src/openenv/cli/importers/templates/agentenv_init.py.tpl @@ -0,0 +1,7 @@ +"""${env_name} environment: an AgentEnv bundle served through agentenv-framework.""" + +from openenv.core.env_server.mcp_types import CallToolAction, ListToolsAction + +from .client import ${client} + +__all__ = ["${client}", "CallToolAction", "ListToolsAction"] diff --git a/tests/test_cli/test_agentenv_client.py b/tests/test_cli/test_agentenv_client.py new file mode 100644 index 000000000..bdd3db180 --- /dev/null +++ b/tests/test_cli/test_agentenv_client.py @@ -0,0 +1,138 @@ +# SPDX-License-Identifier: BSD-3-Clause + +"""Generated AgentEnv client discovery over real HTTP and WebSocket transports.""" + +import asyncio +import importlib.util +import socket + +import pytest +import uvicorn +from openenv.cli.importers.agentenv import AgentEnvImporter, detect_agentenv_bundles +from openenv.core.env_server.http_server import create_app +from openenv.core.env_server.interfaces import Environment +from openenv.core.env_server.mcp_types import ( + CallToolAction, + CallToolObservation, + ListToolsAction, + ListToolsObservation, + Tool, +) +from openenv.core.env_server.types import Observation, State + + +class ChangingToolsEpisode(Environment): + """Minimal episode with task-local tools that can also change during a turn.""" + + SUPPORTS_CONCURRENT_SESSIONS = True + + def reset(self, *, index=0, **kwargs): + self.tool = ("echo", "other")[index] + return Observation() + + def step(self, action, **kwargs): + if isinstance(action, ListToolsAction): + return ListToolsObservation( + tools=[ + Tool( + name=name, + description="Episode tool", + input_schema={"type": "object"}, + ) + for name in (self.tool, "end_turn") + ] + ) + assert isinstance(action, CallToolAction) + assert action.tool_name in (self.tool, "end_turn") + self.tool = "next" + return CallToolObservation(tool_name=action.tool_name, result=action.arguments) + + @property + def state(self): + return State() + + +@pytest.fixture +async def generated_client(tmp_path): + bundle = tmp_path / "bundle" + (bundle / "tasks").mkdir(parents=True) + (bundle / "tasks" / "task.json").write_text( + '[{"id": "turn", "type": "prompt_agent"}]' + ) + destination = tmp_path / "desk_env" + AgentEnvImporter().generate( + source=bundle, + destination=destination, + env_name="desk_env", + detected=detect_agentenv_bundles(bundle)[0], + ) + spec = importlib.util.spec_from_file_location( + "generated_agentenv_client", destination / "client.py" + ) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + app = create_app( + ChangingToolsEpisode, + CallToolAction, + CallToolObservation, + reset_observation_cls=Observation, + max_concurrent_envs=8, + ) + with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + port = sock.getsockname()[1] + server = uvicorn.Server( + uvicorn.Config(app, log_level="critical", lifespan="off") + ) + task = asyncio.create_task(server.serve(sockets=[sock])) + try: + async with asyncio.timeout(10): + while not server.started: + if task.done(): + await task + await asyncio.sleep(0.01) + async with module.DeskEnv(base_url=f"http://127.0.0.1:{port}") as client: + yield client + finally: + server.should_exit = True + await asyncio.wait_for(task, timeout=10) + + +def names(tools): + return [tool.name for tool in tools] + + +async def test_generated_client_discovers_new_task_after_reset(generated_client): + client = generated_client + await client.reset(split="all", index=0) + assert names(await client.list_tools()) == ["echo", "end_turn"] + await client.call_tool("end_turn", response="Done.") + await client.reset(split="all", index=1) + assert names(await client.list_tools()) == ["other", "end_turn"] + assert await client.has_tool("other") + assert not await client.has_tool("echo") + + +@pytest.mark.parametrize("transition", ["end_turn", "step", "tool"]) +async def test_generated_client_discovers_changed_turn_tools( + generated_client, transition +): + client = generated_client + await client.reset(split="all", index=0) + assert names(await client.list_tools()) == ["echo", "end_turn"] + if transition == "step": + await client.step( + CallToolAction(tool_name="end_turn", arguments={"response": "Done."}) + ) + else: + name = "end_turn" if transition == "end_turn" else "echo" + result = await client.call_tool( + name, response="Done.", use_cache="tool argument" + ) + assert result["result"] == {"response": "Done.", "use_cache": "tool argument"} + # Explicit cache requests retain the inherited API, including positional use. + assert names(await client.list_tools(use_cache=True)) == ["echo", "end_turn"] + assert names(await client.list_tools(True)) == ["echo", "end_turn"] + assert names(await client.list_tools()) == ["next", "end_turn"] + assert names(await client.list_tools(use_cache=False)) == ["next", "end_turn"] + assert (await client.get_tool("next")).name == "next" diff --git a/tests/test_cli/test_import_env.py b/tests/test_cli/test_import_env.py index 35c3b0623..2a4a57d25 100644 --- a/tests/test_cli/test_import_env.py +++ b/tests/test_cli/test_import_env.py @@ -9,6 +9,7 @@ from __future__ import annotations import importlib.util +import json import logging import sys from pathlib import Path @@ -17,10 +18,13 @@ import pytest from fastapi.testclient import TestClient from openenv.cli.__main__ import app +from openenv.cli.importers.agentenv import detect_agentenv_bundles from openenv.cli.importers.base import safe_vendor_dir_name from openenv.cli.importers.ors import detect_ors_dependencies, detect_ors_environments from openenv.cli.importers.verifiers import detect_verifiers_environments from openenv.core.env_server.mcp_types import CallToolAction, ListToolsAction +from openenv.core.mcp_client import MCPToolClient +from packaging.requirements import Requirement from typer.testing import CliRunner @@ -218,6 +222,39 @@ def load_environment() -> vf.Environment: ) +def _write_agentenv_bundle(root: Path) -> None: + (root / "envs" / "echo").mkdir(parents=True) + (root / "envs" / "echo" / "server.py").write_text( + 'raise RuntimeError("should not import")\n', encoding="utf-8" + ) + (root / "tasks").mkdir() + steps = [ + {"id": "env", "type": "deploy_env", "env_id": "echo"}, + {"id": "policy", "type": "deploy_agent", "agent_name": "policy"}, + {"id": "play", "type": "prompt_agent", "agent_name": "policy"}, + ] + (root / "tasks" / "ping.json").write_text(json.dumps(steps), encoding="utf-8") + + +def _write_fake_agentenv_sdk(root: Path) -> None: + package = root / "agent_env" + package.mkdir(parents=True) + (package / "__init__.py").write_text("", encoding="utf-8") + (package / "openenv.py").write_text( + """ +from fastapi import FastAPI + +served = [] + + +def build_app(bundle, max_concurrent_envs=8): + served.append((bundle, max_concurrent_envs)) + return FastAPI() +""".lstrip(), + encoding="utf-8", + ) + + def test_ors_detector_finds_environment_class_without_importing_source( tmp_path: Path, ) -> None: @@ -846,3 +883,294 @@ def test_imported_server_main_parses_host_and_port( importlib.import_module(f"{env_name}.server.app").main() assert calls == [{"host": "127.0.0.1", "port": 8001}] + + +def test_agentenv_detector_finds_bundles_without_importing_source( + tmp_path: Path, +) -> None: + source = tmp_path / "source" + _write_agentenv_bundle(source / "bundles" / "desk") + _write_agentenv_bundle(source / ".venv" / "bundles" / "installed") + (source / "notes" / "tasks").mkdir(parents=True) + (source / "notes" / "tasks" / "todo.json").write_text( + '{"title": "not a task"}', encoding="utf-8" + ) + (source / "jobs" / "tasks").mkdir(parents=True) + (source / "jobs" / "tasks" / "queue.json").write_text( + '[{"type": "build"}, {"type": "deploy"}]', encoding="utf-8" + ) + + matches = detect_agentenv_bundles(source) + + assert [(m.source_type, m.class_name, m.module_path) for m in matches] == [ + ("agentenv", "desk", "bundles/desk") + ] + + +def test_import_command_detects_agentenv_bundle_and_generates_server( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + import uvicorn + + source = tmp_path / "echo-desk" + _write_agentenv_bundle(source) + (source / "requirements.txt").write_text( + "numpy>=1.26\nagentenv-framework\n", encoding="utf-8" + ) + output_dir = tmp_path / "out" + + with patch("openenv.cli.commands.import_env._generate_uv_lock", return_value=True): + result = runner.invoke( + app, + [ + "import", + str(source), + "--name", + "desk_env", + "--output-dir", + str(output_dir), + ], + ) + + assert result.exit_code == 0, result.output + env_dir = output_dir / "desk_env" + bundle = env_dir / "vendor" / "echo-desk" + assert (bundle / "tasks" / "ping.json").exists() + assert not (env_dir / "server" / "desk_env_environment.py").exists() + pyproject = (env_dir / "pyproject.toml").read_text(encoding="utf-8") + requirements = (env_dir / "server" / "requirements.txt").read_text(encoding="utf-8") + for text in (pyproject, requirements): + assert "agentenv-framework[openenv]>=0.9.1366" in text + assert text.count("agentenv-framework") == 1 + assert "numpy>=1.26" in text + assert 'requires-python = ">=3.11.4"' in pyproject + assert "vendor/**/*" in pyproject + + sdk = tmp_path / "sdk" + _write_fake_agentenv_sdk(sdk) + calls = [] + monkeypatch.setattr(uvicorn, "run", lambda app, **kwargs: calls.append(kwargs)) + monkeypatch.setattr(sys, "argv", ["server", "--port", "8001"]) + monkeypatch.setenv("MAX_CONCURRENT_ENVS", "64") + sys.path[:0] = [str(sdk), str(output_dir)] + try: + generated = importlib.import_module("desk_env.server.app") + served = importlib.import_module("agent_env.openenv").served + + assert served == [(bundle.resolve(), 64)] + package = importlib.import_module("desk_env") + assert issubclass(package.DeskEnv, MCPToolClient) + assert package.__all__ == ["DeskEnv", "CallToolAction", "ListToolsAction"] + assert TestClient(generated.app).get("/docs").status_code == 200 + generated.main() + assert calls == [{"host": "0.0.0.0", "port": 8001}] + finally: + sys.path.remove(str(sdk)) + sys.path.remove(str(output_dir)) + for name in list(sys.modules): + if name.split(".", 1)[0] in {"agent_env", "desk_env"}: + sys.modules.pop(name) + + +@pytest.mark.parametrize("dependency_file", ["requirements.txt", "pyproject.toml"]) +@pytest.mark.parametrize( + "dependency", + [ + "agentenv-framework @ https://example.invalid/agentenv_framework-0.9.1000-py3-none-any.whl", + "agentenv-framework @ https://example.invalid/agentenv_framework-0.9.1400-py3-none-any.whl", + "agentenv-framework @ https://example.invalid/framework.tar.gz", + "agentenv-framework @ git+https://example.invalid/framework.git@v0.9.1400", + "agentenv-framework @ file:///tmp/agentenv_framework-0.9.1400-py3-none-any.whl", + 'AgentEnv_Framework[tools] @ https://user:secret@example.invalid/framework.whl ; python_version >= "3.11"', + ], +) +def test_import_command_rejects_agentenv_direct_references( + tmp_path: Path, dependency_file: str, dependency: str +) -> None: + source = tmp_path / "bundle" + _write_agentenv_bundle(source) + content = ( + "[project]\ndependencies = " + json.dumps([dependency]) + "\n" + if dependency_file == "pyproject.toml" + else dependency + "\n" + ) + (source / dependency_file).write_text(content, encoding="utf-8") + output_dir = tmp_path / "out" + + with ( + patch("openenv.cli.commands.import_env._generate_uv_lock") as lock, + patch("socket.socket.connect", side_effect=AssertionError("network forbidden")), + ): + result = runner.invoke( + app, + ["import", str(source), "--name", "desk_env", "-o", str(output_dir)], + ) + + assert result.exit_code == 1, result.output + assert "Cannot verify agentenv-framework>=0.9.1366" in result.output + assert "direct reference" in result.output + assert "version constraint" in result.output + assert "secret" not in result.output + lock.assert_not_called() + assert not (output_dir / "desk_env").exists() + + +@pytest.mark.parametrize("dependency_file", ["requirements.txt", "pyproject.toml"]) +@pytest.mark.parametrize("version_range", [">=0.9.1000,<1", ">=0.9.1400"]) +def test_import_command_merges_agentenv_floor_preserving_extras_and_marker( + tmp_path: Path, dependency_file: str, version_range: str +) -> None: + import tomllib + + source = tmp_path / "bundle" + _write_agentenv_bundle(source) + dependency = f'agentenv-framework[tools]{version_range}; python_version >= "3.11"' + content = ( + "[project]\ndependencies = " + json.dumps([dependency]) + "\n" + if dependency_file == "pyproject.toml" + else dependency + "\n" + ) + (source / dependency_file).write_text(content, encoding="utf-8") + output_dir = tmp_path / "out" + + with ( + patch("openenv.cli.commands.import_env._generate_uv_lock", return_value=True), + patch("socket.socket.connect", side_effect=AssertionError("network forbidden")), + ): + result = runner.invoke( + app, + ["import", str(source), "--name", "desk_env", "-o", str(output_dir)], + ) + + assert result.exit_code == 0, result.output + env_dir = output_dir / "desk_env" + project = tomllib.loads((env_dir / "pyproject.toml").read_text(encoding="utf-8")) + requirements = (env_dir / "server" / "requirements.txt").read_text(encoding="utf-8") + for dependencies in (project["project"]["dependencies"], requirements.splitlines()): + matches = [ + Requirement(dep) for dep in dependencies if "agentenv-framework" in dep + ] + assert len(matches) == 1 + requirement = matches[0] + assert requirement.url is None + assert requirement.extras == {"tools", "openenv"} + assert requirement.marker == Requirement(dependency).marker + assert ( + requirement.specifier + == Requirement(f"agentenv-framework{version_range},>=0.9.1366").specifier + ) + assert "0.9.1000" not in requirement.specifier + + +def test_import_command_vendors_only_the_agentenv_bundle_it_picks( + tmp_path: Path, +) -> None: + source = tmp_path / "plugin" + _write_agentenv_bundle(source / "bundles" / "train") + _write_agentenv_bundle(source / "bundles" / "eval") + (source / "bundles" / "train" / "requirements.txt").write_text( + "agentenv-framework>=0.9.1400\nnumpy>=1.26\n", encoding="utf-8" + ) + (source / "requirements.txt").write_text("torch>=2.0\n", encoding="utf-8") + (source / ".agentenv").mkdir() + (source / ".agentenv" / "config.toml").write_text("[stores]\n", encoding="utf-8") + output_dir = tmp_path / "out" + args = ["import", str(source), "--name", "picked_env", "--output-dir"] + + with patch("openenv.cli.commands.import_env._generate_uv_lock", return_value=True): + ambiguous = runner.invoke(app, [*args, str(output_dir)]) + picked = runner.invoke(app, [*args, str(output_dir), "--env-class", "train"]) + + assert ambiguous.exit_code != 0 + assert "Multiple environment entrypoints" in ambiguous.output + assert picked.exit_code == 0, picked.output + env_dir = output_dir / "picked_env" + vendored = sorted( + path.relative_to(env_dir / "vendor").as_posix() + for path in (env_dir / "vendor").rglob("*") + if path.is_file() + ) + assert vendored == [ + "__init__.py", + "train/__init__.py", + "train/envs/echo/server.py", + "train/requirements.txt", + "train/tasks/ping.json", + ] + app_source = (env_dir / "server" / "app.py").read_text(encoding="utf-8") + assert '"vendor" / "train"' in app_source + requirements = (env_dir / "server" / "requirements.txt").read_text(encoding="utf-8") + assert "agentenv-framework[openenv]>=0.9.1366,>=0.9.1400" in requirements + assert "numpy>=1.26" in requirements + assert "torch" not in requirements + + +@pytest.mark.parametrize( + ("bundle_name", "env_name"), [("echo-desk", "desk_env"), ("train", "picked_env")] +) +def test_agentenv_generated_readme_matches_paths_and_prerequisites( + tmp_path: Path, bundle_name: str, env_name: str +) -> None: + import re + + import tomli + + source = tmp_path / bundle_name + _write_agentenv_bundle(source) + output_dir = tmp_path / "out" + with patch("openenv.cli.commands.import_env._generate_uv_lock", return_value=True): + result = runner.invoke( + app, + [ + "import", + str(source), + "--name", + env_name, + "--output-dir", + str(output_dir), + ], + ) + + assert result.exit_code == 0, result.output + env_dir = output_dir / env_name + readme = (env_dir / "README.md").read_text(encoding="utf-8") + # Check the published file inventory against the output, including the bundle + # whose name differs from the package and may contain a hyphen. + inventory = readme.split("## Generated files\n", 1)[1] + paths = re.findall(r"`([^`]+)`", inventory) + assert f"vendor/{bundle_name}" in paths + assert "server/app.py" in paths + for path in paths: + assert (env_dir / path).exists(), path + assert f"{env_name}_environment.py" not in readme + assert "from_docker_image" not in readme + assert "echoed_message" not in readme + + project = tomli.loads((env_dir / "pyproject.toml").read_text())["project"] + assert ( + "uv sync\nMAX_CONCURRENT_ENVS=8 uv run server --host 127.0.0.1 --port 8000" + in readme + ) + assert project["scripts"]["server"] == f"{env_name}.server.app:main" + assert project["requires-python"] == ">=3.11.4" + assert "Python >=3.11.4" in readme + requirement = "agentenv-framework[openenv]>=0.9.1366" + assert requirement in project["dependencies"] + assert requirement in (env_dir / "server/requirements.txt").read_text() + assert requirement in readme + assert "agent_env.openenv.build_app" in readme + adapter = "https://github.com/scaleapi/agentenv-framework/blob/v0.9.1366/src/agent_env/openenv" + assert f"({adapter}/environment.py)" in readme + assert f"({adapter}/app.py)" in readme + client = {"desk_env": "DeskEnv", "picked_env": "PickedEnv"}[env_name] + assert f"from {env_name} import {client}" in readme + assert f"class {client}(MCPToolClient):" in (env_dir / "client.py").read_text() + assert not (env_dir / "models.py").exists() + assert "scaffolding" not in readme + for text in (readme, result.output): + normalized = " ".join(text.split()) + assert "Docker CLI and a running daemon accessible to the server" in normalized + assert "openenv build" in text and "openenv push" in text + assert "do not produce a working deployment" in normalized + assert "server/Dockerfile" in result.output + assert "Warning:" in result.output