zkwentz commited on
Commit
57c61cc
·
verified ·
1 Parent(s): 0d7294a

Upload folder using huggingface_hub

Browse files
Files changed (39) hide show
  1. src/core/.pytest_cache/.gitignore +2 -0
  2. src/core/.pytest_cache/CACHEDIR.TAG +4 -0
  3. src/core/.pytest_cache/README.md +8 -0
  4. src/core/.pytest_cache/v/cache/nodeids +1 -0
  5. src/core/build/lib/openenv_cli/__init__.py +9 -0
  6. src/core/build/lib/openenv_cli/__main__.py +129 -0
  7. src/core/build/lib/openenv_cli/commands/__init__.py +7 -0
  8. src/core/build/lib/openenv_cli/commands/push.py +154 -0
  9. src/core/build/lib/openenv_cli/core/__init__.py +7 -0
  10. src/core/build/lib/openenv_cli/core/auth.py +84 -0
  11. src/core/build/lib/openenv_cli/core/builder.py +270 -0
  12. src/core/build/lib/openenv_cli/core/space.py +73 -0
  13. src/core/build/lib/openenv_cli/core/uploader.py +41 -0
  14. src/core/build/lib/openenv_cli/utils/__init__.py +7 -0
  15. src/core/build/lib/openenv_cli/utils/env_loader.py +91 -0
  16. src/core/build/lib/openenv_core/__init__.py +19 -0
  17. src/core/build/lib/openenv_core/client_types.py +22 -0
  18. src/core/build/lib/openenv_core/containers/__init__.py +7 -0
  19. src/core/build/lib/openenv_core/containers/runtime/__init__.py +15 -0
  20. src/core/build/lib/openenv_core/containers/runtime/providers.py +293 -0
  21. src/core/build/lib/openenv_core/containers/test_local_docker_provider.py +258 -0
  22. src/core/build/lib/openenv_core/env_server/__init__.py +35 -0
  23. src/core/build/lib/openenv_core/env_server/base_transforms.py +29 -0
  24. src/core/build/lib/openenv_core/env_server/http_server.py +233 -0
  25. src/core/build/lib/openenv_core/env_server/interfaces.py +118 -0
  26. src/core/build/lib/openenv_core/env_server/types.py +57 -0
  27. src/core/build/lib/openenv_core/env_server/web_interface.py +1613 -0
  28. src/core/build/lib/openenv_core/http_env_client.py +185 -0
  29. src/core/build/lib/openenv_core/tools/__init__.py +16 -0
  30. src/core/build/lib/openenv_core/tools/git_server_client.py +362 -0
  31. src/core/build/lib/openenv_core/tools/local_python_executor.py +105 -0
  32. src/core/dist/openenv_core-0.1.0-py3-none-any.whl +0 -0
  33. src/core/openenv_core.egg-info/PKG-INFO +204 -0
  34. src/core/openenv_core.egg-info/SOURCES.txt +51 -0
  35. src/core/openenv_core.egg-info/dependency_links.txt +1 -0
  36. src/core/openenv_core.egg-info/entry_points.txt +2 -0
  37. src/core/openenv_core.egg-info/requires.txt +11 -0
  38. src/core/openenv_core.egg-info/top_level.txt +2 -0
  39. src/core/pyproject.toml +11 -2
src/core/.pytest_cache/.gitignore ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ # Created by pytest automatically.
2
+ *
src/core/.pytest_cache/CACHEDIR.TAG ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ Signature: 8a477f597d28d172789f06886806bc55
2
+ # This file is a cache directory tag created by pytest.
3
+ # For information about cache directory tags, see:
4
+ # https://bford.info/cachedir/spec.html
src/core/.pytest_cache/README.md ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ # pytest cache directory #
2
+
3
+ This directory contains data from the pytest's cache plugin,
4
+ which provides the `--lf` and `--ff` options, as well as the `cache` fixture.
5
+
6
+ **Do not** commit this to version control.
7
+
8
+ See [the docs](https://docs.pytest.org/en/stable/how-to/cache.html) for more information.
src/core/.pytest_cache/v/cache/nodeids ADDED
@@ -0,0 +1 @@
 
 
1
+ []
src/core/build/lib/openenv_cli/__init__.py ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ """OpenEnv CLI - Command-line tool for managing OpenEnv environments."""
8
+
9
+ __version__ = "0.1.0"
src/core/build/lib/openenv_cli/__main__.py ADDED
@@ -0,0 +1,129 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ """CLI entry point for OpenEnv."""
8
+
9
+ import argparse
10
+ import sys
11
+
12
+ from rich.console import Console
13
+ from rich.traceback import install
14
+
15
+ from .commands.push import push_environment
16
+ from .core.auth import ensure_authenticated
17
+
18
+
19
+ console = Console()
20
+ install(show_locals=False)
21
+
22
+
23
+ def main():
24
+ """Main entry point for OpenEnv CLI."""
25
+ parser = argparse.ArgumentParser(
26
+ prog="openenv",
27
+ description="OpenEnv CLI - Manage and deploy OpenEnv environments",
28
+ )
29
+
30
+ subparsers = parser.add_subparsers(dest="command", help="Command to run")
31
+
32
+ # Push command
33
+ push_parser = subparsers.add_parser(
34
+ "push",
35
+ help="Push an environment to Hugging Face Spaces",
36
+ )
37
+ push_parser.add_argument(
38
+ "env_name",
39
+ help="Name of the environment to push (e.g., echo_env)",
40
+ )
41
+ push_parser.add_argument(
42
+ "--repo-id",
43
+ help="Hugging Face repository ID in format 'namespace/space-name'. "
44
+ "If not provided, uses '{username}/{env_name}'.",
45
+ )
46
+ push_parser.add_argument(
47
+ "--private",
48
+ action="store_true",
49
+ help="Create a private space (default: public)",
50
+ )
51
+ push_parser.add_argument(
52
+ "--base-image",
53
+ help="Base Docker image to use "
54
+ "(default: ghcr.io/meta-pytorch/openenv-base:latest)",
55
+ )
56
+ push_parser.add_argument(
57
+ "--dry-run",
58
+ action="store_true",
59
+ help="Prepare files but don't upload to Hugging Face",
60
+ )
61
+
62
+ args = parser.parse_args()
63
+
64
+ if args.command == "push":
65
+ try:
66
+ # Authenticate first (before status spinner) to allow interactive login if needed
67
+ # Note: login() may print ASCII art to stdout - we clean up after
68
+ username, token = ensure_authenticated()
69
+ # Print a newline to separate login output from our status message
70
+ console.print() # Clean separator after login output
71
+
72
+ if args.dry_run:
73
+ status_message = f"[bold yellow]Preparing dry run for '{args.env_name}'...[/bold yellow]"
74
+ with console.status(status_message):
75
+ push_environment(
76
+ env_name=args.env_name,
77
+ repo_id=args.repo_id,
78
+ private=args.private,
79
+ base_image=args.base_image,
80
+ dry_run=args.dry_run,
81
+ )
82
+ else:
83
+ # Use status spinner for preparation steps
84
+ with console.status(f"[bold cyan]Preparing '{args.env_name}'...[/bold cyan]"):
85
+ from openenv_cli.commands.push import _prepare_environment
86
+ staging_dir = _prepare_environment(
87
+ env_name=args.env_name,
88
+ repo_id=args.repo_id,
89
+ private=args.private,
90
+ base_image=args.base_image,
91
+ username=username,
92
+ token=token,
93
+ )
94
+
95
+ # Determine repo_id for upload
96
+ if args.repo_id is None:
97
+ from openenv_cli.core.space import get_space_repo_id
98
+ repo_id = get_space_repo_id(args.env_name)
99
+ else:
100
+ repo_id = args.repo_id
101
+
102
+ # Upload without spinner so messages from huggingface_hub appear cleanly
103
+ from openenv_cli.commands.push import _upload_environment
104
+ _upload_environment(
105
+ env_name=args.env_name,
106
+ repo_id=repo_id,
107
+ staging_dir=staging_dir,
108
+ username=username,
109
+ token=token,
110
+ )
111
+
112
+ if args.dry_run:
113
+ console.print(
114
+ f"[bold yellow]Dry run complete for '{args.env_name}'.[/bold yellow]"
115
+ )
116
+ else:
117
+ console.print(
118
+ f"[bold green]Successfully pushed '{args.env_name}'.[/bold green]"
119
+ )
120
+ except Exception as e:
121
+ console.print(f"[bold red]Error:[/bold red] {e}", highlight=False, soft_wrap=True)
122
+ sys.exit(1)
123
+ else:
124
+ console.print(parser.format_help())
125
+ sys.exit(1)
126
+
127
+
128
+ if __name__ == "__main__":
129
+ main()
src/core/build/lib/openenv_cli/commands/__init__.py ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ """OpenEnv CLI commands."""
src/core/build/lib/openenv_cli/commands/push.py ADDED
@@ -0,0 +1,154 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ """Push command for deploying environments to Hugging Face Spaces."""
8
+
9
+ from pathlib import Path
10
+ from typing import Optional
11
+
12
+ from huggingface_hub import HfApi
13
+
14
+ from ..core.auth import ensure_authenticated
15
+ from ..core.builder import (
16
+ prepare_staging_directory,
17
+ copy_environment_files,
18
+ prepare_dockerfile,
19
+ prepare_readme,
20
+ )
21
+ from ..core.space import create_space, get_space_repo_id
22
+ from ..core.uploader import upload_to_space
23
+ from ..utils.env_loader import validate_environment
24
+
25
+
26
+ def push_environment(
27
+ env_name: str,
28
+ repo_id: Optional[str] = None,
29
+ private: bool = False,
30
+ base_image: Optional[str] = None,
31
+ dry_run: bool = False,
32
+ ) -> None:
33
+ """
34
+ Push an environment to Hugging Face Spaces.
35
+
36
+ Args:
37
+ env_name: Name of the environment to push.
38
+ repo_id: Optional repository ID in format 'namespace/space-name'. If not provided,
39
+ uses '{username}/{env_name}'.
40
+ private: Whether the space should be private (default: False).
41
+ base_image: Base Docker image to use (default: ghcr.io/meta-pytorch/openenv-base:latest).
42
+ dry_run: If True, prepare files but don't upload (default: False).
43
+ """
44
+ # Validate environment exists
45
+ validate_environment(env_name)
46
+
47
+ # Get token (authentication should already be done in __main__, but get token for API)
48
+ # ensure_authenticated is idempotent - if already authenticated, it returns immediately
49
+ username, token = ensure_authenticated()
50
+
51
+ # Determine target space repo ID
52
+ if repo_id is None:
53
+ repo_id = get_space_repo_id(env_name)
54
+
55
+ # Create HfApi instance
56
+ api = HfApi(token=token)
57
+
58
+ # Check if space exists, create if needed
59
+ create_space(api, repo_id, private=private)
60
+ # Set default base image if not provided
61
+ if base_image is None:
62
+ base_image = "ghcr.io/meta-pytorch/openenv-base:latest"
63
+
64
+ # Prepare staging directory
65
+ staging_dir = prepare_staging_directory(env_name, base_image)
66
+
67
+ try:
68
+ # Copy files
69
+ copy_environment_files(env_name, staging_dir)
70
+
71
+ # Prepare Dockerfile
72
+ prepare_dockerfile(env_name, staging_dir, base_image)
73
+
74
+ # Prepare README
75
+ prepare_readme(env_name, staging_dir)
76
+
77
+ # Upload to space (skip if dry run)
78
+ if not dry_run:
79
+ upload_to_space(api, repo_id, staging_dir, token)
80
+
81
+ finally:
82
+ # Cleanup staging directory after upload or dry run
83
+ if staging_dir.exists():
84
+ import shutil
85
+ shutil.rmtree(staging_dir)
86
+
87
+
88
+ def _prepare_environment(
89
+ env_name: str,
90
+ repo_id: Optional[str],
91
+ private: bool,
92
+ base_image: Optional[str],
93
+ username: str,
94
+ token: str,
95
+ ) -> Path:
96
+ """
97
+ Internal function to prepare environment staging directory.
98
+
99
+ Returns:
100
+ Path to staging directory (must be cleaned up by caller).
101
+ """
102
+ # Validate environment exists
103
+ validate_environment(env_name)
104
+
105
+ # Determine target space repo ID
106
+ if repo_id is None:
107
+ repo_id = get_space_repo_id(env_name)
108
+
109
+ # Create HfApi instance
110
+ api = HfApi(token=token)
111
+
112
+ # Check if space exists, create if needed
113
+ create_space(api, repo_id, private=private)
114
+
115
+ # Set default base image if not provided
116
+ if base_image is None:
117
+ base_image = "ghcr.io/meta-pytorch/openenv-base:latest"
118
+
119
+ # Prepare staging directory
120
+ staging_dir = prepare_staging_directory(env_name, base_image)
121
+
122
+ # Copy files
123
+ copy_environment_files(env_name, staging_dir)
124
+
125
+ # Prepare Dockerfile
126
+ prepare_dockerfile(env_name, staging_dir, base_image)
127
+
128
+ # Prepare README
129
+ prepare_readme(env_name, staging_dir)
130
+
131
+ return staging_dir
132
+
133
+
134
+ def _upload_environment(
135
+ env_name: str,
136
+ repo_id: str,
137
+ staging_dir: Path,
138
+ username: str,
139
+ token: str,
140
+ ) -> None:
141
+ """
142
+ Internal function to upload environment staging directory.
143
+
144
+ The staging directory will be cleaned up after upload.
145
+ """
146
+ api = HfApi(token=token)
147
+
148
+ try:
149
+ upload_to_space(api, repo_id, staging_dir, token)
150
+ finally:
151
+ # Cleanup staging directory after upload
152
+ if staging_dir.exists():
153
+ import shutil
154
+ shutil.rmtree(staging_dir)
src/core/build/lib/openenv_cli/core/__init__.py ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ """OpenEnv CLI core modules."""
src/core/build/lib/openenv_cli/core/auth.py ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ """Authentication module for Hugging Face."""
8
+
9
+ from typing import Optional, Tuple
10
+
11
+ from huggingface_hub import HfApi, login
12
+ from huggingface_hub.utils import get_token
13
+
14
+
15
+ def check_authentication() -> Optional[str]:
16
+ """
17
+ Check if user is authenticated with Hugging Face.
18
+
19
+ Returns:
20
+ Username if authenticated, None otherwise.
21
+ """
22
+ # Get token from stored credentials (from previous login)
23
+ token = get_token()
24
+
25
+ if not token:
26
+ return None
27
+
28
+ try:
29
+ api = HfApi(token=token)
30
+ user_info = api.whoami()
31
+ return user_info.get("name")
32
+ except Exception:
33
+ # Invalid token or network error
34
+ return None
35
+
36
+
37
+ def ensure_authenticated() -> Tuple[str, str]:
38
+ """
39
+ Ensure user is authenticated, prompting for login if needed.
40
+
41
+ Returns:
42
+ Tuple of (username, token).
43
+
44
+ Raises:
45
+ Exception: If authentication fails.
46
+ """
47
+ # Check existing authentication (from stored credentials)
48
+ username = check_authentication()
49
+ if username:
50
+ token = get_token()
51
+ if token:
52
+ return username, token
53
+
54
+ # Need to login
55
+ username = login_interactive()
56
+ token = get_token()
57
+ if not token:
58
+ raise Exception("Failed to retrieve token after login")
59
+
60
+ return username, token
61
+
62
+
63
+ def login_interactive() -> str:
64
+ """
65
+ Perform interactive login to Hugging Face.
66
+
67
+ Returns:
68
+ Username after successful login.
69
+
70
+ Raises:
71
+ Exception: If login fails.
72
+ """
73
+ try:
74
+ login()
75
+ # Verify login was successful
76
+ username = check_authentication()
77
+ if not username:
78
+ raise Exception("Login failed: unable to verify authentication")
79
+ return username
80
+ except Exception as e:
81
+ # Re-raise our custom exceptions, wrap others
82
+ if isinstance(e, Exception) and str(e).startswith("Login failed"):
83
+ raise
84
+ raise Exception(f"Login failed: {str(e)}")
src/core/build/lib/openenv_cli/core/builder.py ADDED
@@ -0,0 +1,270 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ """Builder module for preparing environments for deployment."""
8
+
9
+ import random
10
+ import re
11
+ import shutil
12
+ from pathlib import Path
13
+
14
+
15
+ def prepare_staging_directory(env_name: str, base_image: str, staging_root: str = "hf-staging") -> Path:
16
+ """
17
+ Prepare staging directory structure for deployment.
18
+
19
+ Args:
20
+ env_name: Name of the environment.
21
+ base_image: Base Docker image to use.
22
+ staging_root: Root directory for staging (default: "hf-staging").
23
+
24
+ Returns:
25
+ Path to the staging directory.
26
+ """
27
+ # Create staging directory
28
+ staging_dir = Path(staging_root) / env_name
29
+ if staging_dir.exists():
30
+ shutil.rmtree(staging_dir)
31
+ staging_dir.mkdir(parents=True)
32
+
33
+ # Create subdirectories
34
+ (staging_dir / "src" / "core").mkdir(parents=True)
35
+ (staging_dir / "src" / "envs" / env_name).mkdir(parents=True)
36
+
37
+ return staging_dir
38
+
39
+
40
+ def copy_environment_files(env_name: str, staging_dir: Path) -> None:
41
+ """
42
+ Copy environment files to staging directory.
43
+
44
+ Args:
45
+ env_name: Name of the environment.
46
+ staging_dir: Staging directory path.
47
+ """
48
+ # Copy core files
49
+ core_src = Path("src/core")
50
+ core_dst = staging_dir / "src" / "core"
51
+ if core_src.exists():
52
+ shutil.copytree(core_src, core_dst, dirs_exist_ok=True)
53
+
54
+ # Copy environment files
55
+ env_src = Path("src/envs") / env_name
56
+ env_dst = staging_dir / "src" / "envs" / env_name
57
+ if not env_src.exists():
58
+ raise FileNotFoundError(f"Environment not found: {env_src}")
59
+ shutil.copytree(env_src, env_dst, dirs_exist_ok=True)
60
+
61
+
62
+ def prepare_dockerfile(env_name: str, staging_dir: Path, base_image: str) -> None:
63
+ """
64
+ Prepare Dockerfile for deployment.
65
+
66
+ Uses the environment's Dockerfile if it exists, otherwise creates a default one.
67
+
68
+ Args:
69
+ env_name: Name of the environment.
70
+ staging_dir: Staging directory path.
71
+ base_image: Base Docker image to use.
72
+ """
73
+ env_dockerfile = Path("src/envs") / env_name / "server" / "Dockerfile"
74
+ dockerfile_path = staging_dir / "Dockerfile"
75
+
76
+ if env_dockerfile.exists():
77
+ # Copy and modify existing Dockerfile
78
+ content = env_dockerfile.read_text()
79
+
80
+ # Replace BASE_IMAGE references
81
+ content = re.sub(
82
+ r'ARG\s+BASE_IMAGE=.*',
83
+ f'ARG BASE_IMAGE={base_image}',
84
+ content
85
+ )
86
+ content = re.sub(
87
+ r'FROM\s+\$\{BASE_IMAGE\}',
88
+ f'FROM {base_image}',
89
+ content
90
+ )
91
+ # Also handle direct FROM statements
92
+ content = re.sub(
93
+ r'FROM\s+openenv-base:.*',
94
+ f'FROM {base_image}',
95
+ content
96
+ )
97
+
98
+ dockerfile_path.write_text(content)
99
+ else:
100
+ # Create default Dockerfile
101
+ default_dockerfile = f"""# Copyright (c) Meta Platforms, Inc. and affiliates.
102
+ # All rights reserved.
103
+ #
104
+ # This source code is licensed under the BSD-style license found in the
105
+ # LICENSE file in the root directory of this source tree.
106
+
107
+ # Use the specified openenv-base image
108
+ FROM {base_image}
109
+
110
+ # Copy only what's needed for this environment
111
+ COPY src/core/ /app/src/core/
112
+ COPY src/envs/{env_name}/ /app/src/envs/{env_name}/
113
+
114
+ # Health check
115
+ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \\
116
+ CMD curl -f http://localhost:8000/health || exit 1
117
+
118
+ # Run the FastAPI server
119
+ CMD ["uvicorn", "envs.{env_name}.server.app:app", "--host", "0.0.0.0", "--port", "8000"]
120
+ """
121
+ dockerfile_path.write_text(default_dockerfile)
122
+
123
+ # Ensure web interface is enabled
124
+ content = dockerfile_path.read_text()
125
+ if "ENABLE_WEB_INTERFACE" not in content:
126
+ # Add before the CMD line
127
+ content = re.sub(
128
+ r'(CMD\s+\[)',
129
+ r'ENV ENABLE_WEB_INTERFACE=true\n\n\1',
130
+ content
131
+ )
132
+ dockerfile_path.write_text(content)
133
+
134
+
135
+ def prepare_readme(env_name: str, staging_dir: Path) -> None:
136
+ """
137
+ Prepare README.md with Hugging Face front matter.
138
+
139
+ If the environment README already has Hugging Face front matter (starts and ends with `---`),
140
+ use it as-is. Otherwise, generate front matter with random emoji and colors.
141
+
142
+ Args:
143
+ env_name: Name of the environment.
144
+ staging_dir: Staging directory path.
145
+ """
146
+ # Check both src/envs/${ENV_NAME}/README.md and src/envs/${ENV_NAME}/server/README.md
147
+ readme_paths = [
148
+ Path("src/envs") / env_name / "README.md",
149
+ Path("src/envs") / env_name / "server" / "README.md",
150
+ ]
151
+
152
+ # Check if any README has Hugging Face front matter
153
+ existing_readme_content = None
154
+ for readme_path in readme_paths:
155
+ if readme_path.exists():
156
+ content = readme_path.read_text()
157
+ # Check if it has front matter (starts with --- and has closing ---)
158
+ if content.startswith("---"):
159
+ lines = content.split("\n")
160
+ # Look for closing --- (must be at the top, within first 100 lines)
161
+ for i in range(1, min(100, len(lines))):
162
+ if lines[i].strip() == "---":
163
+ # Found front matter, use this README as-is
164
+ existing_readme_content = content
165
+ break
166
+ if existing_readme_content:
167
+ break
168
+
169
+ # If we found an existing README with front matter, use it as-is
170
+ if existing_readme_content:
171
+ readme_path = staging_dir / "README.md"
172
+ readme_path.write_text(existing_readme_content)
173
+ return
174
+
175
+ # Otherwise, generate front matter with random emoji and colors
176
+ # Safely capitalize env_name - handle empty string and None cases
177
+ if env_name and len(env_name) > 0:
178
+ env_title = env_name[0].upper() + env_name[1:]
179
+ else:
180
+ env_title = ""
181
+
182
+ # Approved emojis from Spaces Configuration Reference
183
+ approved_emojis = [
184
+ "🎮", "🚀", "💻", "🔬", "🧪", "🎯", "🎨", "📊", "🤖", "🌟",
185
+ "⚡", "🔧", "📱", "💡", "🎲", "🎵", "🎸", "🎭", "🎬", "🏆",
186
+ "🔥", "💎", "🌈", "🎁", "🎈", "🎊", "🎉", "🦄", "🐳", "🐙",
187
+ "🦋", "🐝", "🐞", "🦅", "🦉", "🦇", "🐉", "🦖", "🦕", "🐢",
188
+ "🐍", "🦎", "🐊", "🐋", "🦈", "🐬", "🐠", "🐟", "🐡", "🦑",
189
+ "🦐", "🦞", "🦀", "🐺", "🐗", "🐴", "🐛", "🐌", "🐜", "🦟",
190
+ "🦗", "🕷️", "🦂", "🐅", "🐆", "🦓", "🦍", "🦧", "🐘", "🦛",
191
+ "🦏", "🐪", "🐫", "🦒", "🦘", "🦬", "🐃", "🐂", "🐄", "🐎",
192
+ "🐖", "🐏", "🐑", "🦙", "🐐", "🦌", "🐕", "🐩", "🦮", "🐈",
193
+ "🪶", "🐓", "🦃", "🦚", "🦜", "🦢", "🦩", "🕊️", "🐇", "🐿️",
194
+ "🦫", "🦔", "🦝", "🦨", "🦡", "🦦", "🦥", "🐁", "🐀", "🐾",
195
+ "🐲", "🌵", "🎄", "🌲", "🌳", "🌴", "🌱", "🌿", "☘️", "🍀",
196
+ "🎍", "🪴", "🎋", "🍃", "🍂", "🍁", "🍄", "🐚", "🪨", "🌾",
197
+ "💐", "🌷", "🌹", "🥀", "🌺", "🌸", "🌼", "🌻", "🌞", "🌝",
198
+ "🌛", "🌜", "🌕", "🌖", "🌗", "🌘", "🌑", "🌒", "🌓", "🌔",
199
+ "🌙", "🌚", "🌏", "🪐", "💫", "⭐", "✨", "☄️", "💥", "☀️",
200
+ "⛅", "☁️", "⛈️", "🌤️", "🌦️", "🌧️", "🌩️", "❄️", "☃️", "⛄",
201
+ "🌬️", "💨", "💧", "💦", "☔", "☂️", "🌊", "🌫️",
202
+ ]
203
+
204
+ # Approved colors from Spaces Configuration Reference
205
+ approved_colors = ["red", "yellow", "green", "blue", "indigo", "purple", "pink", "gray"]
206
+
207
+ # Randomly select emoji and colors
208
+ emoji = random.choice(approved_emojis)
209
+ color_from = random.choice(approved_colors)
210
+ color_to = random.choice(approved_colors)
211
+
212
+ # Create README with front matter
213
+ readme_content = f"""---
214
+ title: {env_title} Environment Server
215
+ emoji: {emoji}
216
+ colorFrom: {color_from}
217
+ colorTo: {color_to}
218
+ sdk: docker
219
+ pinned: false
220
+ app_port: 8000
221
+ base_path: /web
222
+ tags:
223
+ - openenv
224
+ ---
225
+
226
+ # {env_title} Environment Server
227
+
228
+ FastAPI server for {env_name} environment powered by Meta's OpenEnv.
229
+
230
+ ## About
231
+
232
+ This Space provides a containerized environment for {env_name} interactions.
233
+ Built with FastAPI and OpenEnv framework.
234
+
235
+ ## Web Interface
236
+
237
+ This deployment includes an interactive web interface for exploring the environment:
238
+ - **HumanAgent Interface**: Interact with the environment using a web form
239
+ - **State Observer**: Real-time view of environment state and action history
240
+ - **Live Updates**: WebSocket-based real-time updates
241
+
242
+ Access the web interface at: `/web`
243
+
244
+ ## API Documentation
245
+
246
+ Visit `/docs` for interactive API documentation.
247
+
248
+ ## Health Check
249
+
250
+ The environment provides a health check endpoint at `/health`.
251
+ """
252
+
253
+ # Try to append content from original README if it exists (without front matter)
254
+ # Only append if the original README doesn't have front matter (if it has front matter,
255
+ # we've already used it as-is above, so we shouldn't append here to avoid duplicates)
256
+ original_readme = Path("src/envs") / env_name / "README.md"
257
+ if original_readme.exists():
258
+ original_content = original_readme.read_text()
259
+
260
+ # Only append if original README doesn't have front matter
261
+ # (if it has front matter, we already used it as-is earlier, so skip to avoid duplicates)
262
+ if not original_content.startswith("---"):
263
+ # Original README has no front matter, append its content after generated sections
264
+ # This preserves environment-specific documentation
265
+ original_content_stripped = original_content.strip()
266
+ if original_content_stripped:
267
+ readme_content += "\n\n" + original_content_stripped
268
+
269
+ readme_path = staging_dir / "README.md"
270
+ readme_path.write_text(readme_content)
src/core/build/lib/openenv_cli/core/space.py ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ """Space management module for Hugging Face Spaces."""
8
+
9
+ from typing import Optional
10
+
11
+ from huggingface_hub import HfApi
12
+
13
+ from .auth import ensure_authenticated
14
+
15
+
16
+ def create_space(api: HfApi, repo_id: str, private: bool = False) -> None:
17
+ """
18
+ Create a Docker Space on Hugging Face.
19
+
20
+ Args:
21
+ api: HfApi instance to use for API calls.
22
+ repo_id: Repository ID in format 'namespace/repo-name'.
23
+ private: Whether the space should be private (default: False).
24
+
25
+ Raises:
26
+ Exception: If space creation fails.
27
+ """
28
+ try:
29
+ api.create_repo(
30
+ repo_id=repo_id,
31
+ repo_type="space",
32
+ space_sdk="docker",
33
+ private=private,
34
+ exist_ok=True # Hub CLI handles existence checks and private/non-private re-creation
35
+ )
36
+ except Exception as e:
37
+ # Check for authentication-related errors and provide clearer messages
38
+ error_str = str(e).lower()
39
+ if any(keyword in error_str for keyword in ["unauthorized", "authentication", "401", "invalid token", "token"]):
40
+ raise Exception(
41
+ f"Authentication failed when creating space {repo_id}. "
42
+ f"Please check your Hugging Face token and ensure it has write permissions. "
43
+ f"Original error: {e}"
44
+ ) from e
45
+
46
+ # Check for permission-related errors
47
+ if any(keyword in error_str for keyword in ["forbidden", "403", "permission", "not authorized"]):
48
+ raise Exception(
49
+ f"Permission denied when creating space {repo_id}. "
50
+ f"Please verify you have permission to create spaces in this namespace. "
51
+ f"Original error: {e}"
52
+ ) from e
53
+
54
+ # Raise a clearer generic error for other cases
55
+ # Note: exist_ok=True handles space existence and will print warnings to the user
56
+ raise Exception(
57
+ f"Failed to create space {repo_id}: {e}"
58
+ ) from e
59
+
60
+
61
+ def get_space_repo_id(env_name: str) -> str:
62
+ """
63
+ Get the full repository ID for a space using the authenticated user's username.
64
+
65
+ Args:
66
+ env_name: Environment name (e.g., "echo_env"). Used as space name.
67
+
68
+ Returns:
69
+ Repository ID in format 'username/env_name'.
70
+ """
71
+ # Use authenticated user's username
72
+ username, _ = ensure_authenticated()
73
+ return f"{username}/{env_name}"
src/core/build/lib/openenv_cli/core/uploader.py ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ """Uploader module for deploying to Hugging Face Spaces."""
8
+
9
+ from pathlib import Path
10
+
11
+ from huggingface_hub import HfApi
12
+ from huggingface_hub import upload_folder
13
+
14
+
15
+ def upload_to_space(
16
+ api: HfApi,
17
+ repo_id: str,
18
+ staging_dir: Path,
19
+ token: str,
20
+ ) -> None:
21
+ """
22
+ Upload staging directory contents to Hugging Face Space.
23
+
24
+ Args:
25
+ api: HfApi instance to use for API calls.
26
+ repo_id: Repository ID in format 'namespace/repo-name'.
27
+ staging_dir: Path to staging directory to upload.
28
+ token: Hugging Face token for authentication.
29
+
30
+ Raises:
31
+ Exception: If upload fails.
32
+ """
33
+ try:
34
+ upload_folder(
35
+ folder_path=str(staging_dir),
36
+ repo_id=repo_id,
37
+ repo_type="space",
38
+ token=token,
39
+ )
40
+ except Exception as e:
41
+ raise Exception(f"Failed to upload to space {repo_id}: {str(e)}")
src/core/build/lib/openenv_cli/utils/__init__.py ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ """OpenEnv CLI utility modules."""
src/core/build/lib/openenv_cli/utils/env_loader.py ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ """Environment loader utilities."""
8
+
9
+ from pathlib import Path
10
+ from typing import Dict, Any, Optional
11
+
12
+
13
+ def validate_environment(env_name: str) -> Path:
14
+ """
15
+ Validate that environment exists and return its path.
16
+
17
+ Args:
18
+ env_name: Name of the environment to validate.
19
+
20
+ Returns:
21
+ Path to the environment directory.
22
+
23
+ Raises:
24
+ FileNotFoundError: If environment does not exist.
25
+ """
26
+ env_path = Path("src/envs") / env_name
27
+ if not env_path.exists():
28
+ raise FileNotFoundError(
29
+ f"Environment '{env_name}' not found under src/envs. "
30
+ f"Expected path: {env_path.absolute()}"
31
+ )
32
+ if not env_path.is_dir():
33
+ raise FileNotFoundError(
34
+ f"Environment '{env_name}' is not a directory. "
35
+ f"Path: {env_path.absolute()}"
36
+ )
37
+ return env_path
38
+
39
+
40
+ def load_env_metadata(env_name: str) -> Dict[str, Any]:
41
+ """
42
+ Load environment metadata.
43
+
44
+ Args:
45
+ env_name: Name of the environment.
46
+
47
+ Returns:
48
+ Dictionary with environment metadata.
49
+ """
50
+ env_path = validate_environment(env_name)
51
+
52
+ metadata: Dict[str, Any] = {
53
+ "name": env_name,
54
+ "path": str(env_path),
55
+ }
56
+
57
+ # Load README if it exists
58
+ readme_path = env_path / "README.md"
59
+ if readme_path.exists():
60
+ readme_content = readme_path.read_text()
61
+ metadata["readme"] = readme_content
62
+
63
+ # Try to extract title from README
64
+ lines = readme_content.split("\n")
65
+ for line in lines:
66
+ if line.startswith("# "):
67
+ metadata["title"] = line[2:].strip()
68
+ break
69
+
70
+ # Check for server directory
71
+ server_path = env_path / "server"
72
+ if server_path.exists():
73
+ metadata["has_server"] = True
74
+
75
+ # Check for Dockerfile
76
+ dockerfile_path = server_path / "Dockerfile"
77
+ if dockerfile_path.exists():
78
+ metadata["has_dockerfile"] = True
79
+ metadata["dockerfile_path"] = str(dockerfile_path)
80
+
81
+ # Check for models.py
82
+ models_path = env_path / "models.py"
83
+ if models_path.exists():
84
+ metadata["has_models"] = True
85
+
86
+ # Check for client.py
87
+ client_path = env_path / "client.py"
88
+ if client_path.exists():
89
+ metadata["has_client"] = True
90
+
91
+ return metadata
src/core/build/lib/openenv_core/__init__.py ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ """Core components for agentic environments."""
8
+
9
+ # Re-export main components from submodules for convenience
10
+ from .env_server import *
11
+ from .client_types import StepResult
12
+ from .http_env_client import HTTPEnvClient
13
+
14
+ # Note: MCP module doesn't export anything yet
15
+
16
+ __all__ = [
17
+ "HTTPEnvClient",
18
+ "StepResult",
19
+ ]
src/core/build/lib/openenv_core/client_types.py ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Type definitions for EnvTorch
2
+ from dataclasses import dataclass
3
+ from typing import Any, Generic, Optional, TypeVar
4
+
5
+ # Generic type for observations
6
+ ObsT = TypeVar("ObsT") # TypeVar for typehinting in IDEs
7
+
8
+
9
+ @dataclass
10
+ class StepResult(Generic[ObsT]):
11
+ """
12
+ Represents the result of one environment step.
13
+
14
+ Attributes:
15
+ observation: The environment's observation after the action.
16
+ reward: Scalar reward for this step (optional).
17
+ done: Whether the episode is finished.
18
+ """
19
+
20
+ observation: ObsT
21
+ reward: Optional[float] = None
22
+ done: bool = False
src/core/build/lib/openenv_core/containers/__init__.py ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ """Container management for environment servers."""
src/core/build/lib/openenv_core/containers/runtime/__init__.py ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ """Container runtime providers."""
8
+
9
+ from .providers import ContainerProvider, KubernetesProvider, LocalDockerProvider
10
+
11
+ __all__ = [
12
+ "ContainerProvider",
13
+ "LocalDockerProvider",
14
+ "KubernetesProvider",
15
+ ]
src/core/build/lib/openenv_core/containers/runtime/providers.py ADDED
@@ -0,0 +1,293 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ """
8
+ Container provider abstractions for running environment servers.
9
+
10
+ This module provides a pluggable architecture for different container providers
11
+ (local Docker, Kubernetes, cloud providers, etc.) to be used with HTTPEnvClient.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from abc import ABC, abstractmethod
17
+ from typing import Any, Dict, Optional
18
+
19
+
20
+ class ContainerProvider(ABC):
21
+ """
22
+ Abstract base class for container providers.
23
+
24
+ Providers implement this interface to support different container platforms:
25
+ - LocalDockerProvider: Runs containers on local Docker daemon
26
+ - KubernetesProvider: Runs containers in Kubernetes cluster
27
+ - FargateProvider: Runs containers on AWS Fargate
28
+ - CloudRunProvider: Runs containers on Google Cloud Run
29
+
30
+ The provider manages a single container lifecycle and provides the base URL
31
+ for connecting to it.
32
+
33
+ Example:
34
+ >>> provider = LocalDockerProvider()
35
+ >>> base_url = provider.start_container("echo-env:latest")
36
+ >>> print(base_url) # http://localhost:8000
37
+ >>> # Use the environment via base_url
38
+ >>> provider.stop_container()
39
+ """
40
+
41
+ @abstractmethod
42
+ def start_container(
43
+ self,
44
+ image: str,
45
+ port: Optional[int] = None,
46
+ env_vars: Optional[Dict[str, str]] = None,
47
+ **kwargs: Any,
48
+ ) -> str:
49
+ """
50
+ Start a container from the specified image.
51
+
52
+ Args:
53
+ image: Container image name (e.g., "echo-env:latest")
54
+ port: Port to expose (if None, provider chooses)
55
+ env_vars: Environment variables to pass to container
56
+ **kwargs: Provider-specific options
57
+
58
+ Returns:
59
+ Base URL to connect to the container (e.g., "http://localhost:8000")
60
+
61
+ Raises:
62
+ RuntimeError: If container fails to start
63
+ """
64
+ pass
65
+
66
+ @abstractmethod
67
+ def stop_container(self) -> None:
68
+ """
69
+ Stop and remove the running container.
70
+
71
+ This cleans up the container that was started by start_container().
72
+ """
73
+ pass
74
+
75
+ @abstractmethod
76
+ def wait_for_ready(self, base_url: str, timeout_s: float = 30.0) -> None:
77
+ """
78
+ Wait for the container to be ready to accept requests.
79
+
80
+ This typically polls the /health endpoint until it returns 200.
81
+
82
+ Args:
83
+ base_url: Base URL of the container
84
+ timeout_s: Maximum time to wait
85
+
86
+ Raises:
87
+ TimeoutError: If container doesn't become ready in time
88
+ """
89
+ pass
90
+
91
+
92
+ class LocalDockerProvider(ContainerProvider):
93
+ """
94
+ Container provider for local Docker daemon.
95
+
96
+ This provider runs containers on the local machine using Docker.
97
+ Useful for development and testing.
98
+
99
+ Example:
100
+ >>> provider = LocalDockerProvider()
101
+ >>> base_url = provider.start_container("echo-env:latest")
102
+ >>> # Container running on http://localhost:<random-port>
103
+ >>> provider.stop_container()
104
+ """
105
+
106
+ def __init__(self):
107
+ """Initialize the local Docker provider."""
108
+ self._container_id: Optional[str] = None
109
+ self._container_name: Optional[str] = None
110
+
111
+ # Check if Docker is available
112
+ import subprocess
113
+
114
+ try:
115
+ subprocess.run(
116
+ ["docker", "version"],
117
+ check=True,
118
+ capture_output=True,
119
+ timeout=5,
120
+ )
121
+ except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired):
122
+ raise RuntimeError(
123
+ "Docker is not available. Please install Docker Desktop or Docker Engine."
124
+ )
125
+
126
+ def start_container(
127
+ self,
128
+ image: str,
129
+ port: Optional[int] = None,
130
+ env_vars: Optional[Dict[str, str]] = None,
131
+ **kwargs: Any,
132
+ ) -> str:
133
+ """
134
+ Start a Docker container locally.
135
+
136
+ Args:
137
+ image: Docker image name
138
+ port: Port to expose (if None, finds available port)
139
+ env_vars: Environment variables for the container
140
+ **kwargs: Additional Docker run options
141
+
142
+ Returns:
143
+ Base URL to connect to the container
144
+ """
145
+ import subprocess
146
+ import time
147
+
148
+ # Find available port if not specified
149
+ if port is None:
150
+ port = self._find_available_port()
151
+
152
+ # Generate container name
153
+ self._container_name = self._generate_container_name(image)
154
+
155
+ # Build docker run command
156
+ cmd = [
157
+ "docker", "run",
158
+ "-d", # Detached
159
+ "--name", self._container_name,
160
+ "-p", f"{port}:8000", # Map port
161
+ ]
162
+
163
+ # Add environment variables
164
+ if env_vars:
165
+ for key, value in env_vars.items():
166
+ cmd.extend(["-e", f"{key}={value}"])
167
+
168
+ # Add image
169
+ cmd.append(image)
170
+
171
+ # Run container
172
+ try:
173
+ result = subprocess.run(cmd, capture_output=True, text=True, check=True)
174
+ self._container_id = result.stdout.strip()
175
+ except subprocess.CalledProcessError as e:
176
+ error_msg = f"Failed to start Docker container.\nCommand: {' '.join(cmd)}\nExit code: {e.returncode}\nStderr: {e.stderr}\nStdout: {e.stdout}"
177
+ raise RuntimeError(error_msg) from e
178
+
179
+ # Wait a moment for container to start
180
+ time.sleep(1)
181
+
182
+ base_url = f"http://localhost:{port}"
183
+ return base_url
184
+
185
+ def stop_container(self) -> None:
186
+ """
187
+ Stop and remove the Docker container.
188
+ """
189
+ if self._container_id is None:
190
+ return
191
+
192
+ import subprocess
193
+
194
+ try:
195
+ # Stop container
196
+ subprocess.run(
197
+ ["docker", "stop", self._container_id],
198
+ capture_output=True,
199
+ check=True,
200
+ timeout=10,
201
+ )
202
+
203
+ # Remove container
204
+ subprocess.run(
205
+ ["docker", "rm", self._container_id],
206
+ capture_output=True,
207
+ check=True,
208
+ timeout=10,
209
+ )
210
+ except subprocess.CalledProcessError:
211
+ # Container might already be stopped/removed
212
+ pass
213
+ finally:
214
+ self._container_id = None
215
+ self._container_name = None
216
+
217
+ def wait_for_ready(self, base_url: str, timeout_s: float = 30.0) -> None:
218
+ """
219
+ Wait for container to be ready by polling /health endpoint.
220
+
221
+ Args:
222
+ base_url: Base URL of the container
223
+ timeout_s: Maximum time to wait
224
+
225
+ Raises:
226
+ TimeoutError: If container doesn't become ready
227
+ """
228
+ import time
229
+ import requests
230
+
231
+ start_time = time.time()
232
+ health_url = f"{base_url}/health"
233
+
234
+ while time.time() - start_time < timeout_s:
235
+ try:
236
+ response = requests.get(health_url, timeout=2.0)
237
+ if response.status_code == 200:
238
+ return
239
+ except requests.RequestException:
240
+ pass
241
+
242
+ time.sleep(0.5)
243
+
244
+ raise TimeoutError(
245
+ f"Container at {base_url} did not become ready within {timeout_s}s"
246
+ )
247
+
248
+ def _find_available_port(self) -> int:
249
+ """
250
+ Find an available port on localhost.
251
+
252
+ Returns:
253
+ An available port number
254
+ """
255
+ import socket
256
+
257
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
258
+ s.bind(("", 0))
259
+ s.listen(1)
260
+ port = s.getsockname()[1]
261
+ return port
262
+
263
+ def _generate_container_name(self, image: str) -> str:
264
+ """
265
+ Generate a unique container name based on image name and timestamp.
266
+
267
+ Args:
268
+ image: Docker image name
269
+
270
+ Returns:
271
+ A unique container name
272
+ """
273
+ import time
274
+
275
+ clean_image = image.split("/")[-1].split(":")[0]
276
+ timestamp = int(time.time() * 1000)
277
+ return f"{clean_image}-{timestamp}"
278
+
279
+
280
+ class KubernetesProvider(ContainerProvider):
281
+ """
282
+ Container provider for Kubernetes clusters.
283
+
284
+ This provider creates pods in a Kubernetes cluster and exposes them
285
+ via services or port-forwarding.
286
+
287
+ Example:
288
+ >>> provider = KubernetesProvider(namespace="envtorch-dev")
289
+ >>> base_url = provider.start_container("echo-env:latest")
290
+ >>> # Pod running in k8s, accessible via service or port-forward
291
+ >>> provider.stop_container()
292
+ """
293
+ pass
src/core/build/lib/openenv_core/containers/test_local_docker_provider.py ADDED
@@ -0,0 +1,258 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ End-to-end test for LocalDockerProvider.
4
+
5
+ This script tests the complete flow:
6
+ 1. Start a container using LocalDockerProvider
7
+ 2. Wait for it to be ready
8
+ 3. Make HTTP requests to test the environment
9
+ 4. Clean up the container
10
+ """
11
+
12
+ import sys
13
+ from pathlib import Path
14
+
15
+ # Add src to path
16
+ sys.path.insert(0, str(Path(__file__).parent.parent.parent))
17
+
18
+ import requests
19
+
20
+ from core.containers.runtime import LocalDockerProvider
21
+
22
+ # TODO: Remove this test or make it a functional test sicne this will be tested in e2e test for echo env
23
+ def test_local_docker_provider():
24
+ """Test LocalDockerProvider end-to-end."""
25
+ print("=" * 60)
26
+ print("LocalDockerProvider End-to-End Test")
27
+ print("=" * 60)
28
+ print()
29
+
30
+ provider = None
31
+
32
+ try:
33
+ # Step 1: Create provider
34
+ print("Step 1: Creating LocalDockerProvider...")
35
+ provider = LocalDockerProvider()
36
+ print("✓ Provider created\n")
37
+
38
+ # Step 2: Start container
39
+ print("Step 2: Starting echo-env container...")
40
+ base_url = provider.start_container("echo-env:latest")
41
+ print(f"✓ Container started at: {base_url}")
42
+ if provider._container_id:
43
+ print(f" Container ID: {provider._container_id[:12]}...")
44
+ if provider._container_name:
45
+ print(f" Container name: {provider._container_name}\n")
46
+
47
+ # Step 3: Wait for ready
48
+ print("Step 3: Waiting for container to be ready...")
49
+ provider.wait_for_ready(base_url, timeout_s=30.0)
50
+ print("✓ Container is ready!\n")
51
+
52
+ # Step 4: Test health endpoint
53
+ print("Step 4: Testing /health endpoint...")
54
+ response = requests.get(f"{base_url}/health")
55
+ print(f" Status: {response.status_code}")
56
+ print(f" Response: {response.json()}")
57
+ assert response.status_code == 200
58
+ assert response.json()["status"] == "healthy"
59
+ print("✓ Health check passed\n")
60
+
61
+ # Step 5: Test reset endpoint
62
+ print("Step 5: Testing /reset endpoint...")
63
+ response = requests.post(
64
+ f"{base_url}/reset",
65
+ json={},
66
+ headers={"Content-Type": "application/json"},
67
+ )
68
+ print(f" Status: {response.status_code}")
69
+ data = response.json()
70
+ print(f" Message: {data['observation']['echoed_message']}")
71
+ print(f" Reward: {data['reward']}")
72
+ print(f" Done: {data['done']}")
73
+ assert response.status_code == 200
74
+ assert data["observation"]["echoed_message"] == "Echo environment ready!"
75
+ print("✓ Reset test passed\n")
76
+
77
+ # Step 6: Test step endpoint
78
+ print("Step 6: Testing /step endpoint...")
79
+ response = requests.post(
80
+ f"{base_url}/step",
81
+ json={"action": {"message": "Hello from LocalDockerProvider!"}},
82
+ headers={"Content-Type": "application/json"},
83
+ )
84
+ print(f" Status: {response.status_code}")
85
+ data = response.json()
86
+ print(f" Echoed: {data['observation']['echoed_message']}")
87
+ print(f" Length: {data['observation']['message_length']}")
88
+ print(f" Reward: {data['reward']}")
89
+ assert response.status_code == 200
90
+ assert data["observation"]["echoed_message"] == "Hello from LocalDockerProvider!"
91
+ assert data["observation"]["message_length"] == 31
92
+ print("✓ Step test passed\n")
93
+
94
+ # Step 7: Test state endpoint
95
+ print("Step 7: Testing /state endpoint...")
96
+ response = requests.get(f"{base_url}/state")
97
+ print(f" Status: {response.status_code}")
98
+ data = response.json()
99
+ print(f" Episode ID: {data['episode_id']}")
100
+ print(f" Step count: {data['step_count']}")
101
+ assert response.status_code == 200
102
+ assert data["step_count"] == 1 # One step from above
103
+ print("✓ State test passed\n")
104
+
105
+ # Step 8: Multiple steps
106
+ print("Step 8: Testing multiple steps...")
107
+ for i in range(3):
108
+ response = requests.post(
109
+ f"{base_url}/step",
110
+ json={"action": {"message": f"Message {i+1}"}},
111
+ headers={"Content-Type": "application/json"},
112
+ )
113
+ assert response.status_code == 200
114
+ print(f" Step {i+1}: ✓")
115
+
116
+ # Check state updated
117
+ response = requests.get(f"{base_url}/state")
118
+ data = response.json()
119
+ assert data["step_count"] == 4 # 1 + 3 more steps
120
+ print(f" Final step count: {data['step_count']}")
121
+ print("✓ Multiple steps test passed\n")
122
+
123
+ print("=" * 60)
124
+ print("✓ All tests passed!")
125
+ print("=" * 60)
126
+ print()
127
+
128
+ return True
129
+
130
+ except Exception as e:
131
+ print(f"\n❌ Test failed: {e}")
132
+ import traceback
133
+ traceback.print_exc()
134
+ return False
135
+
136
+ finally:
137
+ # Step 9: Cleanup
138
+ if provider is not None:
139
+ print("\nStep 9: Cleaning up container...")
140
+ try:
141
+ provider.stop_container()
142
+ print("✓ Container stopped and removed\n")
143
+ except Exception as e:
144
+ print(f"⚠️ Cleanup warning: {e}\n")
145
+
146
+
147
+ def test_provider_with_custom_port():
148
+ """Test provider with custom port."""
149
+ print("=" * 60)
150
+ print("LocalDockerProvider with Custom Port Test")
151
+ print("=" * 60)
152
+ print()
153
+
154
+ provider = None
155
+
156
+ try:
157
+ provider = LocalDockerProvider()
158
+
159
+ print("Starting container on custom port 8123...")
160
+ base_url = provider.start_container("echo-env:latest", port=8123)
161
+ print(f"✓ Started at: {base_url}")
162
+ assert ":8123" in base_url
163
+
164
+ print("Waiting for ready...")
165
+ provider.wait_for_ready(base_url)
166
+ print("✓ Ready!")
167
+
168
+ print("Testing health...")
169
+ response = requests.get(f"{base_url}/health")
170
+ assert response.status_code == 200
171
+ print("✓ Health check passed")
172
+
173
+ print("\n✓ Custom port test passed!\n")
174
+ return True
175
+
176
+ except Exception as e:
177
+ print(f"\n❌ Test failed: {e}")
178
+ return False
179
+
180
+ finally:
181
+ if provider is not None:
182
+ provider.stop_container()
183
+ print("✓ Cleaned up\n")
184
+
185
+
186
+ def test_provider_with_env_vars():
187
+ """Test provider with environment variables."""
188
+ print("=" * 60)
189
+ print("LocalDockerProvider with Environment Variables Test")
190
+ print("=" * 60)
191
+ print()
192
+
193
+ provider = None
194
+
195
+ try:
196
+ provider = LocalDockerProvider()
197
+
198
+ print("Starting container with environment variables...")
199
+ base_url = provider.start_container(
200
+ "echo-env:latest",
201
+ env_vars={"DEBUG": "true", "LOG_LEVEL": "info"}
202
+ )
203
+ print(f"✓ Started at: {base_url}")
204
+
205
+ print("Waiting for ready...")
206
+ provider.wait_for_ready(base_url)
207
+ print("✓ Ready!")
208
+
209
+ print("Testing health...")
210
+ response = requests.get(f"{base_url}/health")
211
+ assert response.status_code == 200
212
+ print("✓ Health check passed")
213
+
214
+ print("\n✓ Environment variables test passed!\n")
215
+ return True
216
+
217
+ except Exception as e:
218
+ print(f"\n❌ Test failed: {e}")
219
+ return False
220
+
221
+ finally:
222
+ if provider is not None:
223
+ provider.stop_container()
224
+ print("✓ Cleaned up\n")
225
+
226
+
227
+ if __name__ == "__main__":
228
+ print()
229
+ print("🐳 LocalDockerProvider Test Suite")
230
+ print()
231
+
232
+ results = []
233
+
234
+ # Run basic test
235
+ results.append(("Basic End-to-End", test_local_docker_provider()))
236
+
237
+ # Run custom port test
238
+ results.append(("Custom Port", test_provider_with_custom_port()))
239
+
240
+ # Run environment variables test
241
+ results.append(("Environment Variables", test_provider_with_env_vars()))
242
+
243
+ # Summary
244
+ print("=" * 60)
245
+ print("Test Summary")
246
+ print("=" * 60)
247
+ for name, passed in results:
248
+ status = "✓ PASSED" if passed else "✗ FAILED"
249
+ print(f"{name:25} {status}")
250
+ print("=" * 60)
251
+
252
+ all_passed = all(result for _, result in results)
253
+ if all_passed:
254
+ print("\n🎉 All tests passed!")
255
+ exit(0)
256
+ else:
257
+ print("\n❌ Some tests failed")
258
+ exit(1)
src/core/build/lib/openenv_core/env_server/__init__.py ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ """Core environment interfaces and types."""
8
+
9
+ from .base_transforms import CompositeTransform, NullTransform
10
+ from .http_server import HTTPEnvServer, create_app, create_fastapi_app
11
+ from .interfaces import Environment, Message, ModelTokenizer, Transform
12
+ from .types import Action, Observation, State
13
+ from .web_interface import create_web_interface_app, WebInterfaceManager
14
+
15
+ __all__ = [
16
+ # Core interfaces
17
+ "Environment",
18
+ "Transform",
19
+ "Message",
20
+ "ModelTokenizer",
21
+ # Types
22
+ "Action",
23
+ "Observation",
24
+ "State",
25
+ # Base transforms
26
+ "CompositeTransform",
27
+ "NullTransform",
28
+ # HTTP Server
29
+ "HTTPEnvServer",
30
+ "create_app",
31
+ "create_fastapi_app",
32
+ # Web Interface
33
+ "create_web_interface_app",
34
+ "WebInterfaceManager",
35
+ ]
src/core/build/lib/openenv_core/env_server/base_transforms.py ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ """Base transform implementations for composing environment-specific transforms."""
8
+
9
+ from .interfaces import Transform
10
+ from .types import Observation
11
+
12
+
13
+ class CompositeTransform(Transform):
14
+ """Combines multiple transforms into a single transform."""
15
+
16
+ def __init__(self, transforms: list[Transform]):
17
+ self.transforms = transforms
18
+
19
+ def __call__(self, observation: Observation) -> Observation:
20
+ for transform in self.transforms:
21
+ observation = transform(observation)
22
+ return observation
23
+
24
+
25
+ class NullTransform(Transform):
26
+ """Default transform that passes through unchanged."""
27
+
28
+ def __call__(self, observation: Observation) -> Observation:
29
+ return observation
src/core/build/lib/openenv_core/env_server/http_server.py ADDED
@@ -0,0 +1,233 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ """
8
+ HTTP server wrapper for Environment instances.
9
+
10
+ This module provides utilities to wrap any Environment subclass and expose it
11
+ over HTTP endpoints that HTTPEnvClient can consume.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import os
17
+ from dataclasses import asdict
18
+ from typing import Any, Dict, Type
19
+
20
+ from .interfaces import Environment
21
+ from .types import Action, Observation
22
+ from fastapi import Body, FastAPI
23
+
24
+ class HTTPEnvServer:
25
+ """
26
+ HTTP server wrapper for Environment instances.
27
+
28
+ This class wraps an Environment and exposes its reset(), step(), and state
29
+ methods as HTTP endpoints compatible with HTTPEnvClient.
30
+
31
+ The server expects:
32
+ - Action deserialization: Converts JSON dict to Action subclass
33
+ - Observation serialization: Converts Observation subclass to JSON dict
34
+
35
+ Example:
36
+ >>> from core.env_server import HTTPEnvServer
37
+ >>> from envs.coding_env.server import CodeExecutionEnvironment
38
+ >>>
39
+ >>> env = CodeExecutionEnvironment()
40
+ >>> server = HTTPEnvServer(env)
41
+ >>>
42
+ >>> # Register routes with FastAPI
43
+ >>> from fastapi import FastAPI
44
+ >>> app = FastAPI()
45
+ >>> server.register_routes(app)
46
+ """
47
+
48
+ def __init__(
49
+ self,
50
+ env: Environment,
51
+ action_cls: Type[Action],
52
+ observation_cls: Type[Observation],
53
+ ):
54
+ """
55
+ Initialize HTTP server wrapper.
56
+
57
+ Args:
58
+ env: The Environment instance to wrap
59
+ action_cls: The Action subclass this environment expects
60
+ observation_cls: The Observation subclass this environment returns
61
+ """
62
+ self.env = env
63
+ self.action_cls = action_cls
64
+ self.observation_cls = observation_cls
65
+
66
+ def register_routes(self, app: Any) -> None:
67
+ """
68
+ Register HTTP routes on a FastAPI application.
69
+
70
+ Args:
71
+ app: FastAPI application instance
72
+ """
73
+
74
+ if not isinstance(app, FastAPI):
75
+ raise TypeError("app must be a FastAPI instance")
76
+
77
+ @app.post("/reset")
78
+ async def reset(request: Dict[str, Any] = Body(default={})) -> Dict[str, Any]:
79
+ """Reset endpoint - returns initial observation."""
80
+ # TODO: Handle seed, episode_id from request if provided
81
+ observation = self.env.reset()
82
+ return self._serialize_observation(observation)
83
+
84
+ @app.post("/step")
85
+ async def step(request: Dict[str, Any]) -> Dict[str, Any]:
86
+ """Step endpoint - executes action and returns observation."""
87
+ action_data = request.get("action", {})
88
+ # TODO: Handle timeout_s, request_id, episode_id from request if provided
89
+
90
+ # Deserialize action
91
+ action = self._deserialize_action(action_data)
92
+
93
+ # Execute step
94
+ observation = self.env.step(action)
95
+
96
+ # Return serialized observation
97
+ return self._serialize_observation(observation)
98
+
99
+ @app.get("/state")
100
+ async def get_state() -> Dict[str, Any]:
101
+ """State endpoint - returns current environment state."""
102
+ state = self.env.state
103
+ return asdict(state)
104
+
105
+ @app.get("/health")
106
+ async def health() -> Dict[str, str]:
107
+ """Health check endpoint."""
108
+ return {"status": "healthy"}
109
+
110
+
111
+ def _deserialize_action(self, action_data: Dict[str, Any]) -> Action:
112
+ """
113
+ Convert JSON dict to Action instance.
114
+
115
+ Args:
116
+ action_data: Dictionary containing action data
117
+
118
+ Returns:
119
+ Action instance
120
+
121
+ Note:
122
+ This is a simple implementation. Subclasses may need to override
123
+ for more complex deserialization logic.
124
+ """
125
+ # Remove metadata if present (it will be set via kw_only field)
126
+ metadata = action_data.pop("metadata", {})
127
+ action = self.action_cls(**action_data)
128
+ action.metadata = metadata
129
+ return action
130
+
131
+ def _serialize_observation(self, observation: Observation) -> Dict[str, Any]:
132
+ """
133
+ Convert Observation instance to JSON-compatible dict.
134
+
135
+ Args:
136
+ observation: Observation instance
137
+
138
+ Returns:
139
+ Dictionary compatible with HTTPEnvClient._parse_result()
140
+
141
+ The format matches what HTTPEnvClient expects:
142
+ {
143
+ "observation": {...}, # Observation fields
144
+ "reward": float | None,
145
+ "done": bool,
146
+ }
147
+ """
148
+ obs_dict = asdict(observation)
149
+
150
+ # Extract reward and done (these are part of StepResult on client side)
151
+ reward = obs_dict.pop("reward", None)
152
+ done = obs_dict.pop("done", False)
153
+ obs_dict.pop("metadata", None) # Remove metadata from observation
154
+
155
+ # Return in HTTPEnvClient expected format
156
+ return {
157
+ "observation": obs_dict,
158
+ "reward": reward,
159
+ "done": done,
160
+ }
161
+
162
+ def create_app(
163
+ env: Environment,
164
+ action_cls: Type[Action],
165
+ observation_cls: Type[Observation],
166
+ env_name: Optional[str] = None,
167
+ ) -> Any:
168
+ """
169
+ Create a FastAPI application with or without web interface.
170
+
171
+ This function creates a FastAPI app with the web interface enabled by default,
172
+ including README integration for better user experience.
173
+
174
+ Args:
175
+ env: The Environment instance to serve
176
+ action_cls: The Action subclass this environment expects
177
+ observation_cls: The Observation subclass this environment returns
178
+ env_name: Optional environment name for README loading
179
+
180
+ Returns:
181
+ FastAPI application instance with or without web interface and README integration
182
+ """
183
+ # Check if web interface should be enabled
184
+ # This can be controlled via environment variable or build argument
185
+ enable_web = (
186
+ os.getenv("ENABLE_WEB_INTERFACE", "false").lower() in ("true", "1", "yes")
187
+ )
188
+
189
+ if enable_web:
190
+ # Import web interface only when needed
191
+ from .web_interface import create_web_interface_app
192
+ return create_web_interface_app(env, action_cls, observation_cls, env_name)
193
+ else:
194
+ # Use standard FastAPI app without web interface
195
+ return create_fastapi_app(env, action_cls, observation_cls)
196
+
197
+
198
+ def create_fastapi_app(
199
+ env: Environment,
200
+ action_cls: Type[Action],
201
+ observation_cls: Type[Observation],
202
+ ) -> Any:
203
+ """
204
+ Create a FastAPI application with routes for the given environment.
205
+
206
+ Args:
207
+ env: The Environment instance to serve
208
+ action_cls: The Action subclass this environment expects
209
+ observation_cls: The Observation subclass this environment returns
210
+
211
+ Returns:
212
+ FastAPI application instance with routes registered
213
+
214
+ Example:
215
+ >>> from envs.coding_env.server import CodeExecutionEnvironment
216
+ >>> from envs.coding_env.models import CodeAction, CodeObservation
217
+ >>>
218
+ >>> env = CodeExecutionEnvironment()
219
+ >>> app = create_fastapi_app(env, CodeAction, CodeObservation)
220
+ >>>
221
+ >>> # Run with: uvicorn module:app --host 0.0.0.0 --port 8000
222
+ """
223
+ try:
224
+ from fastapi import FastAPI
225
+ except ImportError:
226
+ raise ImportError(
227
+ "FastAPI is required. Install with: pip install fastapi uvicorn"
228
+ )
229
+
230
+ app = FastAPI(title="Environment HTTP Server")
231
+ server = HTTPEnvServer(env, action_cls, observation_cls)
232
+ server.register_routes(app)
233
+ return app
src/core/build/lib/openenv_core/env_server/interfaces.py ADDED
@@ -0,0 +1,118 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ from abc import ABC, abstractmethod
8
+ from typing import Any, Protocol, TypedDict
9
+
10
+ from .types import Action, Observation, State
11
+
12
+
13
+ class Message(TypedDict):
14
+ """A message in a conversation.
15
+
16
+ Compatible with Huggingface chat template format.
17
+ """
18
+
19
+ role: str
20
+ content: str
21
+
22
+
23
+ class ModelTokenizer(Protocol):
24
+ """Protocol for tokenizers that support chat templates.
25
+
26
+ This protocol defines the interface that tokenizers must implement
27
+ to work with chat-based environments. It's compatible with
28
+ Huggingface transformers tokenizers.
29
+ """
30
+
31
+ def apply_chat_template(
32
+ self,
33
+ conversation: list[Message],
34
+ tokenize: bool = True,
35
+ return_tensors: str | None = None,
36
+ **kwargs: Any,
37
+ ) -> Any:
38
+ """Apply a chat template to format and optionally tokenize a conversation.
39
+
40
+ Args:
41
+ conversation: List of message dictionaries with 'role' and 'content'
42
+ tokenize: Whether to tokenize the output
43
+ return_tensors: Format for returned tensors ('pt' for PyTorch)
44
+ **kwargs: Additional arguments
45
+
46
+ Returns:
47
+ Formatted and optionally tokenized conversation
48
+ """
49
+ ...
50
+
51
+ def decode(
52
+ self, token_ids: Any, skip_special_tokens: bool = False, **kwargs: Any
53
+ ) -> str:
54
+ """Decode token IDs back to text.
55
+
56
+ Args:
57
+ token_ids: Token IDs to decode
58
+ skip_special_tokens: Whether to skip special tokens in output
59
+ **kwargs: Additional arguments
60
+
61
+ Returns:
62
+ Decoded text string
63
+ """
64
+ ...
65
+
66
+
67
+ class Transform(ABC):
68
+ """Transform observations to add rewards, metrics, or other modifications.
69
+
70
+ Transforms follow the TorchRL pattern where they take an observation
71
+ and return a (potentially modified) observation. This allows for
72
+ flexible reward computation and observation augmentation.
73
+ """
74
+
75
+ @abstractmethod
76
+ def __call__(self, observation: Observation) -> Observation:
77
+ """Transform an observation.
78
+
79
+ Args:
80
+ observation: The input observation
81
+
82
+ Returns:
83
+ The transformed observation
84
+ """
85
+ pass
86
+
87
+
88
+ class Environment(ABC):
89
+ """Base class for all environment servers following Gym/Gymnasium API.
90
+
91
+ Args:
92
+ transform: Optional transform to apply to observations
93
+ """
94
+
95
+ def __init__(self, transform: Transform | None = None):
96
+ self.transform = transform
97
+
98
+ @abstractmethod
99
+ def reset(self) -> Observation:
100
+ """Reset the environment and return initial observation."""
101
+ pass
102
+
103
+ @abstractmethod
104
+ def step(self, action: Action) -> Observation:
105
+ """Take a step in the environment."""
106
+ pass
107
+
108
+ @property
109
+ @abstractmethod
110
+ def state(self) -> State:
111
+ """Get the current environment state."""
112
+ pass
113
+
114
+ def _apply_transform(self, observation: Observation) -> Observation:
115
+ """Apply transform if one is provided."""
116
+ if self.transform is not None:
117
+ return self.transform(observation)
118
+ return observation
src/core/build/lib/openenv_core/env_server/types.py ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ from dataclasses import dataclass, field
8
+ from typing import Any, Dict, List, Optional, Union
9
+
10
+
11
+ # Type aliases
12
+ Scalar = Union[int, float, bool]
13
+
14
+
15
+ @dataclass(kw_only=True)
16
+ class Action:
17
+ """Base class for all environment actions."""
18
+
19
+ metadata: Dict[str, Any] = field(default_factory=dict)
20
+
21
+
22
+ @dataclass(kw_only=True)
23
+ class Observation:
24
+ """Base class for all environment observations."""
25
+
26
+ done: bool = False
27
+ reward: Union[bool, int, float, None] = None
28
+ metadata: Dict[str, Any] = field(default_factory=dict)
29
+
30
+
31
+ @dataclass
32
+ class State:
33
+ """Base class for environment state."""
34
+
35
+ episode_id: Optional[str] = None
36
+ step_count: int = 0
37
+
38
+
39
+ @dataclass
40
+ class CodeExecResult:
41
+ """Result of code execution containing stdout, stderr, and exit code."""
42
+
43
+ stdout: str
44
+ stderr: str
45
+ exit_code: int
46
+
47
+
48
+ @dataclass
49
+ class EnvironmentMetadata:
50
+ """Metadata about an environment for documentation and UI purposes."""
51
+
52
+ name: str
53
+ description: str
54
+ readme_content: Optional[str] = None
55
+ version: Optional[str] = None
56
+ author: Optional[str] = None
57
+ documentation_url: Optional[str] = None
src/core/build/lib/openenv_core/env_server/web_interface.py ADDED
@@ -0,0 +1,1613 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ """
8
+ Web interface for OpenEnv environments.
9
+
10
+ This module provides a web-based interface for interacting with OpenEnv environments,
11
+ including a two-pane layout for HumanAgent interaction and state observation.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import time
18
+ from dataclasses import asdict, dataclass
19
+ from typing import Any, Dict, List, Optional, Type
20
+ from datetime import datetime
21
+
22
+ from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Request
23
+ from fastapi.responses import HTMLResponse, FileResponse
24
+ from fastapi.staticfiles import StaticFiles
25
+ from pydantic import BaseModel
26
+
27
+ from .interfaces import Environment
28
+ from .types import Action, Observation, State, EnvironmentMetadata
29
+
30
+
31
+ def load_environment_metadata(env: Environment, env_name: Optional[str] = None) -> EnvironmentMetadata:
32
+ """
33
+ Load environment metadata including README content.
34
+
35
+ Args:
36
+ env: The environment instance
37
+ env_name: Optional environment name for README file lookup
38
+
39
+ Returns:
40
+ EnvironmentMetadata with loaded information
41
+ """
42
+ # Try to get metadata from environment if it has a method for it
43
+ if hasattr(env, 'get_metadata'):
44
+ return env.get_metadata()
45
+
46
+ # Default metadata
47
+ metadata = EnvironmentMetadata(
48
+ name=env_name or env.__class__.__name__,
49
+ description=f"{env.__class__.__name__} environment",
50
+ version="1.0.0"
51
+ )
52
+
53
+ # Try to load README from file system
54
+ readme_content = _load_readme_from_filesystem(env_name)
55
+ if readme_content:
56
+ metadata.readme_content = readme_content
57
+
58
+ return metadata
59
+
60
+
61
+ def _load_readme_from_filesystem(env_name: Optional[str]) -> Optional[str]:
62
+ """
63
+ Load README content from the filesystem.
64
+
65
+ Tries multiple locations:
66
+ 1. Container filesystem: /app/README.md
67
+ 2. Local development: src/envs/{env_name}/README.md
68
+ 3. Environment variable: ENV_README_PATH
69
+ """
70
+ import os
71
+ from pathlib import Path
72
+
73
+ # Try container filesystem first
74
+ container_readme = Path("/app/README.md")
75
+ if container_readme.exists():
76
+ try:
77
+ return container_readme.read_text(encoding='utf-8')
78
+ except Exception:
79
+ pass
80
+
81
+ # Try environment variable path
82
+ custom_path = os.environ.get("ENV_README_PATH")
83
+ if custom_path and Path(custom_path).exists():
84
+ try:
85
+ return Path(custom_path).read_text(encoding='utf-8')
86
+ except Exception:
87
+ pass
88
+
89
+ # Try local development path
90
+ if env_name:
91
+ local_readme = Path(f"src/envs/{env_name}/README.md")
92
+ if local_readme.exists():
93
+ try:
94
+ return local_readme.read_text(encoding='utf-8')
95
+ except Exception:
96
+ pass
97
+
98
+ return None
99
+
100
+
101
+ @dataclass
102
+ class ActionLog:
103
+ """Log entry for an action taken."""
104
+ timestamp: str
105
+ action: Dict[str, Any]
106
+ observation: Dict[str, Any]
107
+ reward: Optional[float]
108
+ done: bool
109
+ step_count: int
110
+
111
+
112
+ @dataclass
113
+ class EpisodeState:
114
+ """Current episode state for the web interface."""
115
+ episode_id: Optional[str]
116
+ step_count: int
117
+ current_observation: Optional[Dict[str, Any]]
118
+ action_logs: List[ActionLog]
119
+ is_reset: bool = True
120
+
121
+
122
+ class WebInterfaceManager:
123
+ """Manages the web interface for an environment."""
124
+
125
+ def __init__(
126
+ self,
127
+ env: Environment,
128
+ action_cls: Type[Action],
129
+ observation_cls: Type[Observation],
130
+ metadata: Optional[EnvironmentMetadata] = None,
131
+ ):
132
+ self.env = env
133
+ self.action_cls = action_cls
134
+ self.observation_cls = observation_cls
135
+ self.metadata = metadata or EnvironmentMetadata(
136
+ name=env.__class__.__name__,
137
+ description=f"{env.__class__.__name__} environment"
138
+ )
139
+ self.episode_state = EpisodeState(
140
+ episode_id=None,
141
+ step_count=0,
142
+ current_observation=None,
143
+ action_logs=[]
144
+ )
145
+ self.connected_clients: List[WebSocket] = []
146
+
147
+ async def connect_websocket(self, websocket: WebSocket):
148
+ """Connect a new WebSocket client."""
149
+ await websocket.accept()
150
+ self.connected_clients.append(websocket)
151
+
152
+ # Send current state to the new client
153
+ await self._send_state_update()
154
+
155
+ async def disconnect_websocket(self, websocket: WebSocket):
156
+ """Disconnect a WebSocket client."""
157
+ if websocket in self.connected_clients:
158
+ self.connected_clients.remove(websocket)
159
+
160
+ async def _send_state_update(self):
161
+ """Send current state to all connected clients."""
162
+ if not self.connected_clients:
163
+ return
164
+
165
+ state_data = {
166
+ "type": "state_update",
167
+ "episode_state": asdict(self.episode_state)
168
+ }
169
+
170
+ # Send to all connected clients
171
+ disconnected_clients = []
172
+ for client in self.connected_clients:
173
+ try:
174
+ await client.send_text(json.dumps(state_data))
175
+ except:
176
+ disconnected_clients.append(client)
177
+
178
+ # Remove disconnected clients
179
+ for client in disconnected_clients:
180
+ self.connected_clients.remove(client)
181
+
182
+ async def reset_environment(self) -> Dict[str, Any]:
183
+ """Reset the environment and update state."""
184
+ observation = self.env.reset()
185
+ state = self.env.state
186
+
187
+ # Update episode state
188
+ self.episode_state.episode_id = state.episode_id
189
+ self.episode_state.step_count = 0
190
+ self.episode_state.current_observation = asdict(observation)
191
+ self.episode_state.action_logs = []
192
+ self.episode_state.is_reset = True
193
+
194
+ # Send state update
195
+ await self._send_state_update()
196
+
197
+ return {
198
+ "observation": asdict(observation),
199
+ "reward": observation.reward,
200
+ "done": observation.done,
201
+ }
202
+
203
+ async def step_environment(self, action_data: Dict[str, Any]) -> Dict[str, Any]:
204
+ """Execute a step in the environment and update state."""
205
+ # Deserialize action
206
+ action = self._deserialize_action(action_data)
207
+
208
+ # Execute step
209
+ observation = self.env.step(action)
210
+ state = self.env.state
211
+
212
+ # Create action log
213
+ action_log = ActionLog(
214
+ timestamp=datetime.now().isoformat(),
215
+ action=asdict(action),
216
+ observation=asdict(observation),
217
+ reward=observation.reward,
218
+ done=observation.done,
219
+ step_count=state.step_count
220
+ )
221
+
222
+ # Update episode state
223
+ self.episode_state.episode_id = state.episode_id
224
+ self.episode_state.step_count = state.step_count
225
+ self.episode_state.current_observation = asdict(observation)
226
+ self.episode_state.action_logs.append(action_log)
227
+ self.episode_state.is_reset = False
228
+
229
+ # Send state update
230
+ await self._send_state_update()
231
+
232
+ return {
233
+ "observation": asdict(observation),
234
+ "reward": observation.reward,
235
+ "done": observation.done,
236
+ }
237
+
238
+ def get_state(self) -> Dict[str, Any]:
239
+ """Get current environment state."""
240
+ state = self.env.state
241
+ return asdict(state)
242
+
243
+ def _deserialize_action(self, action_data: Dict[str, Any]) -> Action:
244
+ """Convert JSON dict to Action instance."""
245
+ metadata = action_data.pop("metadata", {})
246
+
247
+ # Handle tensor fields that come from JSON as lists
248
+ processed_data = {}
249
+ for key, value in action_data.items():
250
+ if key == "tokens" and isinstance(value, (list, str)):
251
+ # Convert list or string to tensor
252
+ if isinstance(value, str):
253
+ # If it's a string, try to parse it as a list of numbers
254
+ try:
255
+ import json
256
+ value = json.loads(value)
257
+ except:
258
+ # If parsing fails, treat as empty list
259
+ value = []
260
+ if isinstance(value, list):
261
+ import torch
262
+ processed_data[key] = torch.tensor(value, dtype=torch.long)
263
+ else:
264
+ processed_data[key] = value
265
+ elif key == "action_id" and isinstance(value, str):
266
+ # Convert action_id from string to int
267
+ try:
268
+ processed_data[key] = int(value)
269
+ except ValueError:
270
+ # If conversion fails, keep original value
271
+ processed_data[key] = value
272
+ else:
273
+ processed_data[key] = value
274
+
275
+ action = self.action_cls(**processed_data)
276
+ action.metadata = metadata
277
+ return action
278
+
279
+
280
+ def create_web_interface_app(
281
+ env: Environment,
282
+ action_cls: Type[Action],
283
+ observation_cls: Type[Observation],
284
+ env_name: Optional[str] = None,
285
+ ) -> FastAPI:
286
+ """
287
+ Create a FastAPI application with web interface for the given environment.
288
+
289
+ Args:
290
+ env: The Environment instance to serve
291
+ action_cls: The Action subclass this environment expects
292
+ observation_cls: The Observation subclass this environment returns
293
+ env_name: Optional environment name for README loading
294
+
295
+ Returns:
296
+ FastAPI application instance with web interface
297
+ """
298
+ from .http_server import create_fastapi_app
299
+
300
+ # Create the base environment app
301
+ app = create_fastapi_app(env, action_cls, observation_cls)
302
+
303
+ # Load environment metadata
304
+ metadata = load_environment_metadata(env, env_name)
305
+
306
+ # Create web interface manager
307
+ web_manager = WebInterfaceManager(env, action_cls, observation_cls, metadata)
308
+
309
+ # Add web interface routes
310
+ @app.get("/web", response_class=HTMLResponse)
311
+ async def web_interface():
312
+ """Serve the web interface."""
313
+ return get_web_interface_html(action_cls, web_manager.metadata)
314
+
315
+ @app.get("/web/metadata")
316
+ async def web_metadata():
317
+ """Get environment metadata."""
318
+ return asdict(web_manager.metadata)
319
+
320
+ @app.websocket("/ws")
321
+ async def websocket_endpoint(websocket: WebSocket):
322
+ """WebSocket endpoint for real-time updates."""
323
+ await web_manager.connect_websocket(websocket)
324
+ try:
325
+ while True:
326
+ # Keep connection alive
327
+ await websocket.receive_text()
328
+ except WebSocketDisconnect:
329
+ await web_manager.disconnect_websocket(websocket)
330
+
331
+ @app.post("/web/reset")
332
+ async def web_reset():
333
+ """Reset endpoint for web interface."""
334
+ return await web_manager.reset_environment()
335
+
336
+ @app.post("/web/step")
337
+ async def web_step(request: Dict[str, Any]):
338
+ """Step endpoint for web interface."""
339
+ # Check if this is a message-based request (chat environment)
340
+ if "message" in request:
341
+ message = request["message"]
342
+ # Convert message to action using the environment's message_to_action method
343
+ action = web_manager.env.message_to_action(message)
344
+ action_data = {"tokens": action.tokens.tolist()}
345
+ else:
346
+ action_data = request.get("action", {})
347
+
348
+ return await web_manager.step_environment(action_data)
349
+
350
+ @app.get("/web/state")
351
+ async def web_state():
352
+ """State endpoint for web interface."""
353
+ return web_manager.get_state()
354
+
355
+ return app
356
+
357
+
358
+ def get_web_interface_html(action_cls: Type[Action], metadata: Optional[EnvironmentMetadata] = None) -> str:
359
+ """Generate the HTML for the web interface."""
360
+
361
+ # Check if this is a chat environment by looking for tokens field
362
+ is_chat_env = False
363
+ if hasattr(action_cls, '__dataclass_fields__'):
364
+ for field_name, field_info in action_cls.__dataclass_fields__.items():
365
+ if field_name == 'tokens' and hasattr(field_info.type, '__name__') and 'Tensor' in field_info.type.__name__:
366
+ is_chat_env = True
367
+ break
368
+
369
+ # Get action fields for dynamic form generation with enhanced metadata
370
+ action_fields = _extract_action_fields(action_cls)
371
+
372
+ return f"""
373
+ <!DOCTYPE html>
374
+ <html lang="en">
375
+ <head>
376
+ <meta charset="UTF-8">
377
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
378
+ <title>OpenEnv Web Interface</title>
379
+ <style>
380
+ * {{
381
+ margin: 0;
382
+ padding: 0;
383
+ box-sizing: border-box;
384
+ }}
385
+
386
+ body {{
387
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
388
+ background-color: #f5f5f5;
389
+ height: 100vh;
390
+ overflow: hidden;
391
+ }}
392
+
393
+ .container {{
394
+ display: flex;
395
+ height: 100vh;
396
+ }}
397
+
398
+ .left-pane {{
399
+ width: 50%;
400
+ background: white;
401
+ border-right: 1px solid #e0e0e0;
402
+ display: flex;
403
+ flex-direction: column;
404
+ }}
405
+
406
+ .right-pane {{
407
+ width: 50%;
408
+ background: #fafafa;
409
+ display: flex;
410
+ flex-direction: column;
411
+ }}
412
+
413
+ .pane-header {{
414
+ padding: 20px;
415
+ border-bottom: 1px solid #e0e0e0;
416
+ background: #f8f9fa;
417
+ font-weight: 600;
418
+ font-size: 16px;
419
+ }}
420
+
421
+ .pane-content {{
422
+ flex: 1;
423
+ padding: 20px;
424
+ overflow-y: auto;
425
+ }}
426
+
427
+ .action-form {{
428
+ background: white;
429
+ border: 1px solid #e0e0e0;
430
+ border-radius: 8px;
431
+ padding: 20px;
432
+ margin-bottom: 20px;
433
+ }}
434
+
435
+ .form-group {{
436
+ margin-bottom: 15px;
437
+ }}
438
+
439
+ .form-group label {{
440
+ display: block;
441
+ margin-bottom: 5px;
442
+ font-weight: 500;
443
+ color: #333;
444
+ }}
445
+
446
+ .form-group input, .form-group textarea {{
447
+ width: 100%;
448
+ padding: 8px 12px;
449
+ border: 1px solid #ddd;
450
+ border-radius: 4px;
451
+ font-size: 14px;
452
+ }}
453
+
454
+ .form-group input:focus, .form-group textarea:focus {{
455
+ outline: none;
456
+ border-color: #007bff;
457
+ box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
458
+ }}
459
+
460
+ .btn {{
461
+ background: #007bff;
462
+ color: white;
463
+ border: none;
464
+ padding: 10px 20px;
465
+ border-radius: 4px;
466
+ cursor: pointer;
467
+ font-size: 14px;
468
+ margin-right: 10px;
469
+ margin-bottom: 10px;
470
+ }}
471
+
472
+ .btn:hover {{
473
+ background: #0056b3;
474
+ }}
475
+
476
+ .btn:disabled {{
477
+ background: #6c757d;
478
+ cursor: not-allowed;
479
+ }}
480
+
481
+ .btn-secondary {{
482
+ background: #6c757d;
483
+ }}
484
+
485
+ .btn-secondary:hover {{
486
+ background: #545b62;
487
+ }}
488
+
489
+ .state-display {{
490
+ background: white;
491
+ border: 1px solid #e0e0e0;
492
+ border-radius: 8px;
493
+ padding: 15px;
494
+ margin-bottom: 20px;
495
+ }}
496
+
497
+ .state-item {{
498
+ margin-bottom: 8px;
499
+ }}
500
+
501
+ .state-label {{
502
+ font-weight: 500;
503
+ color: #666;
504
+ }}
505
+
506
+ .state-value {{
507
+ color: #333;
508
+ font-family: monospace;
509
+ }}
510
+
511
+ .logs-container {{
512
+ background: white;
513
+ border: 1px solid #e0e0e0;
514
+ border-radius: 8px;
515
+ padding: 15px;
516
+ max-height: 400px;
517
+ overflow-y: auto;
518
+ }}
519
+
520
+ .log-entry {{
521
+ border-bottom: 1px solid #f0f0f0;
522
+ padding: 10px 0;
523
+ }}
524
+
525
+ .log-entry:last-child {{
526
+ border-bottom: none;
527
+ }}
528
+
529
+ .log-timestamp {{
530
+ font-size: 12px;
531
+ color: #666;
532
+ margin-bottom: 5px;
533
+ }}
534
+
535
+ .log-action {{
536
+ background: #e3f2fd;
537
+ padding: 8px;
538
+ border-radius: 4px;
539
+ margin-bottom: 5px;
540
+ font-family: monospace;
541
+ font-size: 12px;
542
+ }}
543
+
544
+ .log-observation {{
545
+ background: #f3e5f5;
546
+ padding: 8px;
547
+ border-radius: 4px;
548
+ font-family: monospace;
549
+ font-size: 12px;
550
+ }}
551
+
552
+ .log-reward {{
553
+ font-weight: 600;
554
+ color: #28a745;
555
+ }}
556
+
557
+ .log-done {{
558
+ font-weight: 600;
559
+ color: #dc3545;
560
+ }}
561
+
562
+ .status-indicator {{
563
+ display: inline-block;
564
+ width: 8px;
565
+ height: 8px;
566
+ border-radius: 50%;
567
+ margin-right: 8px;
568
+ }}
569
+
570
+ .status-connected {{
571
+ background: #28a745;
572
+ }}
573
+
574
+ .status-disconnected {{
575
+ background: #dc3545;
576
+ }}
577
+
578
+ .json-display {{
579
+ background: #f8f9fa;
580
+ border: 1px solid #e9ecef;
581
+ border-radius: 4px;
582
+ padding: 10px;
583
+ font-family: monospace;
584
+ font-size: 12px;
585
+ white-space: pre-wrap;
586
+ max-height: 200px;
587
+ overflow-y: auto;
588
+ }}
589
+
590
+ /* Chat Interface Styles */
591
+ .chat-interface {{
592
+ background: white;
593
+ border: 1px solid #e0e0e0;
594
+ border-radius: 8px;
595
+ padding: 20px;
596
+ margin-bottom: 20px;
597
+ }}
598
+
599
+ .chat-messages {{
600
+ background: #f8f9fa;
601
+ border: 1px solid #e0e0e0;
602
+ border-radius: 8px;
603
+ padding: 15px;
604
+ margin-bottom: 15px;
605
+ max-height: 400px;
606
+ overflow-y: auto;
607
+ }}
608
+
609
+ .chat-message {{
610
+ margin-bottom: 15px;
611
+ padding: 10px;
612
+ border-radius: 8px;
613
+ }}
614
+
615
+ .chat-message:last-child {{
616
+ margin-bottom: 0;
617
+ }}
618
+
619
+ .chat-message.user {{
620
+ background: #e3f2fd;
621
+ margin-left: 20px;
622
+ }}
623
+
624
+ .chat-message.assistant {{
625
+ background: #f3e5f5;
626
+ margin-right: 20px;
627
+ }}
628
+
629
+ .chat-message.system {{
630
+ background: #e8f5e8;
631
+ font-style: italic;
632
+ }}
633
+
634
+ .message-role {{
635
+ font-weight: 600;
636
+ font-size: 12px;
637
+ color: #666;
638
+ margin-bottom: 5px;
639
+ }}
640
+
641
+ .message-content {{
642
+ font-size: 14px;
643
+ line-height: 1.4;
644
+ }}
645
+
646
+ .chat-input-container {{
647
+ border-top: 1px solid #e0e0e0;
648
+ padding-top: 15px;
649
+ }}
650
+
651
+ .role-selector {{
652
+ margin-bottom: 10px;
653
+ }}
654
+
655
+ .role-selector label {{
656
+ font-weight: 500;
657
+ margin-right: 10px;
658
+ }}
659
+
660
+ .role-selector select {{
661
+ padding: 5px 10px;
662
+ border: 1px solid #ddd;
663
+ border-radius: 4px;
664
+ }}
665
+
666
+ .message-input {{
667
+ display: flex;
668
+ gap: 10px;
669
+ align-items: flex-end;
670
+ }}
671
+
672
+ .message-input textarea {{
673
+ flex: 1;
674
+ padding: 10px;
675
+ border: 1px solid #ddd;
676
+ border-radius: 4px;
677
+ resize: vertical;
678
+ font-family: inherit;
679
+ }}
680
+
681
+ .message-input textarea:focus {{
682
+ outline: none;
683
+ border-color: #007bff;
684
+ box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
685
+ }}
686
+
687
+ /* Instructions Section Styles */
688
+ .instructions-section {{
689
+ background: white;
690
+ border: 1px solid #e0e0e0;
691
+ border-radius: 8px;
692
+ padding: 20px;
693
+ margin-bottom: 20px;
694
+ }}
695
+
696
+ .instructions-header {{
697
+ display: flex;
698
+ justify-content: space-between;
699
+ align-items: center;
700
+ margin-bottom: 15px;
701
+ }}
702
+
703
+ .instructions-title {{
704
+ font-size: 18px;
705
+ font-weight: 600;
706
+ color: #333;
707
+ margin: 0;
708
+ }}
709
+
710
+ .instructions-toggle {{
711
+ background: #f8f9fa;
712
+ border: 1px solid #dee2e6;
713
+ border-radius: 4px;
714
+ padding: 5px 10px;
715
+ cursor: pointer;
716
+ font-size: 12px;
717
+ color: #6c757d;
718
+ }}
719
+
720
+ .instructions-toggle:hover {{
721
+ background: #e9ecef;
722
+ }}
723
+
724
+ .instructions-content {{
725
+ display: none;
726
+ max-height: 400px;
727
+ overflow-y: auto;
728
+ border-top: 1px solid #e0e0e0;
729
+ padding-top: 15px;
730
+ }}
731
+
732
+ .instructions-content.expanded {{
733
+ display: block;
734
+ }}
735
+
736
+ .instructions-content h1,
737
+ .instructions-content h2,
738
+ .instructions-content h3 {{
739
+ color: #333;
740
+ margin-top: 20px;
741
+ margin-bottom: 10px;
742
+ }}
743
+
744
+ .instructions-content h1 {{
745
+ font-size: 24px;
746
+ border-bottom: 2px solid #007bff;
747
+ padding-bottom: 10px;
748
+ }}
749
+
750
+ .instructions-content h2 {{
751
+ font-size: 20px;
752
+ }}
753
+
754
+ .instructions-content h3 {{
755
+ font-size: 16px;
756
+ }}
757
+
758
+ .instructions-content p {{
759
+ margin-bottom: 10px;
760
+ line-height: 1.6;
761
+ }}
762
+
763
+ .instructions-content code {{
764
+ background: #f8f9fa;
765
+ padding: 2px 4px;
766
+ border-radius: 3px;
767
+ font-family: monospace;
768
+ font-size: 14px;
769
+ }}
770
+
771
+ .instructions-content pre {{
772
+ background: #f8f9fa;
773
+ border: 1px solid #e9ecef;
774
+ border-radius: 4px;
775
+ padding: 15px;
776
+ overflow-x: auto;
777
+ margin: 10px 0;
778
+ }}
779
+
780
+ .instructions-content pre code {{
781
+ background: none;
782
+ padding: 0;
783
+ }}
784
+
785
+ .instructions-content ul,
786
+ .instructions-content ol {{
787
+ margin: 10px 0;
788
+ padding-left: 20px;
789
+ }}
790
+
791
+ .instructions-content li {{
792
+ margin-bottom: 5px;
793
+ }}
794
+
795
+ .instructions-content table {{
796
+ border-collapse: collapse;
797
+ width: 100%;
798
+ margin: 15px 0;
799
+ }}
800
+
801
+ .instructions-content th,
802
+ .instructions-content td {{
803
+ border: 1px solid #dee2e6;
804
+ padding: 8px 12px;
805
+ text-align: left;
806
+ }}
807
+
808
+ .instructions-content th {{
809
+ background: #f8f9fa;
810
+ font-weight: 600;
811
+ }}
812
+
813
+ /* Enhanced Form Styles */
814
+ .help-text {{
815
+ display: block;
816
+ margin-top: 5px;
817
+ font-size: 12px;
818
+ color: #6c757d;
819
+ font-style: italic;
820
+ }}
821
+
822
+ .form-group label {{
823
+ font-weight: 500;
824
+ color: #333;
825
+ margin-bottom: 5px;
826
+ }}
827
+
828
+ .form-group select {{
829
+ width: 100%;
830
+ padding: 8px 12px;
831
+ border: 1px solid #ddd;
832
+ border-radius: 4px;
833
+ font-size: 14px;
834
+ background-color: white;
835
+ }}
836
+
837
+ .form-group select:focus {{
838
+ outline: none;
839
+ border-color: #007bff;
840
+ box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
841
+ }}
842
+
843
+ .form-group textarea {{
844
+ width: 100%;
845
+ padding: 8px 12px;
846
+ border: 1px solid #ddd;
847
+ border-radius: 4px;
848
+ font-size: 14px;
849
+ font-family: inherit;
850
+ resize: vertical;
851
+ }}
852
+
853
+ .form-group textarea:focus {{
854
+ outline: none;
855
+ border-color: #007bff;
856
+ box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
857
+ }}
858
+
859
+ .form-group input[type="number"] {{
860
+ width: 100%;
861
+ padding: 8px 12px;
862
+ border: 1px solid #ddd;
863
+ border-radius: 4px;
864
+ font-size: 14px;
865
+ }}
866
+
867
+ .form-group input[type="number"]:focus {{
868
+ outline: none;
869
+ border-color: #007bff;
870
+ box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
871
+ }}
872
+
873
+ .form-group input[type="text"]:focus {{
874
+ outline: none;
875
+ border-color: #007bff;
876
+ box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
877
+ }}
878
+
879
+ .required-indicator {{
880
+ color: #dc3545;
881
+ font-weight: bold;
882
+ }}
883
+
884
+ .form-group .field-description {{
885
+ font-size: 11px;
886
+ color: #666;
887
+ margin-top: 2px;
888
+ font-style: italic;
889
+ }}
890
+ </style>
891
+ </head>
892
+ <body>
893
+ <div class="container">
894
+ <!-- Left Pane: HumanAgent Interface -->
895
+ <div class="left-pane">
896
+ <div class="pane-header">
897
+ <span class="status-indicator status-disconnected" id="connection-status"></span>
898
+ HumanAgent Interface
899
+ </div>
900
+ <div class="pane-content">
901
+ <!-- Instructions Section -->
902
+ {_generate_instructions_section(metadata)}
903
+
904
+ <!-- Action Form or Chat Interface -->
905
+ {_generate_action_interface(action_fields, is_chat_env)}
906
+
907
+ <!-- Control Buttons -->
908
+ <div style="margin-bottom: 20px;">
909
+ <button class="btn btn-secondary" id="reset-btn">Reset Environment</button>
910
+ <button class="btn btn-secondary" id="state-btn">Get State</button>
911
+ </div>
912
+
913
+ <!-- Current State Display -->
914
+ <div class="state-display">
915
+ <h3>Current State</h3>
916
+ <div id="current-state">
917
+ <div class="state-item">
918
+ <span class="state-label">Status:</span>
919
+ <span class="state-value" id="env-status">Not initialized</span>
920
+ </div>
921
+ <div class="state-item">
922
+ <span class="state-label">Episode ID:</span>
923
+ <span class="state-value" id="episode-id">-</span>
924
+ </div>
925
+ <div class="state-item">
926
+ <span class="state-label">Step Count:</span>
927
+ <span class="state-value" id="step-count">0</span>
928
+ </div>
929
+ </div>
930
+ </div>
931
+ </div>
932
+ </div>
933
+
934
+ <!-- Right Pane: State Observer -->
935
+ <div class="right-pane">
936
+ <div class="pane-header">
937
+ State Observer
938
+ </div>
939
+ <div class="pane-content">
940
+ <!-- Current Observation -->
941
+ <div class="state-display">
942
+ <h3>Current Observation</h3>
943
+ <div id="current-observation" class="json-display">
944
+ No observation yet
945
+ </div>
946
+ </div>
947
+
948
+ <!-- Action Logs -->
949
+ <div class="logs-container">
950
+ <h3>Action History</h3>
951
+ <div id="action-logs">
952
+ No actions taken yet
953
+ </div>
954
+ </div>
955
+ </div>
956
+ </div>
957
+ </div>
958
+
959
+ <script>
960
+ class OpenEnvWebInterface {{
961
+ constructor() {{
962
+ this.ws = null;
963
+ this.isConnected = false;
964
+ this.init();
965
+ }}
966
+
967
+ init() {{
968
+ this.connectWebSocket();
969
+ this.setupEventListeners();
970
+ }}
971
+
972
+ connectWebSocket() {{
973
+ const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
974
+ const wsUrl = `${{protocol}}//${{window.location.host}}/ws`;
975
+
976
+ this.ws = new WebSocket(wsUrl);
977
+
978
+ this.ws.onopen = () => {{
979
+ this.isConnected = true;
980
+ this.updateConnectionStatus(true);
981
+ console.log('WebSocket connected');
982
+ }};
983
+
984
+ this.ws.onmessage = (event) => {{
985
+ const data = JSON.parse(event.data);
986
+ if (data.type === 'state_update') {{
987
+ this.updateUI(data.episode_state);
988
+ }}
989
+ }};
990
+
991
+ this.ws.onclose = () => {{
992
+ this.isConnected = false;
993
+ this.updateConnectionStatus(false);
994
+ console.log('WebSocket disconnected');
995
+ // Attempt to reconnect after 3 seconds
996
+ setTimeout(() => this.connectWebSocket(), 3000);
997
+ }};
998
+
999
+ this.ws.onerror = (error) => {{
1000
+ console.error('WebSocket error:', error);
1001
+ }};
1002
+ }}
1003
+
1004
+ setupEventListeners() {{
1005
+ // Instructions toggle
1006
+ const instructionsToggle = document.getElementById('instructions-toggle');
1007
+ const instructionsContent = document.getElementById('instructions-content');
1008
+ if (instructionsToggle && instructionsContent) {{
1009
+ instructionsToggle.addEventListener('click', () => {{
1010
+ instructionsContent.classList.toggle('expanded');
1011
+ instructionsToggle.textContent = instructionsContent.classList.contains('expanded')
1012
+ ? 'Hide Instructions' : 'Show Instructions';
1013
+ }});
1014
+ }}
1015
+
1016
+ // Check if this is a chat environment
1017
+ const isChatEnv = document.getElementById('chat-messages') !== null;
1018
+
1019
+ if (isChatEnv) {{
1020
+ // Chat environment event listeners
1021
+ document.getElementById('send-message-btn').addEventListener('click', () => {{
1022
+ this.sendMessage();
1023
+ }});
1024
+
1025
+ // Send message on Enter (but allow Shift+Enter for new lines)
1026
+ document.getElementById('message-input').addEventListener('keydown', (e) => {{
1027
+ if (e.key === 'Enter' && !e.shiftKey) {{
1028
+ e.preventDefault();
1029
+ this.sendMessage();
1030
+ }}
1031
+ }});
1032
+ }} else {{
1033
+ // Traditional action form submission
1034
+ const actionForm = document.getElementById('action-form');
1035
+ if (actionForm) {{
1036
+ actionForm.addEventListener('submit', (e) => {{
1037
+ e.preventDefault();
1038
+ this.submitAction();
1039
+ }});
1040
+ }}
1041
+ }}
1042
+
1043
+ // Reset button
1044
+ document.getElementById('reset-btn').addEventListener('click', () => {{
1045
+ this.resetEnvironment();
1046
+ }});
1047
+
1048
+ // State button
1049
+ document.getElementById('state-btn').addEventListener('click', () => {{
1050
+ this.getState();
1051
+ }});
1052
+ }}
1053
+
1054
+ async sendMessage() {{
1055
+ const messageInput = document.getElementById('message-input');
1056
+ const roleSelect = document.getElementById('message-role');
1057
+ const message = messageInput.value.trim();
1058
+ const role = roleSelect.value;
1059
+
1060
+ if (!message) {{
1061
+ return;
1062
+ }}
1063
+
1064
+ // Add message to chat display immediately
1065
+ this.addMessageToChat(role, message);
1066
+
1067
+ // Clear input
1068
+ messageInput.value = '';
1069
+
1070
+ try {{
1071
+ // Send message to server to convert to action and step
1072
+ const response = await fetch('/web/step', {{
1073
+ method: 'POST',
1074
+ headers: {{ 'Content-Type': 'application/json' }},
1075
+ body: JSON.stringify({{
1076
+ message: {{
1077
+ role: role,
1078
+ content: message
1079
+ }}
1080
+ }})
1081
+ }});
1082
+
1083
+ if (!response.ok) {{
1084
+ throw new Error(`HTTP error! status: ${{response.status}}`);
1085
+ }}
1086
+
1087
+ const result = await response.json();
1088
+ console.log('Message sent:', result);
1089
+ }} catch (error) {{
1090
+ console.error('Error sending message:', error);
1091
+ alert('Error sending message: ' + error.message);
1092
+ }}
1093
+ }}
1094
+
1095
+ addMessageToChat(role, content) {{
1096
+ const chatMessages = document.getElementById('chat-messages');
1097
+ const messageDiv = document.createElement('div');
1098
+ messageDiv.className = `chat-message ${{role}}`;
1099
+
1100
+ messageDiv.innerHTML = `
1101
+ <div class="message-role">${{role.charAt(0).toUpperCase() + role.slice(1)}}</div>
1102
+ <div class="message-content">${{content}}</div>
1103
+ `;
1104
+
1105
+ chatMessages.appendChild(messageDiv);
1106
+ chatMessages.scrollTop = chatMessages.scrollHeight;
1107
+ }}
1108
+
1109
+ async submitAction() {{
1110
+ const formData = new FormData(document.getElementById('action-form'));
1111
+ const action = {{}};
1112
+
1113
+ // Collect form data
1114
+ for (const [key, value] of formData.entries()) {{
1115
+ if (value !== '') {{
1116
+ // Handle tensor fields (tokens) - convert comma-separated string to array
1117
+ if (key === 'tokens') {{
1118
+ try {{
1119
+ action[key] = value.split(',').map(x => parseInt(x.trim())).filter(x => !isNaN(x));
1120
+ }} catch (e) {{
1121
+ console.error('Error parsing tokens:', e);
1122
+ action[key] = [];
1123
+ }}
1124
+ }} else {{
1125
+ action[key] = value;
1126
+ }}
1127
+ }}
1128
+ }}
1129
+
1130
+ try {{
1131
+ const response = await fetch('/web/step', {{
1132
+ method: 'POST',
1133
+ headers: {{ 'Content-Type': 'application/json' }},
1134
+ body: JSON.stringify({{ action }})
1135
+ }});
1136
+
1137
+ if (!response.ok) {{
1138
+ throw new Error(`HTTP error! status: ${{response.status}}`);
1139
+ }}
1140
+
1141
+ const result = await response.json();
1142
+ console.log('Step result:', result);
1143
+ }} catch (error) {{
1144
+ console.error('Error submitting action:', error);
1145
+ alert('Error submitting action: ' + error.message);
1146
+ }}
1147
+ }}
1148
+
1149
+ async resetEnvironment() {{
1150
+ try {{
1151
+ const response = await fetch('/web/reset', {{
1152
+ method: 'POST',
1153
+ headers: {{ 'Content-Type': 'application/json' }}
1154
+ }});
1155
+
1156
+ if (!response.ok) {{
1157
+ throw new Error(`HTTP error! status: ${{response.status}}`);
1158
+ }}
1159
+
1160
+ const result = await response.json();
1161
+ console.log('Reset result:', result);
1162
+ }} catch (error) {{
1163
+ console.error('Error resetting environment:', error);
1164
+ alert('Error resetting environment: ' + error.message);
1165
+ }}
1166
+ }}
1167
+
1168
+ async getState() {{
1169
+ try {{
1170
+ const response = await fetch('/web/state');
1171
+ const state = await response.json();
1172
+ console.log('Current state:', state);
1173
+ alert('Current state: ' + JSON.stringify(state, null, 2));
1174
+ }} catch (error) {{
1175
+ console.error('Error getting state:', error);
1176
+ alert('Error getting state: ' + error.message);
1177
+ }}
1178
+ }}
1179
+
1180
+ updateConnectionStatus(connected) {{
1181
+ const indicator = document.getElementById('connection-status');
1182
+ if (connected) {{
1183
+ indicator.className = 'status-indicator status-connected';
1184
+ }} else {{
1185
+ indicator.className = 'status-indicator status-disconnected';
1186
+ }}
1187
+ }}
1188
+
1189
+ updateUI(episodeState) {{
1190
+ // Check if this is a chat environment
1191
+ const isChatEnv = document.getElementById('chat-messages') !== null;
1192
+
1193
+ // Update current state
1194
+ document.getElementById('env-status').textContent =
1195
+ episodeState.is_reset ? 'Reset' : 'Running';
1196
+ document.getElementById('episode-id').textContent =
1197
+ episodeState.episode_id || '-';
1198
+ document.getElementById('step-count').textContent =
1199
+ episodeState.step_count.toString();
1200
+
1201
+ if (isChatEnv) {{
1202
+ // Update chat interface
1203
+ this.updateChatInterface(episodeState);
1204
+ }} else {{
1205
+ // Update traditional observation display
1206
+ const observationDiv = document.getElementById('current-observation');
1207
+ if (episodeState.current_observation) {{
1208
+ observationDiv.textContent = JSON.stringify(
1209
+ episodeState.current_observation, null, 2
1210
+ );
1211
+ }} else {{
1212
+ observationDiv.textContent = 'No observation yet';
1213
+ }}
1214
+ }}
1215
+
1216
+ // Update action logs
1217
+ const logsDiv = document.getElementById('action-logs');
1218
+ if (episodeState.action_logs.length === 0) {{
1219
+ logsDiv.innerHTML = 'No actions taken yet';
1220
+ }} else {{
1221
+ logsDiv.innerHTML = episodeState.action_logs.map(log => `
1222
+ <div class="log-entry">
1223
+ <div class="log-timestamp">${{log.timestamp}} (Step ${{log.step_count}})</div>
1224
+ <div class="log-action">Action: ${{JSON.stringify(log.action, null, 2)}}</div>
1225
+ <div class="log-observation">Observation: ${{JSON.stringify(log.observation, null, 2)}}</div>
1226
+ <div>
1227
+ <span class="log-reward">Reward: ${{log.reward !== null ? log.reward : 'None'}}</span>
1228
+ ${{log.done ? '<span class="log-done">DONE</span>' : ''}}
1229
+ </div>
1230
+ </div>
1231
+ `).join('');
1232
+ }}
1233
+ }}
1234
+
1235
+ updateChatInterface(episodeState) {{
1236
+ const chatMessages = document.getElementById('chat-messages');
1237
+ if (!chatMessages) return;
1238
+
1239
+ // Clear existing messages (except system message)
1240
+ const systemMessage = chatMessages.querySelector('.chat-message.system');
1241
+ chatMessages.innerHTML = '';
1242
+ if (systemMessage) {{
1243
+ chatMessages.appendChild(systemMessage);
1244
+ }}
1245
+
1246
+ // Add messages from current observation
1247
+ if (episodeState.current_observation && episodeState.current_observation.messages) {{
1248
+ episodeState.current_observation.messages.forEach(msg => {{
1249
+ this.addMessageToChat(msg.role, msg.content);
1250
+ }});
1251
+ }}
1252
+ }}
1253
+ }}
1254
+
1255
+ // Initialize the web interface when the page loads
1256
+ document.addEventListener('DOMContentLoaded', () => {{
1257
+ new OpenEnvWebInterface();
1258
+ }});
1259
+ </script>
1260
+ </body>
1261
+ </html>
1262
+ """.replace('{_generate_action_form_fields(action_fields)}', _generate_action_form_fields(action_fields))
1263
+
1264
+
1265
+ def _generate_instructions_section(metadata: Optional[EnvironmentMetadata]) -> str:
1266
+ """Generate the instructions section with environment documentation."""
1267
+ if not metadata or not metadata.readme_content:
1268
+ return ''
1269
+
1270
+ # Convert markdown to HTML (basic conversion)
1271
+ import re
1272
+ html_content = _markdown_to_html(metadata.readme_content)
1273
+
1274
+ return f'''
1275
+ <!-- Instructions Section -->
1276
+ <div class="instructions-section">
1277
+ <div class="instructions-header">
1278
+ <h3 class="instructions-title">{metadata.name}</h3>
1279
+ <button class="instructions-toggle" id="instructions-toggle">Show Instructions</button>
1280
+ </div>
1281
+ <div class="instructions-content" id="instructions-content">
1282
+ <div class="instructions-readme">
1283
+ {html_content}
1284
+ </div>
1285
+ </div>
1286
+ </div>
1287
+ '''
1288
+
1289
+
1290
+ def _extract_action_fields(action_cls: Type[Action]) -> List[Dict[str, Any]]:
1291
+ """Extract enhanced field metadata from Action class for form generation."""
1292
+ import typing
1293
+ from typing import get_origin, get_args
1294
+
1295
+ action_fields = []
1296
+ if not hasattr(action_cls, '__dataclass_fields__'):
1297
+ return action_fields
1298
+
1299
+ for field_name, field_info in action_cls.__dataclass_fields__.items():
1300
+ if field_name == 'metadata':
1301
+ continue
1302
+
1303
+ field_type = field_info.type
1304
+ field_metadata = _extract_field_metadata(field_name, field_info)
1305
+
1306
+ # Determine input type based on field type
1307
+ input_type = _determine_input_type(field_type)
1308
+
1309
+ # Check if field is required
1310
+ is_required = field_info.default is field_info.default_factory
1311
+
1312
+ action_fields.append({
1313
+ 'name': field_name,
1314
+ 'type': input_type,
1315
+ 'required': is_required,
1316
+ 'description': field_metadata.get('description', ''),
1317
+ 'default_value': field_metadata.get('default_value'),
1318
+ 'choices': field_metadata.get('choices', []),
1319
+ 'min_value': field_metadata.get('min_value'),
1320
+ 'max_value': field_metadata.get('max_value'),
1321
+ 'placeholder': field_metadata.get('placeholder', ''),
1322
+ 'help_text': field_metadata.get('help_text', ''),
1323
+ })
1324
+
1325
+ return action_fields
1326
+
1327
+
1328
+ def _extract_field_metadata(field_name: str, field_info) -> Dict[str, Any]:
1329
+ """Extract metadata from dataclass field including docstring and type hints."""
1330
+ import typing
1331
+ from typing import get_origin, get_args, Literal, Union, Optional
1332
+
1333
+ metadata = {}
1334
+
1335
+ # Extract description from field docstring or annotation
1336
+ if hasattr(field_info, 'metadata') and field_info.metadata:
1337
+ # Check for custom metadata
1338
+ for meta in field_info.metadata:
1339
+ if isinstance(meta, dict):
1340
+ metadata.update(meta)
1341
+
1342
+ # Extract type information
1343
+ field_type = field_info.type
1344
+ origin = get_origin(field_type)
1345
+
1346
+ # Handle Literal types for dropdown choices
1347
+ if origin is Literal:
1348
+ args = get_args(field_type)
1349
+ metadata['choices'] = list(args)
1350
+
1351
+ # Handle Optional types
1352
+ if origin is Union:
1353
+ args = get_args(field_type)
1354
+ if len(args) == 2 and type(None) in args:
1355
+ # This is Optional[SomeType]
1356
+ non_none_type = args[0] if args[1] is type(None) else args[1]
1357
+ metadata['optional'] = True
1358
+ # Recursively check the non-None type for choices
1359
+ if get_origin(non_none_type) is Literal:
1360
+ metadata['choices'] = list(get_args(non_none_type))
1361
+ else:
1362
+ # Regular Union type
1363
+ metadata['choices'] = [str(arg) for arg in args if arg is not type(None)]
1364
+
1365
+ # Handle numeric constraints
1366
+ if field_type in (int, float):
1367
+ # Check for common constraint patterns in field name
1368
+ if 'count' in field_name.lower() or 'num' in field_name.lower():
1369
+ metadata['min_value'] = 0
1370
+ if 'id' in field_name.lower():
1371
+ metadata['min_value'] = 0
1372
+
1373
+ # Generate placeholder text
1374
+ if 'message' in field_name.lower():
1375
+ metadata['placeholder'] = f'Enter {field_name.replace("_", " ")}...'
1376
+ elif 'code' in field_name.lower():
1377
+ metadata['placeholder'] = 'Enter Python code here...'
1378
+ elif 'tokens' in field_name.lower():
1379
+ metadata['placeholder'] = 'Enter comma-separated token IDs (e.g., 1,2,3,4,5)'
1380
+ else:
1381
+ metadata['placeholder'] = f'Enter {field_name.replace("_", " ")}...'
1382
+
1383
+ # Generate help text based on field name and type
1384
+ if 'action_id' in field_name.lower():
1385
+ metadata['help_text'] = 'The action ID to execute in the environment'
1386
+ elif 'game_name' in field_name.lower():
1387
+ metadata['help_text'] = 'Name of the game or environment'
1388
+ elif 'tokens' in field_name.lower():
1389
+ metadata['help_text'] = 'Token IDs as a comma-separated list of integers'
1390
+ elif 'code' in field_name.lower():
1391
+ metadata['help_text'] = 'Python code to execute in the environment'
1392
+ elif 'message' in field_name.lower():
1393
+ metadata['help_text'] = 'Text message to send'
1394
+
1395
+ return metadata
1396
+
1397
+
1398
+ def _determine_input_type(field_type) -> str:
1399
+ """Determine the appropriate HTML input type for a field type."""
1400
+ import typing
1401
+ from typing import get_origin, get_args, Literal, Union
1402
+
1403
+ # Handle direct types
1404
+ if field_type == str:
1405
+ return "text"
1406
+ elif field_type == int:
1407
+ return "number"
1408
+ elif field_type == float:
1409
+ return "number"
1410
+ elif field_type == bool:
1411
+ return "checkbox"
1412
+
1413
+ # Handle complex types
1414
+ origin = get_origin(field_type)
1415
+
1416
+ if origin is Literal:
1417
+ return "select"
1418
+ elif origin is Union:
1419
+ args = get_args(field_type)
1420
+ if len(args) == 2 and type(None) in args:
1421
+ # Optional type - use the non-None type
1422
+ non_none_type = args[0] if args[1] is type(None) else args[1]
1423
+ return _determine_input_type(non_none_type)
1424
+ elif all(isinstance(arg, str) for arg in args if arg is not type(None)):
1425
+ return "select"
1426
+ else:
1427
+ return "text"
1428
+ elif hasattr(field_type, '__name__') and 'Tensor' in field_type.__name__:
1429
+ return "tensor"
1430
+ else:
1431
+ return "text"
1432
+
1433
+
1434
+ def _markdown_to_html(markdown: str) -> str:
1435
+ """Convert basic markdown to HTML for README display."""
1436
+ import html
1437
+ import re
1438
+
1439
+ # Escape HTML first
1440
+ html_content = html.escape(markdown)
1441
+
1442
+ # Convert headers
1443
+ html_content = re.sub(r'^# (.*?)$', r'<h1>\1</h1>', html_content, flags=re.MULTILINE)
1444
+ html_content = re.sub(r'^## (.*?)$', r'<h2>\1</h2>', html_content, flags=re.MULTILINE)
1445
+ html_content = re.sub(r'^### (.*?)$', r'<h3>\1</h3>', html_content, flags=re.MULTILINE)
1446
+
1447
+ # Convert code blocks
1448
+ html_content = re.sub(r'```(.*?)\n(.*?)\n```', r'<pre><code>\2</code></pre>', html_content, flags=re.DOTALL)
1449
+ html_content = re.sub(r'`([^`]+)`', r'<code>\1</code>', html_content)
1450
+
1451
+ # Convert bold and italic
1452
+ html_content = re.sub(r'\*\*(.*?)\*\*', r'<strong>\1</strong>', html_content)
1453
+ html_content = re.sub(r'\*(.*?)\*', r'<em>\1</em>', html_content)
1454
+
1455
+ # Convert lists
1456
+ html_content = re.sub(r'^- (.*?)$', r'<li>\1</li>', html_content, flags=re.MULTILINE)
1457
+ html_content = re.sub(r'(<li>.*</li>)', r'<ul>\1</ul>', html_content, flags=re.DOTALL)
1458
+
1459
+ # Convert line breaks
1460
+ html_content = html_content.replace('\n', '<br>')
1461
+
1462
+ return html_content
1463
+
1464
+
1465
+ def _generate_action_interface(action_fields: List[Dict[str, Any]], is_chat_env: bool) -> str:
1466
+ """Generate either a chat interface or action form based on environment type."""
1467
+ if is_chat_env:
1468
+ return _generate_chat_interface()
1469
+ else:
1470
+ return _generate_action_form(action_fields)
1471
+
1472
+ def _generate_chat_interface() -> str:
1473
+ """Generate a chat-style interface for chat environments."""
1474
+ return '''
1475
+ <!-- Chat Interface -->
1476
+ <div class="chat-interface">
1477
+ <h3>Chat Interface</h3>
1478
+ <div class="chat-messages" id="chat-messages">
1479
+ <div class="chat-message system">
1480
+ <div class="message-role">System</div>
1481
+ <div class="message-content">Chat environment ready. Send a message to start the conversation.</div>
1482
+ </div>
1483
+ </div>
1484
+ <div class="chat-input-container">
1485
+ <div class="role-selector">
1486
+ <label for="message-role">Role:</label>
1487
+ <select id="message-role">
1488
+ <option value="user">User</option>
1489
+ <option value="assistant">Assistant</option>
1490
+ </select>
1491
+ </div>
1492
+ <div class="message-input">
1493
+ <textarea id="message-input" placeholder="Type your message here..." rows="3"></textarea>
1494
+ <button class="btn" id="send-message-btn">Send Message</button>
1495
+ </div>
1496
+ </div>
1497
+ </div>
1498
+ '''
1499
+
1500
+ def _generate_action_form(action_fields: List[Dict[str, Any]]) -> str:
1501
+ """Generate a traditional action form for non-chat environments."""
1502
+ return f'''
1503
+ <!-- Action Form -->
1504
+ <div class="action-form">
1505
+ <h3>Take Action</h3>
1506
+ <form id="action-form">
1507
+ {_generate_action_form_fields(action_fields)}
1508
+ <button type="submit" class="btn" id="step-btn">Step</button>
1509
+ </form>
1510
+ </div>
1511
+ '''
1512
+
1513
+ def _generate_action_form_fields(action_fields: List[Dict[str, Any]]) -> str:
1514
+ """Generate HTML form fields for action input with enhanced metadata."""
1515
+ if not action_fields:
1516
+ return '<p>No action fields available</p>'
1517
+
1518
+ fields_html = []
1519
+ for field in action_fields:
1520
+ field_html = _generate_single_field(field)
1521
+ fields_html.append(field_html)
1522
+
1523
+ return '\n'.join(fields_html)
1524
+
1525
+
1526
+ def _generate_single_field(field: Dict[str, Any]) -> str:
1527
+ """Generate HTML for a single form field with enhanced metadata."""
1528
+ field_name = field['name']
1529
+ field_type = field['type']
1530
+ required = field['required']
1531
+ placeholder = field.get('placeholder', '')
1532
+ help_text = field.get('help_text', '')
1533
+ choices = field.get('choices', [])
1534
+ min_value = field.get('min_value')
1535
+ max_value = field.get('max_value')
1536
+ default_value = field.get('default_value')
1537
+
1538
+ # Build label with required indicator
1539
+ label_text = field_name.replace('_', ' ').title()
1540
+ if required:
1541
+ label_text += ' <span style="color: red;">*</span>'
1542
+
1543
+ # Build input attributes
1544
+ input_attrs = []
1545
+ if required:
1546
+ input_attrs.append('required')
1547
+ if placeholder:
1548
+ input_attrs.append(f'placeholder="{placeholder}"')
1549
+ if min_value is not None:
1550
+ input_attrs.append(f'min="{min_value}"')
1551
+ if max_value is not None:
1552
+ input_attrs.append(f'max="{max_value}"')
1553
+ if default_value is not None:
1554
+ input_attrs.append(f'value="{default_value}"')
1555
+
1556
+ attrs_str = ' '.join(input_attrs)
1557
+
1558
+ if field_type == 'checkbox':
1559
+ return f'''
1560
+ <div class="form-group">
1561
+ <label>
1562
+ <input type="checkbox" name="{field_name}" value="true" {attrs_str}>
1563
+ {label_text}
1564
+ </label>
1565
+ {f'<small class="help-text">{help_text}</small>' if help_text else ''}
1566
+ </div>
1567
+ '''
1568
+
1569
+ elif field_type == 'select':
1570
+ options_html = []
1571
+ if not required:
1572
+ options_html.append(f'<option value="">-- Select {label_text} --</option>')
1573
+
1574
+ for choice in choices:
1575
+ selected = 'selected' if str(choice) == str(default_value) else ''
1576
+ options_html.append(f'<option value="{choice}" {selected}>{choice}</option>')
1577
+
1578
+ return f'''
1579
+ <div class="form-group">
1580
+ <label for="{field_name}">{label_text}:</label>
1581
+ <select name="{field_name}" id="{field_name}" {attrs_str}>
1582
+ {''.join(options_html)}
1583
+ </select>
1584
+ {f'<small class="help-text">{help_text}</small>' if help_text else ''}
1585
+ </div>
1586
+ '''
1587
+
1588
+ elif field_type == 'tensor':
1589
+ return f'''
1590
+ <div class="form-group">
1591
+ <label for="{field_name}">{label_text} (comma-separated integers):</label>
1592
+ <input type="text" name="{field_name}" id="{field_name}" {attrs_str}>
1593
+ <small class="help-text">{help_text or 'Enter token IDs as comma-separated integers (e.g., 1,2,3,4,5)'}</small>
1594
+ </div>
1595
+ '''
1596
+
1597
+ elif field_type == 'text' and ('message' in field_name.lower() or 'code' in field_name.lower()):
1598
+ return f'''
1599
+ <div class="form-group">
1600
+ <label for="{field_name}">{label_text}:</label>
1601
+ <textarea name="{field_name}" id="{field_name}" rows="3" {attrs_str}></textarea>
1602
+ {f'<small class="help-text">{help_text}</small>' if help_text else ''}
1603
+ </div>
1604
+ '''
1605
+
1606
+ else:
1607
+ return f'''
1608
+ <div class="form-group">
1609
+ <label for="{field_name}">{label_text}:</label>
1610
+ <input type="{field_type}" name="{field_name}" id="{field_name}" {attrs_str}>
1611
+ {f'<small class="help-text">{help_text}</small>' if help_text else ''}
1612
+ </div>
1613
+ '''
src/core/build/lib/openenv_core/http_env_client.py ADDED
@@ -0,0 +1,185 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ core/runner_env.py
3
+ Minimal HTTP-based environment client.
4
+ - Talks to a single env worker exposing: POST /reset, POST /step
5
+
6
+ Future hooks (commented below) for:
7
+ - episode_id, seed on reset
8
+ - request_id on step
9
+ - custom headers (auth/trace)
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from abc import ABC, abstractmethod
15
+ from typing import Any, Dict, Generic, Optional, Type, TYPE_CHECKING, TypeVar
16
+
17
+ import requests
18
+
19
+ from .client_types import StepResult
20
+ from .containers.runtime import LocalDockerProvider
21
+
22
+ if TYPE_CHECKING:
23
+ from .containers.runtime import ContainerProvider
24
+
25
+ ActT = TypeVar("ActT")
26
+ ObsT = TypeVar("ObsT")
27
+ EnvClientT = TypeVar("EnvClientT", bound="HTTPEnvClient")
28
+
29
+
30
+ class HTTPEnvClient(ABC, Generic[ActT, ObsT]):
31
+ def __init__(
32
+ self,
33
+ base_url: str,
34
+ request_timeout_s: float = 15.0,
35
+ default_headers: Optional[Dict[str, str]] = None,
36
+ provider: Optional["ContainerProvider"] = None,
37
+ ):
38
+ self._base = base_url.rstrip("/")
39
+ self._timeout = float(request_timeout_s)
40
+ self._http = requests.Session()
41
+ self._headers = default_headers or {}
42
+ self._provider = provider
43
+
44
+ @classmethod
45
+ def from_docker_image(
46
+ cls: Type[EnvClientT],
47
+ image: str,
48
+ provider: Optional["ContainerProvider"] = None,
49
+ **kwargs: Any,
50
+ ) -> EnvClientT:
51
+ """
52
+ Create an environment client by spinning up a Docker container locally.
53
+
54
+ This is a development utility that:
55
+ 1. Starts a Docker container from the specified image
56
+ 2. Waits for the server to be ready
57
+ 3. Creates and returns a client instance connected to the container
58
+
59
+ Note: The container lifecycle management is left to the user or higher-level
60
+ orchestration. The container will keep running until manually stopped.
61
+
62
+ Args:
63
+ image: Docker image name to run (e.g., "echo-env:latest")
64
+ provider: Container provider to use (defaults to LocalDockerProvider)
65
+ **kwargs: Additional arguments to pass to provider.start_container()
66
+ (e.g., env_vars, port)
67
+
68
+ Returns:
69
+ An instance of the client class connected to the running container
70
+
71
+ Example:
72
+ >>> from envs.coding_env.client import CodingEnv
73
+ >>> from envs.coding_env.models import CodeAction
74
+ >>>
75
+ >>> # Create environment from image
76
+ >>> env = CodingEnv.from_docker_image("coding-env:latest")
77
+ >>>
78
+ >>> # Create environment with custom env vars
79
+ >>> env = CodingEnv.from_docker_image(
80
+ ... "coding-env:latest",
81
+ ... env_vars={"MY_VAR": "value"}
82
+ ... )
83
+ >>>
84
+ >>> # Use the environment
85
+ >>> result = env.reset()
86
+ >>> print(result.observation)
87
+ >>>
88
+ >>> step_result = env.step(CodeAction(code="print('hello')"))
89
+ >>> print(step_result.observation.stdout)
90
+ >>>
91
+ >>> # Cleanup (optional)
92
+ >>> env.close()
93
+ """
94
+
95
+ # Use default provider if none provided
96
+ if provider is None:
97
+ provider = LocalDockerProvider()
98
+
99
+ # 1. Start container with optional kwargs (e.g., env_vars, port)
100
+ base_url = provider.start_container(image, **kwargs)
101
+
102
+ # 2. Wait for server to be ready
103
+ provider.wait_for_ready(base_url)
104
+
105
+ # 3. Create and return client instance with provider reference
106
+ return cls(base_url=base_url, provider=provider)
107
+
108
+ @abstractmethod
109
+ def _step_payload(self, action: ActT) -> dict:
110
+ """Convert an Action object to the JSON body expected by the env server."""
111
+ raise NotImplementedError
112
+
113
+ @abstractmethod
114
+ def _parse_result(self, payload: dict) -> StepResult[ObsT]:
115
+ """Convert a JSON response from the env server to StepResult[ObsT]."""
116
+ raise NotImplementedError
117
+
118
+ @abstractmethod
119
+ def _parse_state(self, payload: dict) -> Any:
120
+ """Convert a JSON response from the state endpoint to a State object."""
121
+ raise NotImplementedError
122
+
123
+ # ---------- Environment Server Interface Methods ----------
124
+ def reset(self) -> StepResult[ObsT]:
125
+ body: Dict[str, Any] = {}
126
+ # TODO: later:
127
+ # body["seed"] = seed
128
+ # body["episode_id"] = episode_id
129
+ r = self._http.post(
130
+ f"{self._base}/reset",
131
+ json=body,
132
+ headers=self._headers,
133
+ timeout=self._timeout,
134
+ )
135
+ r.raise_for_status()
136
+ return self._parse_result(r.json())
137
+
138
+ def step(self, action: ActT) -> StepResult[ObsT]:
139
+ body: Dict[str, Any] = {
140
+ "action": self._step_payload(action),
141
+ "timeout_s": int(self._timeout),
142
+ }
143
+ # TODO: later:
144
+ # body["request_id"] = str(uuid.uuid4())
145
+ # body["episode_id"] = current_episode_id
146
+ r = self._http.post(
147
+ f"{self._base}/step",
148
+ json=body,
149
+ headers=self._headers,
150
+ timeout=self._timeout,
151
+ )
152
+ r.raise_for_status()
153
+ return self._parse_result(r.json())
154
+
155
+ def state(self) -> Any:
156
+ """
157
+ Get the current environment state from the server.
158
+
159
+ Returns:
160
+ State object with environment state information (e.g., episode_id, step_count)
161
+
162
+ Example:
163
+ >>> client = EchoEnv.from_docker_image("echo-env:latest")
164
+ >>> result = client.reset()
165
+ >>> state = client.state()
166
+ >>> print(state.episode_id)
167
+ >>> print(state.step_count)
168
+ """
169
+ r = self._http.get(
170
+ f"{self._base}/state",
171
+ headers=self._headers,
172
+ timeout=self._timeout,
173
+ )
174
+ r.raise_for_status()
175
+ return self._parse_state(r.json())
176
+
177
+ def close(self) -> None:
178
+ """
179
+ Close the environment and clean up resources.
180
+
181
+ If this client was created via from_docker_image(), this will stop
182
+ and remove the associated container.
183
+ """
184
+ if self._provider is not None:
185
+ self._provider.stop_container()
src/core/build/lib/openenv_core/tools/__init__.py ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ """Core tools for code execution and other utilities."""
8
+
9
+ from .git_server_client import GitServerClient, RepoInfo
10
+ from .local_python_executor import PyExecutor
11
+
12
+ __all__ = [
13
+ "PyExecutor",
14
+ "GitServerClient",
15
+ "RepoInfo",
16
+ ]
src/core/build/lib/openenv_core/tools/git_server_client.py ADDED
@@ -0,0 +1,362 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Git Server Client for connecting to external Gitea instance.
4
+
5
+ This module provides a lightweight client for interacting with a shared
6
+ Gitea service, optimized for task-based isolation where multiple environment
7
+ instances share the same Gitea server but have isolated workspaces.
8
+ """
9
+
10
+ import json
11
+ import os
12
+ import shutil
13
+ import subprocess
14
+ import time
15
+ from dataclasses import dataclass
16
+ from pathlib import Path
17
+ from urllib.parse import urlparse
18
+
19
+
20
+ @dataclass
21
+ class RepoInfo:
22
+ """Information about a repository."""
23
+
24
+ name: str
25
+ url: str
26
+ commit: str
27
+ clone_url: str
28
+
29
+
30
+ class GitServerClient:
31
+ """
32
+ Client for connecting to an external Gitea server.
33
+
34
+ This client is optimized for task-based isolation where:
35
+ - Multiple tasks share the same Gitea instance
36
+ - Each task has its own isolated workspace
37
+ - Fast reset() via git operations (no server restart)
38
+ - Repos are pre-migrated to Gitea once
39
+
40
+ Args:
41
+ gitea_url: URL of the Gitea server (e.g., "http://gitea:3000")
42
+ username: Gitea username for authentication
43
+ password: Gitea password for authentication
44
+ workspace_dir: Local workspace directory for cloning repos
45
+
46
+ Example:
47
+ >>> # Connect to shared Gitea (credentials from environment)
48
+ >>> import os
49
+ >>> client = GitServerClient(
50
+ ... gitea_url=os.getenv("GITEA_URL"),
51
+ ... username=os.getenv("GITEA_USERNAME"),
52
+ ... password=os.getenv("GITEA_PASSWORD")
53
+ ... )
54
+ >>> client.wait_for_ready()
55
+ >>> # Clone repo to workspace
56
+ >>> path = client.clone_to_workspace("my-repo", commit="abc123")
57
+ >>> # Fast reset to base state
58
+ >>> client.reset_workspace("my-repo", commit="abc123")
59
+ """
60
+
61
+ def __init__(
62
+ self,
63
+ gitea_url: str,
64
+ username: str,
65
+ password: str,
66
+ workspace_dir: str = "/workspace",
67
+ ):
68
+ """Initialize Git Server Client."""
69
+ self.gitea_url = gitea_url.rstrip("/")
70
+ self.username = username
71
+ self.password = password
72
+ self.workspace_dir = Path(workspace_dir)
73
+ self.is_ready = False
74
+
75
+ # Parse Gitea URL
76
+ parsed = urlparse(self.gitea_url)
77
+ self.domain = parsed.hostname or "localhost"
78
+ self.port = parsed.port or 3000
79
+
80
+ # Ensure workspace exists
81
+ os.makedirs(self.workspace_dir, exist_ok=True)
82
+
83
+ # Configure git credentials
84
+ self._configure_git()
85
+
86
+ def _configure_git(self):
87
+ """Configure git credentials for automatic authentication."""
88
+ home_dir = Path.home()
89
+
90
+ # Git config
91
+ git_config = f"""[user]
92
+ name = {self.username}
93
+ email = {self.username}@local.env
94
+ [init]
95
+ defaultBranch = main
96
+ [credential]
97
+ helper = store
98
+ """
99
+ gitconfig_path = home_dir / ".gitconfig"
100
+ gitconfig_path.write_text(git_config)
101
+
102
+ # Git credentials
103
+ git_credentials = f"http://{self.username}:{self.password}@{self.domain}:{self.port}\n"
104
+ gitcreds_path = home_dir / ".git-credentials"
105
+ gitcreds_path.write_text(git_credentials)
106
+ gitcreds_path.chmod(0o600)
107
+
108
+ def wait_for_ready(self, timeout: int = 30) -> bool:
109
+ """
110
+ Wait for Gitea server to be ready.
111
+
112
+ Args:
113
+ timeout: Maximum seconds to wait
114
+
115
+ Returns:
116
+ True if server is ready, False otherwise
117
+ """
118
+ start_time = time.time()
119
+ while time.time() - start_time < timeout:
120
+ try:
121
+ result = subprocess.run(
122
+ ["curl", "-sf", f"{self.gitea_url}/"],
123
+ capture_output=True,
124
+ timeout=5,
125
+ )
126
+ if result.returncode == 0:
127
+ self.is_ready = True
128
+ return True
129
+ except subprocess.TimeoutExpired:
130
+ pass
131
+ except Exception:
132
+ pass
133
+
134
+ time.sleep(1)
135
+
136
+ return False
137
+
138
+ def list_repositories(self) -> list[dict[str, str]]:
139
+ """
140
+ List all repositories in Gitea.
141
+
142
+ Returns:
143
+ List of repository information dictionaries
144
+ """
145
+ if not self.is_ready:
146
+ raise RuntimeError("Gitea server is not ready")
147
+
148
+ result = subprocess.run(
149
+ [
150
+ "curl",
151
+ "-s",
152
+ f"{self.gitea_url}/api/v1/user/repos",
153
+ "-u",
154
+ f"{self.username}:{self.password}",
155
+ ],
156
+ capture_output=True,
157
+ text=True,
158
+ )
159
+
160
+ if result.returncode != 0:
161
+ return []
162
+
163
+ try:
164
+ repos = json.loads(result.stdout)
165
+ return [
166
+ {
167
+ "name": repo["name"],
168
+ "full_name": repo["full_name"],
169
+ "clone_url": repo["clone_url"],
170
+ "description": repo.get("description", ""),
171
+ }
172
+ for repo in repos
173
+ ]
174
+ except (json.JSONDecodeError, KeyError):
175
+ return []
176
+
177
+ def clone_to_workspace(
178
+ self, repo_name: str, target_dir: str | None = None, commit: str = "main"
179
+ ) -> str:
180
+ """
181
+ Clone a repository to the workspace at a specific commit.
182
+
183
+ This creates a fresh clone optimized for task isolation.
184
+
185
+ Args:
186
+ repo_name: Name of repository to clone
187
+ target_dir: Target directory name (defaults to repo_name)
188
+ commit: Commit hash or branch to checkout
189
+
190
+ Returns:
191
+ Path to cloned repository
192
+
193
+ Raises:
194
+ RuntimeError: If clone fails
195
+ """
196
+ if not self.is_ready:
197
+ raise RuntimeError("Gitea server is not ready")
198
+
199
+ target_dir = target_dir or repo_name
200
+ target_path = self.workspace_dir / target_dir
201
+
202
+ # Remove existing directory if present
203
+ if target_path.exists():
204
+ shutil.rmtree(target_path)
205
+
206
+ clone_url = f"{self.gitea_url}/{self.username}/{repo_name}.git"
207
+
208
+ # Clone repository
209
+ result = subprocess.run(
210
+ ["git", "clone", clone_url, str(target_path)],
211
+ capture_output=True,
212
+ text=True,
213
+ )
214
+
215
+ if result.returncode != 0:
216
+ raise RuntimeError(f"Clone failed: {result.stderr}")
217
+
218
+ # Checkout specific commit
219
+ if commit != "main":
220
+ result = subprocess.run(
221
+ ["git", "checkout", commit],
222
+ cwd=str(target_path),
223
+ capture_output=True,
224
+ text=True,
225
+ )
226
+
227
+ if result.returncode != 0:
228
+ raise RuntimeError(f"Checkout failed: {result.stderr}")
229
+
230
+ return str(target_path)
231
+
232
+ def reset_workspace(self, repo_name: str, commit: str = "main") -> bool:
233
+ """
234
+ Fast reset of workspace to base state (optimized for task resets).
235
+
236
+ This is much faster than re-cloning. It:
237
+ 1. Checks out the target commit
238
+ 2. Resets to that commit (hard)
239
+ 3. Cleans untracked files
240
+
241
+ Args:
242
+ repo_name: Name of repository (directory in workspace)
243
+ commit: Commit hash or branch to reset to
244
+
245
+ Returns:
246
+ True if reset successful
247
+
248
+ Raises:
249
+ RuntimeError: If reset fails
250
+ """
251
+ repo_path = self.workspace_dir / repo_name
252
+
253
+ if not repo_path.exists():
254
+ raise RuntimeError(f"Repository not found in workspace: {repo_name}")
255
+
256
+ # Fetch latest (in case commit is new)
257
+ subprocess.run(
258
+ ["git", "fetch", "--all"],
259
+ cwd=str(repo_path),
260
+ capture_output=True,
261
+ )
262
+
263
+ # Checkout and hard reset to commit
264
+ result = subprocess.run(
265
+ ["git", "checkout", commit],
266
+ cwd=str(repo_path),
267
+ capture_output=True,
268
+ text=True,
269
+ )
270
+
271
+ if result.returncode != 0:
272
+ raise RuntimeError(f"Checkout failed: {result.stderr}")
273
+
274
+ result = subprocess.run(
275
+ ["git", "reset", "--hard", f"origin/{commit}" if commit != "main" else commit],
276
+ cwd=str(repo_path),
277
+ capture_output=True,
278
+ text=True,
279
+ )
280
+
281
+ if result.returncode != 0:
282
+ # Try without origin/ prefix
283
+ result = subprocess.run(
284
+ ["git", "reset", "--hard", commit],
285
+ cwd=str(repo_path),
286
+ capture_output=True,
287
+ text=True,
288
+ )
289
+ if result.returncode != 0:
290
+ raise RuntimeError(f"Reset failed: {result.stderr}")
291
+
292
+ # Clean untracked files and directories
293
+ subprocess.run(
294
+ ["git", "clean", "-fdx"],
295
+ cwd=str(repo_path),
296
+ capture_output=True,
297
+ )
298
+
299
+ return True
300
+
301
+ def execute_git_command(
302
+ self, command: str, working_dir: str = ""
303
+ ) -> tuple[int, str, str]:
304
+ """
305
+ Execute a git command in the workspace.
306
+
307
+ Args:
308
+ command: Git command to execute (without 'git' prefix)
309
+ working_dir: Working directory relative to workspace
310
+
311
+ Returns:
312
+ Tuple of (exit_code, stdout, stderr)
313
+ """
314
+ work_path = (
315
+ self.workspace_dir / working_dir if working_dir else self.workspace_dir
316
+ )
317
+
318
+ if not work_path.exists():
319
+ return (1, "", f"Working directory does not exist: {work_path}")
320
+
321
+ # Split command safely
322
+ cmd_parts = ["git"] + command.split()
323
+
324
+ result = subprocess.run(
325
+ cmd_parts,
326
+ cwd=str(work_path),
327
+ capture_output=True,
328
+ text=True,
329
+ )
330
+
331
+ return (result.returncode, result.stdout, result.stderr)
332
+
333
+ def get_current_commit(self, repo_name: str) -> str:
334
+ """
335
+ Get current commit hash of a workspace repository.
336
+
337
+ Args:
338
+ repo_name: Name of repository in workspace
339
+
340
+ Returns:
341
+ Commit hash
342
+ """
343
+ repo_path = self.workspace_dir / repo_name
344
+
345
+ if not repo_path.exists():
346
+ raise RuntimeError(f"Repository not found: {repo_name}")
347
+
348
+ result = subprocess.run(
349
+ ["git", "rev-parse", "HEAD"],
350
+ cwd=str(repo_path),
351
+ capture_output=True,
352
+ text=True,
353
+ )
354
+
355
+ if result.returncode != 0:
356
+ raise RuntimeError(f"Failed to get commit: {result.stderr}")
357
+
358
+ return result.stdout.strip()
359
+
360
+ def workspace_exists(self, repo_name: str) -> bool:
361
+ """Check if a repository exists in workspace."""
362
+ return (self.workspace_dir / repo_name).exists()
src/core/build/lib/openenv_core/tools/local_python_executor.py ADDED
@@ -0,0 +1,105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ """
8
+ Local Python Executor.
9
+
10
+ This module provides functionality for executing Python code locally by wrapping
11
+ the smolagents LocalPythonExecutor.
12
+ """
13
+
14
+ from smolagents import LocalPythonExecutor
15
+
16
+ from core.env_server.types import CodeExecResult
17
+
18
+
19
+ class PyExecutor:
20
+ """
21
+ Wrapper around smolagents LocalPythonExecutor for executing Python code.
22
+
23
+ This class provides a simple interface to execute Python code in a subprocess
24
+ and capture the results including stdout, stderr, and exit code.
25
+
26
+ Args:
27
+ additional_imports: List of additional module imports to authorize.
28
+ For example: ["numpy", "pandas", "matplotlib"]
29
+ These will be added to the base authorized imports.
30
+
31
+ Example:
32
+ >>> # Basic usage with default imports
33
+ >>> executor = PyExecutor()
34
+ >>> result = executor.run("print('Hello, World!')")
35
+ >>> print(result.stdout) # "Hello, World!\n"
36
+ >>> print(result.exit_code) # 0
37
+ >>>
38
+ >>> # Usage with additional imports
39
+ >>> executor = PyExecutor(additional_imports=["numpy", "pandas"])
40
+ >>> result = executor.run("import numpy as np\\nprint(np.array([1, 2, 3]))")
41
+ >>> print(result.stdout) # "[1 2 3]\n"
42
+ """
43
+
44
+ def __init__(self, additional_imports: list[str] | None = None):
45
+ """
46
+ Initialize the PyExecutor with a LocalPythonExecutor instance.
47
+
48
+ Args:
49
+ additional_imports: List of additional module names to authorize for import.
50
+ Defaults to an empty list if not provided.
51
+ """
52
+ if additional_imports is None:
53
+ additional_imports = []
54
+ self._executor = LocalPythonExecutor(
55
+ additional_authorized_imports=additional_imports
56
+ )
57
+ # Initialize tools to make BASE_PYTHON_TOOLS available (including print)
58
+ self._executor.send_tools({})
59
+
60
+ def run(self, code: str) -> CodeExecResult:
61
+ """
62
+ Execute Python code and return the result.
63
+
64
+ Args:
65
+ code: Python code string to execute
66
+
67
+ Returns:
68
+ CodeExecResult containing stdout, stderr, and exit_code
69
+
70
+ Example:
71
+ >>> executor = PyExecutor()
72
+ >>> result = executor.run("x = 5 + 3\\nprint(x)")
73
+ >>> print(result.stdout) # "8\n"
74
+ >>> print(result.exit_code) # 0
75
+ >>>
76
+ >>> # Error handling
77
+ >>> result = executor.run("1 / 0")
78
+ >>> print(result.exit_code) # 1
79
+ >>> print(result.stderr) # Contains error message
80
+ """
81
+ try:
82
+ # Execute the code using LocalPythonExecutor
83
+ # LocalPythonExecutor returns a CodeOutput object with output, logs, is_final_answer
84
+ exec_result = self._executor(code)
85
+
86
+ # Extract the logs (which contain print outputs) as stdout
87
+ # The output field contains the return value of the code
88
+ stdout = exec_result.logs
89
+ stderr = ""
90
+ exit_code = 0 # Success
91
+
92
+ return CodeExecResult(
93
+ stdout=stdout,
94
+ stderr=stderr,
95
+ exit_code=exit_code,
96
+ )
97
+
98
+ except Exception as e:
99
+ # LocalPythonExecutor raises InterpreterError for various issues
100
+ # (syntax errors, forbidden operations, runtime errors, etc.)
101
+ return CodeExecResult(
102
+ stdout="",
103
+ stderr=str(e),
104
+ exit_code=1, # Non-zero indicates error
105
+ )
src/core/dist/openenv_core-0.1.0-py3-none-any.whl ADDED
Binary file (46.5 kB). View file
 
src/core/openenv_core.egg-info/PKG-INFO ADDED
@@ -0,0 +1,204 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Metadata-Version: 2.4
2
+ Name: openenv-core
3
+ Version: 0.1.0
4
+ Summary: Core components for OpenEnv - HTTP-based agentic environments
5
+ Author-email: "Meta Platforms, Inc." <opensource@meta.com>
6
+ License: BSD-3-Clause
7
+ Project-URL: Homepage, https://github.com/facebookresearch/OpenEnv
8
+ Project-URL: Repository, https://github.com/facebookresearch/OpenEnv
9
+ Project-URL: Documentation, https://github.com/facebookresearch/OpenEnv/blob/main/README.md
10
+ Project-URL: Bug Tracker, https://github.com/facebookresearch/OpenEnv/issues
11
+ Keywords: environment,agent,http,docker,fastapi
12
+ Requires-Python: >=3.8
13
+ Description-Content-Type: text/markdown
14
+ Requires-Dist: requests>=2.25.0
15
+ Requires-Dist: fastapi>=0.104.0
16
+ Requires-Dist: uvicorn>=0.24.0
17
+ Requires-Dist: huggingface_hub>=0.20.0
18
+ Requires-Dist: rich>=13.0.0
19
+ Provides-Extra: dev
20
+ Requires-Dist: pytest>=7.0.0; extra == "dev"
21
+ Requires-Dist: black>=23.0.0; extra == "dev"
22
+ Requires-Dist: ruff>=0.1.0; extra == "dev"
23
+ Requires-Dist: mypy>=1.0.0; extra == "dev"
24
+
25
+ # <img width="35" height="35" alt="image" src="https://github.com/user-attachments/assets/2700a971-e5d6-4036-b03f-2f89c9791609" /> OpenEnv: Agentic Execution Environments
26
+
27
+ An e2e framework for creating, deploying and using isolated execution environments for agentic RL training, built using Gymnasium style simple APIs. OpenEnv provides a standard for interacting with agentic execution environments via simple Gymnasium style APIs - step(), reset(), state(). Users of agentic execution environments can interact with the environment during RL training loops using these simple APIs.
28
+
29
+ In addition to making it easier for researchers and RL framework writers, we also provide tools for environment creators making it easier for them to create richer environments and make them available over familar protocols like HTTP and packaged using canonical technologies like docker. Environment creators can use the OpenEnv framework to create environments that are isolated, secure, and easy to deploy and use.
30
+
31
+
32
+ ## Overview
33
+ `openenv-core` provides the foundational building blocks for creating and interacting with containerized environments over HTTP. It enables you to build agent environments that can be deployed as Docker containers and accessed via a simple HTTP API.
34
+
35
+ > ⚠️ **Early Development Warning** OpenEnv is currently in an experimental
36
+ > stage. You should expect bugs, incomplete features, and APIs that may change
37
+ > in future versions. The project welcomes bugfixes, but to make sure things are
38
+ > well coordinated you should discuss any significant change before starting the
39
+ > work. It's recommended that you signal your intention to contribute in the
40
+ > issue tracker, either by filing a new issue or by claiming an existing one.
41
+
42
+
43
+ # OpenEnv Core
44
+
45
+ Core components for OpenEnv - a framework for building HTTP-based agentic environments.
46
+
47
+ ## Features
48
+
49
+ - **HTTPEnvClient**: Generic HTTP client for interacting with remote environments
50
+ - **HTTPEnvServer**: FastAPI-based server wrapper for exposing environments over HTTP
51
+ - **Container Providers**: Pluggable architecture for running containers (Docker, Kubernetes, etc.)
52
+ - **Type System**: Strongly-typed Action/Observation/State interfaces
53
+ - **Web Interface**: Optional web UI for interacting with environments
54
+
55
+ ## Installation
56
+
57
+ ```bash
58
+ pip install openenv-core
59
+ ```
60
+
61
+ For development:
62
+ ```bash
63
+ pip install openenv-core[dev]
64
+ ```
65
+
66
+ ## Quick Start
67
+
68
+ ### Creating an Environment Client
69
+
70
+ ```python
71
+ from openenv_core import HTTPEnvClient, StepResult
72
+ from dataclasses import dataclass
73
+
74
+ @dataclass
75
+ class MyAction:
76
+ text: str
77
+
78
+ @dataclass
79
+ class MyObservation:
80
+ response: str
81
+
82
+ class MyEnvClient(HTTPEnvClient[MyAction, MyObservation]):
83
+ def _step_payload(self, action: MyAction) -> dict:
84
+ return {"text": action.text}
85
+
86
+ def _parse_result(self, payload: dict) -> StepResult[MyObservation]:
87
+ obs_data = payload["observation"]
88
+ return StepResult(
89
+ observation=MyObservation(**obs_data),
90
+ reward=payload.get("reward"),
91
+ done=payload.get("done", False)
92
+ )
93
+
94
+ def _parse_state(self, payload: dict) -> Any:
95
+ return payload
96
+
97
+ # Use with Docker
98
+ env = MyEnvClient.from_docker_image("my-env:latest")
99
+ result = env.reset()
100
+ step_result = env.step(MyAction(text="hello"))
101
+ env.close()
102
+ ```
103
+
104
+ ### Creating an Environment Server
105
+
106
+ ```python
107
+ from openenv_core.env_server import Environment, HTTPEnvServer, create_app
108
+ from dataclasses import dataclass
109
+
110
+ @dataclass
111
+ class MyAction:
112
+ text: str
113
+
114
+ @dataclass
115
+ class MyObservation:
116
+ response: str
117
+ reward: float = 0.0
118
+ done: bool = False
119
+
120
+ class MyEnvironment(Environment):
121
+ def reset(self) -> MyObservation:
122
+ return MyObservation(response="Ready")
123
+
124
+ def step(self, action: MyAction) -> MyObservation:
125
+ return MyObservation(
126
+ response=f"Echo: {action.text}",
127
+ reward=1.0,
128
+ done=False
129
+ )
130
+
131
+ # Create FastAPI app
132
+ env = MyEnvironment()
133
+ app = create_app(env, MyAction, MyObservation)
134
+
135
+ # Run with: uvicorn module:app --host 0.0.0.0 --port 8000
136
+ ```
137
+
138
+ ## Container Providers
139
+
140
+ OpenEnv Core supports multiple container providers:
141
+
142
+ ### Local Docker Provider
143
+
144
+ ```python
145
+ from openenv_core.containers.runtime import LocalDockerProvider
146
+
147
+ provider = LocalDockerProvider()
148
+ base_url = provider.start_container("my-env:latest")
149
+ provider.wait_for_ready(base_url)
150
+ # Use environment...
151
+ provider.stop_container()
152
+ ```
153
+
154
+ ### Kubernetes Provider (Coming Soon)
155
+
156
+ ```python
157
+ from openenv_core.containers.runtime import KubernetesProvider
158
+
159
+ provider = KubernetesProvider(namespace="envs")
160
+ base_url = provider.start_container("my-env:latest")
161
+ # Use environment...
162
+ provider.stop_container()
163
+ ```
164
+
165
+
166
+ ## API Reference
167
+
168
+ ### HTTPEnvClient
169
+
170
+ Base class for environment clients with these abstract methods:
171
+
172
+ - `_step_payload(action)`: Convert action to JSON
173
+ - `_parse_result(payload)`: Parse response to StepResult
174
+ - `_parse_state(payload)`: Parse state response
175
+
176
+ ### HTTPEnvServer
177
+
178
+ Server wrapper with these methods:
179
+
180
+ - `register_routes(app)`: Register endpoints on FastAPI app
181
+ - `_deserialize_action(data)`: Convert JSON to Action
182
+ - `_serialize_observation(obs)`: Convert Observation to JSON
183
+
184
+ ### Environment Interface
185
+
186
+ Base interface for environment implementations:
187
+
188
+ - `reset()`: Reset environment and return initial observation
189
+ - `step(action)`: Execute action and return observation
190
+ - `state`: Property returning current environment state
191
+
192
+ ## License
193
+
194
+ This project is licensed under the BSD-3-Clause License - see the LICENSE file for details.
195
+
196
+ ## Contributing
197
+
198
+ Contributions are welcome! Please see the main OpenEnv repository for contribution guidelines.
199
+
200
+ ## Links
201
+
202
+ - **Homepage**: https://github.com/facebookresearch/OpenEnv
203
+ - **Documentation**: https://github.com/facebookresearch/OpenEnv/blob/main/README.md
204
+ - **Bug Tracker**: https://github.com/facebookresearch/OpenEnv/issues
src/core/openenv_core.egg-info/SOURCES.txt ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ README.md
2
+ __init__.py
3
+ client_types.py
4
+ http_env_client.py
5
+ pyproject.toml
6
+ ./__init__.py
7
+ ./client_types.py
8
+ ./http_env_client.py
9
+ ../openenv_cli/__init__.py
10
+ ../openenv_cli/__main__.py
11
+ ../openenv_cli/commands/__init__.py
12
+ ../openenv_cli/commands/push.py
13
+ ../openenv_cli/core/__init__.py
14
+ ../openenv_cli/core/auth.py
15
+ ../openenv_cli/core/builder.py
16
+ ../openenv_cli/core/space.py
17
+ ../openenv_cli/core/uploader.py
18
+ ../openenv_cli/utils/__init__.py
19
+ ../openenv_cli/utils/env_loader.py
20
+ ./containers/__init__.py
21
+ ./containers/test_local_docker_provider.py
22
+ ./containers/runtime/__init__.py
23
+ ./containers/runtime/providers.py
24
+ ./env_server/__init__.py
25
+ ./env_server/base_transforms.py
26
+ ./env_server/http_server.py
27
+ ./env_server/interfaces.py
28
+ ./env_server/types.py
29
+ ./env_server/web_interface.py
30
+ ./tools/__init__.py
31
+ ./tools/git_server_client.py
32
+ ./tools/local_python_executor.py
33
+ containers/__init__.py
34
+ containers/test_local_docker_provider.py
35
+ containers/runtime/__init__.py
36
+ containers/runtime/providers.py
37
+ env_server/__init__.py
38
+ env_server/base_transforms.py
39
+ env_server/http_server.py
40
+ env_server/interfaces.py
41
+ env_server/types.py
42
+ env_server/web_interface.py
43
+ openenv_core.egg-info/PKG-INFO
44
+ openenv_core.egg-info/SOURCES.txt
45
+ openenv_core.egg-info/dependency_links.txt
46
+ openenv_core.egg-info/entry_points.txt
47
+ openenv_core.egg-info/requires.txt
48
+ openenv_core.egg-info/top_level.txt
49
+ tools/__init__.py
50
+ tools/git_server_client.py
51
+ tools/local_python_executor.py
src/core/openenv_core.egg-info/dependency_links.txt ADDED
@@ -0,0 +1 @@
 
 
1
+
src/core/openenv_core.egg-info/entry_points.txt ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ [console_scripts]
2
+ openenv = openenv_cli.__main__:main
src/core/openenv_core.egg-info/requires.txt ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ requests>=2.25.0
2
+ fastapi>=0.104.0
3
+ uvicorn>=0.24.0
4
+ huggingface_hub>=0.20.0
5
+ rich>=13.0.0
6
+
7
+ [dev]
8
+ pytest>=7.0.0
9
+ black>=23.0.0
10
+ ruff>=0.1.0
11
+ mypy>=1.0.0
src/core/openenv_core.egg-info/top_level.txt ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ openenv_cli
2
+ openenv_core
src/core/pyproject.toml CHANGED
@@ -18,8 +18,13 @@ dependencies = [
18
  "requests>=2.25.0",
19
  "fastapi>=0.104.0",
20
  "uvicorn>=0.24.0",
 
 
21
  ]
22
 
 
 
 
23
  [project.optional-dependencies]
24
  dev = [
25
  "pytest>=7.0.0",
@@ -41,6 +46,10 @@ packages = [
41
  "openenv_core.containers",
42
  "openenv_core.containers.runtime",
43
  "openenv_core.env_server",
44
- "openenv_core.tools"
 
 
 
 
45
  ]
46
- package-dir = {"openenv_core" = "."}
 
18
  "requests>=2.25.0",
19
  "fastapi>=0.104.0",
20
  "uvicorn>=0.24.0",
21
+ "huggingface_hub>=0.20.0",
22
+ "rich>=13.0.0",
23
  ]
24
 
25
+ [project.scripts]
26
+ openenv = "openenv_cli.__main__:main"
27
+
28
  [project.optional-dependencies]
29
  dev = [
30
  "pytest>=7.0.0",
 
46
  "openenv_core.containers",
47
  "openenv_core.containers.runtime",
48
  "openenv_core.env_server",
49
+ "openenv_core.tools",
50
+ "openenv_cli",
51
+ "openenv_cli.commands",
52
+ "openenv_cli.core",
53
+ "openenv_cli.utils",
54
  ]
55
+ package-dir = {"openenv_core" = ".", "openenv_cli" = "../openenv_cli"}