openhands-workspace 1.6.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- openhands_workspace-1.6.0/PKG-INFO +7 -0
- openhands_workspace-1.6.0/openhands/workspace/__init__.py +29 -0
- openhands_workspace-1.6.0/openhands/workspace/docker/__init__.py +20 -0
- openhands_workspace-1.6.0/openhands/workspace/docker/dev_workspace.py +116 -0
- openhands_workspace-1.6.0/openhands/workspace/docker/workspace.py +338 -0
- openhands_workspace-1.6.0/openhands/workspace/py.typed +0 -0
- openhands_workspace-1.6.0/openhands/workspace/remote_api/__init__.py +6 -0
- openhands_workspace-1.6.0/openhands/workspace/remote_api/workspace.py +356 -0
- openhands_workspace-1.6.0/openhands_workspace.egg-info/PKG-INFO +7 -0
- openhands_workspace-1.6.0/openhands_workspace.egg-info/SOURCES.txt +20 -0
- openhands_workspace-1.6.0/openhands_workspace.egg-info/dependency_links.txt +1 -0
- openhands_workspace-1.6.0/openhands_workspace.egg-info/requires.txt +2 -0
- openhands_workspace-1.6.0/openhands_workspace.egg-info/top_level.txt +1 -0
- openhands_workspace-1.6.0/pyproject.toml +24 -0
- openhands_workspace-1.6.0/setup.cfg +4 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""OpenHands Workspace - Docker and container-based workspace implementations."""
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
from openhands.sdk.workspace import PlatformType, TargetType
|
|
6
|
+
|
|
7
|
+
from .docker import DockerWorkspace
|
|
8
|
+
from .remote_api import APIRemoteWorkspace
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from .docker import DockerDevWorkspace
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"APIRemoteWorkspace",
|
|
16
|
+
"DockerDevWorkspace",
|
|
17
|
+
"DockerWorkspace",
|
|
18
|
+
"PlatformType",
|
|
19
|
+
"TargetType",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def __getattr__(name: str):
|
|
24
|
+
"""Lazy import DockerDevWorkspace to avoid build module imports."""
|
|
25
|
+
if name == "DockerDevWorkspace":
|
|
26
|
+
from .docker import DockerDevWorkspace
|
|
27
|
+
|
|
28
|
+
return DockerDevWorkspace
|
|
29
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Docker workspace implementation."""
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
from .workspace import DockerWorkspace
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from .dev_workspace import DockerDevWorkspace
|
|
10
|
+
|
|
11
|
+
__all__ = ["DockerWorkspace", "DockerDevWorkspace"]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def __getattr__(name: str):
|
|
15
|
+
"""Lazy import DockerDevWorkspace to avoid build module imports."""
|
|
16
|
+
if name == "DockerDevWorkspace":
|
|
17
|
+
from .dev_workspace import DockerDevWorkspace
|
|
18
|
+
|
|
19
|
+
return DockerDevWorkspace
|
|
20
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Docker development workspace with on-the-fly image building capability."""
|
|
2
|
+
|
|
3
|
+
from pydantic import Field, model_validator
|
|
4
|
+
|
|
5
|
+
from openhands.sdk.workspace import PlatformType, TargetType
|
|
6
|
+
|
|
7
|
+
from .workspace import DockerWorkspace
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class DockerDevWorkspace(DockerWorkspace):
|
|
11
|
+
"""Docker workspace with on-the-fly image building capability.
|
|
12
|
+
|
|
13
|
+
This workspace extends DockerWorkspace to support building Docker images
|
|
14
|
+
on-the-fly from a base image. This is useful for development and testing
|
|
15
|
+
scenarios where you need to customize the agent server environment.
|
|
16
|
+
|
|
17
|
+
Note: This class requires the OpenHands SDK workspace structure and should
|
|
18
|
+
only be used within the OpenHands development environment or when you have
|
|
19
|
+
the full SDK source code available.
|
|
20
|
+
|
|
21
|
+
For production use cases with pre-built images, use DockerWorkspace instead.
|
|
22
|
+
|
|
23
|
+
Example:
|
|
24
|
+
with DockerDevWorkspace(
|
|
25
|
+
base_image="python:3.12",
|
|
26
|
+
target="source"
|
|
27
|
+
) as workspace:
|
|
28
|
+
result = workspace.execute_command("ls -la")
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
# Add base_image support
|
|
32
|
+
base_image: str | None = Field(
|
|
33
|
+
default=None,
|
|
34
|
+
description=(
|
|
35
|
+
"Base Docker image to build the agent server from. "
|
|
36
|
+
"Mutually exclusive with server_image."
|
|
37
|
+
),
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
# Add build-specific options
|
|
41
|
+
target: TargetType = Field(
|
|
42
|
+
default="source", description="Build target for the Docker image."
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
@model_validator(mode="after")
|
|
46
|
+
def _validate_images(self):
|
|
47
|
+
"""Ensure exactly one of base_image or server_image is provided."""
|
|
48
|
+
if (self.base_image is None) == (self.server_image is None):
|
|
49
|
+
raise ValueError(
|
|
50
|
+
"Exactly one of 'base_image' or 'server_image' must be set."
|
|
51
|
+
)
|
|
52
|
+
if self.base_image and "ghcr.io/openhands/agent-server" in self.base_image:
|
|
53
|
+
raise ValueError(
|
|
54
|
+
"base_image cannot be a pre-built agent-server image. "
|
|
55
|
+
"Use server_image=... instead."
|
|
56
|
+
)
|
|
57
|
+
return self
|
|
58
|
+
|
|
59
|
+
@staticmethod
|
|
60
|
+
def _build_image_from_base(
|
|
61
|
+
*, base_image: str, target: TargetType, platform: PlatformType
|
|
62
|
+
) -> str:
|
|
63
|
+
"""Build a Docker image from a base image.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
base_image: The base Docker image to build from.
|
|
67
|
+
target: The build target (e.g., 'source', 'dev').
|
|
68
|
+
platform: The platform to build for (e.g., 'linux/amd64').
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
The built Docker image tag.
|
|
72
|
+
|
|
73
|
+
Raises:
|
|
74
|
+
RuntimeError: If the base_image is a pre-built agent-server image
|
|
75
|
+
or if the build fails.
|
|
76
|
+
"""
|
|
77
|
+
from openhands.agent_server.docker.build import BuildOptions, build
|
|
78
|
+
|
|
79
|
+
if "ghcr.io/openhands/agent-server" in base_image:
|
|
80
|
+
raise RuntimeError(
|
|
81
|
+
"base_image cannot be a pre-built agent-server image. "
|
|
82
|
+
"Use server_image=... instead."
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
build_opts = BuildOptions(
|
|
86
|
+
base_image=base_image,
|
|
87
|
+
target=target,
|
|
88
|
+
platforms=[platform],
|
|
89
|
+
push=False,
|
|
90
|
+
)
|
|
91
|
+
tags = build(opts=build_opts)
|
|
92
|
+
if not tags:
|
|
93
|
+
raise RuntimeError("Build failed, no image tags returned")
|
|
94
|
+
return tags[0]
|
|
95
|
+
|
|
96
|
+
def get_image(self) -> str:
|
|
97
|
+
"""Build the image if base_image is provided, otherwise use server_image.
|
|
98
|
+
|
|
99
|
+
This overrides the parent method to add on-the-fly image building
|
|
100
|
+
capability.
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
The Docker image tag to use.
|
|
104
|
+
"""
|
|
105
|
+
if self.base_image:
|
|
106
|
+
# Build the image from base_image
|
|
107
|
+
return self._build_image_from_base(
|
|
108
|
+
base_image=self.base_image,
|
|
109
|
+
target=self.target,
|
|
110
|
+
platform=self.platform,
|
|
111
|
+
)
|
|
112
|
+
elif self.server_image:
|
|
113
|
+
# Use pre-built image
|
|
114
|
+
return self.server_image
|
|
115
|
+
else:
|
|
116
|
+
raise ValueError("Either base_image or server_image must be set")
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
"""Docker-based remote workspace implementation."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
import threading
|
|
7
|
+
import time
|
|
8
|
+
import uuid
|
|
9
|
+
from typing import Any
|
|
10
|
+
from urllib.request import urlopen
|
|
11
|
+
|
|
12
|
+
from pydantic import Field, PrivateAttr, model_validator
|
|
13
|
+
|
|
14
|
+
from openhands.sdk.logger import get_logger
|
|
15
|
+
from openhands.sdk.utils.command import execute_command
|
|
16
|
+
from openhands.sdk.workspace import PlatformType, RemoteWorkspace
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
logger = get_logger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def check_port_available(port: int) -> bool:
|
|
23
|
+
"""Check if a port is available for binding."""
|
|
24
|
+
import socket
|
|
25
|
+
|
|
26
|
+
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
27
|
+
try:
|
|
28
|
+
sock.bind(("0.0.0.0", port))
|
|
29
|
+
return True
|
|
30
|
+
except OSError:
|
|
31
|
+
time.sleep(0.1)
|
|
32
|
+
return False
|
|
33
|
+
finally:
|
|
34
|
+
sock.close()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def find_available_tcp_port(
|
|
38
|
+
min_port: int = 30000, max_port: int = 39999, max_attempts: int = 50
|
|
39
|
+
) -> int:
|
|
40
|
+
"""Find an available TCP port in a specified range."""
|
|
41
|
+
import random
|
|
42
|
+
|
|
43
|
+
rng = random.SystemRandom()
|
|
44
|
+
ports = list(range(min_port, max_port + 1))
|
|
45
|
+
rng.shuffle(ports)
|
|
46
|
+
|
|
47
|
+
for port in ports[:max_attempts]:
|
|
48
|
+
if check_port_available(port):
|
|
49
|
+
return port
|
|
50
|
+
return -1
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class DockerWorkspace(RemoteWorkspace):
|
|
54
|
+
"""Remote workspace that sets up and manages a Docker container.
|
|
55
|
+
|
|
56
|
+
This workspace creates a Docker container running a pre-built OpenHands agent
|
|
57
|
+
server image, waits for it to become healthy, and then provides remote workspace
|
|
58
|
+
operations through the container's HTTP API.
|
|
59
|
+
|
|
60
|
+
Note: This class only works with pre-built images. To build images on-the-fly
|
|
61
|
+
from a base image, use DockerDevWorkspace instead.
|
|
62
|
+
|
|
63
|
+
Example:
|
|
64
|
+
with DockerWorkspace(
|
|
65
|
+
server_image="ghcr.io/openhands/agent-server:latest"
|
|
66
|
+
) as workspace:
|
|
67
|
+
result = workspace.execute_command("ls -la")
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
# Override parent fields with defaults
|
|
71
|
+
working_dir: str = Field(
|
|
72
|
+
default="/workspace",
|
|
73
|
+
description="Working directory inside the container.",
|
|
74
|
+
)
|
|
75
|
+
host: str = Field(
|
|
76
|
+
default="",
|
|
77
|
+
description=("Remote host URL (set automatically during container startup)."),
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
# Docker-specific configuration
|
|
81
|
+
server_image: str | None = Field(
|
|
82
|
+
default=None,
|
|
83
|
+
description="Pre-built agent server image to use.",
|
|
84
|
+
)
|
|
85
|
+
host_port: int | None = Field(
|
|
86
|
+
default=None,
|
|
87
|
+
description="Port to bind the container to. If None, finds available port.",
|
|
88
|
+
)
|
|
89
|
+
forward_env: list[str] = Field(
|
|
90
|
+
default_factory=lambda: ["DEBUG"],
|
|
91
|
+
description="Environment variables to forward to the container.",
|
|
92
|
+
)
|
|
93
|
+
mount_dir: str | None = Field(
|
|
94
|
+
default=None,
|
|
95
|
+
description="Optional host directory to mount into the container.",
|
|
96
|
+
)
|
|
97
|
+
detach_logs: bool = Field(
|
|
98
|
+
default=True, description="Whether to stream Docker logs in background."
|
|
99
|
+
)
|
|
100
|
+
platform: PlatformType = Field(
|
|
101
|
+
default="linux/amd64", description="Platform for the Docker image."
|
|
102
|
+
)
|
|
103
|
+
extra_ports: bool = Field(
|
|
104
|
+
default=False,
|
|
105
|
+
description="Whether to expose additional ports (VSCode, VNC).",
|
|
106
|
+
)
|
|
107
|
+
enable_gpu: bool = Field(
|
|
108
|
+
default=False,
|
|
109
|
+
description="Whether to enable GPU support with --gpus all.",
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
_container_id: str | None = PrivateAttr(default=None)
|
|
113
|
+
_logs_thread: threading.Thread | None = PrivateAttr(default=None)
|
|
114
|
+
_stop_logs: threading.Event = PrivateAttr(default_factory=threading.Event)
|
|
115
|
+
|
|
116
|
+
@model_validator(mode="after")
|
|
117
|
+
def _validate_server_image(self):
|
|
118
|
+
"""Ensure server_image is set when using DockerWorkspace directly."""
|
|
119
|
+
if self.__class__ is DockerWorkspace and self.server_image is None:
|
|
120
|
+
raise ValueError("server_image must be provided")
|
|
121
|
+
return self
|
|
122
|
+
|
|
123
|
+
def model_post_init(self, context: Any) -> None:
|
|
124
|
+
"""Set up the Docker container and initialize the remote workspace."""
|
|
125
|
+
# Subclasses should call get_image() to get the image to use
|
|
126
|
+
# This allows them to build or prepare the image before container startup
|
|
127
|
+
image = self.get_image()
|
|
128
|
+
self._start_container(image, context)
|
|
129
|
+
|
|
130
|
+
def get_image(self) -> str:
|
|
131
|
+
"""Get the Docker image to use for the container.
|
|
132
|
+
|
|
133
|
+
Subclasses can override this to provide custom image resolution logic
|
|
134
|
+
(e.g., building images on-the-fly).
|
|
135
|
+
|
|
136
|
+
Returns:
|
|
137
|
+
The Docker image tag to use.
|
|
138
|
+
"""
|
|
139
|
+
if self.server_image is None:
|
|
140
|
+
raise ValueError("server_image must be set")
|
|
141
|
+
return self.server_image
|
|
142
|
+
|
|
143
|
+
def _start_container(self, image: str, context: Any) -> None:
|
|
144
|
+
"""Start the Docker container with the given image.
|
|
145
|
+
|
|
146
|
+
This method handles all container lifecycle: port allocation, Docker
|
|
147
|
+
validation, container creation, health checks, and RemoteWorkspace
|
|
148
|
+
initialization.
|
|
149
|
+
|
|
150
|
+
Args:
|
|
151
|
+
image: The Docker image tag to use.
|
|
152
|
+
context: The Pydantic context from model_post_init.
|
|
153
|
+
"""
|
|
154
|
+
# Determine port
|
|
155
|
+
if self.host_port is None:
|
|
156
|
+
self.host_port = find_available_tcp_port()
|
|
157
|
+
else:
|
|
158
|
+
self.host_port = int(self.host_port)
|
|
159
|
+
|
|
160
|
+
if not check_port_available(self.host_port):
|
|
161
|
+
raise RuntimeError(f"Port {self.host_port} is not available")
|
|
162
|
+
|
|
163
|
+
if self.extra_ports:
|
|
164
|
+
if not check_port_available(self.host_port + 1):
|
|
165
|
+
raise RuntimeError(
|
|
166
|
+
f"Port {self.host_port + 1} is not available for VSCode"
|
|
167
|
+
)
|
|
168
|
+
if not check_port_available(self.host_port + 2):
|
|
169
|
+
raise RuntimeError(
|
|
170
|
+
f"Port {self.host_port + 2} is not available for VNC"
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
# Ensure docker is available
|
|
174
|
+
docker_ver = execute_command(["docker", "version"]).returncode
|
|
175
|
+
if docker_ver != 0:
|
|
176
|
+
raise RuntimeError(
|
|
177
|
+
"Docker is not available. Please install and start "
|
|
178
|
+
"Docker Desktop/daemon."
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
# Prepare Docker run flags
|
|
182
|
+
flags: list[str] = []
|
|
183
|
+
for key in self.forward_env:
|
|
184
|
+
if key in os.environ:
|
|
185
|
+
flags += ["-e", f"{key}={os.environ[key]}"]
|
|
186
|
+
|
|
187
|
+
if self.mount_dir:
|
|
188
|
+
mount_path = "/workspace"
|
|
189
|
+
flags += ["-v", f"{self.mount_dir}:{mount_path}"]
|
|
190
|
+
logger.info(
|
|
191
|
+
"Mounting host dir %s to container path %s",
|
|
192
|
+
self.mount_dir,
|
|
193
|
+
mount_path,
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
ports = ["-p", f"{self.host_port}:8000"]
|
|
197
|
+
if self.extra_ports:
|
|
198
|
+
ports += [
|
|
199
|
+
"-p",
|
|
200
|
+
f"{self.host_port + 1}:8001", # VSCode
|
|
201
|
+
"-p",
|
|
202
|
+
f"{self.host_port + 2}:8002", # Desktop VNC
|
|
203
|
+
]
|
|
204
|
+
flags += ports
|
|
205
|
+
|
|
206
|
+
# Add GPU support if enabled
|
|
207
|
+
if self.enable_gpu:
|
|
208
|
+
flags += ["--gpus", "all"]
|
|
209
|
+
|
|
210
|
+
# Run container
|
|
211
|
+
run_cmd = [
|
|
212
|
+
"docker",
|
|
213
|
+
"run",
|
|
214
|
+
"-d",
|
|
215
|
+
"--platform",
|
|
216
|
+
self.platform,
|
|
217
|
+
"--rm",
|
|
218
|
+
"--name",
|
|
219
|
+
f"agent-server-{uuid.uuid4()}",
|
|
220
|
+
*flags,
|
|
221
|
+
image,
|
|
222
|
+
"--host",
|
|
223
|
+
"0.0.0.0",
|
|
224
|
+
"--port",
|
|
225
|
+
"8000",
|
|
226
|
+
]
|
|
227
|
+
proc = execute_command(run_cmd)
|
|
228
|
+
if proc.returncode != 0:
|
|
229
|
+
raise RuntimeError(f"Failed to run docker container: {proc.stderr}")
|
|
230
|
+
|
|
231
|
+
self._container_id = proc.stdout.strip()
|
|
232
|
+
logger.info("Started container: %s", self._container_id)
|
|
233
|
+
|
|
234
|
+
# Optionally stream logs in background
|
|
235
|
+
if self.detach_logs:
|
|
236
|
+
self._logs_thread = threading.Thread(
|
|
237
|
+
target=self._stream_docker_logs, daemon=True
|
|
238
|
+
)
|
|
239
|
+
self._logs_thread.start()
|
|
240
|
+
|
|
241
|
+
# Set host for RemoteWorkspace to use
|
|
242
|
+
# The container exposes port 8000, mapped to self.host_port
|
|
243
|
+
# Override parent's host initialization
|
|
244
|
+
object.__setattr__(self, "host", f"http://localhost:{self.host_port}")
|
|
245
|
+
object.__setattr__(self, "api_key", None)
|
|
246
|
+
|
|
247
|
+
# Wait for container to be healthy
|
|
248
|
+
self._wait_for_health()
|
|
249
|
+
logger.info("Docker workspace is ready at %s", self.host)
|
|
250
|
+
|
|
251
|
+
# Now initialize the parent RemoteWorkspace with the container URL
|
|
252
|
+
super().model_post_init(context)
|
|
253
|
+
|
|
254
|
+
def _stream_docker_logs(self) -> None:
|
|
255
|
+
"""Stream Docker logs to stdout in the background."""
|
|
256
|
+
if not self._container_id:
|
|
257
|
+
return
|
|
258
|
+
try:
|
|
259
|
+
p = subprocess.Popen(
|
|
260
|
+
["docker", "logs", "-f", self._container_id],
|
|
261
|
+
stdout=subprocess.PIPE,
|
|
262
|
+
stderr=subprocess.STDOUT,
|
|
263
|
+
text=True,
|
|
264
|
+
)
|
|
265
|
+
if p.stdout is None:
|
|
266
|
+
return
|
|
267
|
+
for line in iter(p.stdout.readline, ""):
|
|
268
|
+
if self._stop_logs.is_set():
|
|
269
|
+
break
|
|
270
|
+
if line:
|
|
271
|
+
sys.stdout.write(f"[DOCKER] {line}")
|
|
272
|
+
sys.stdout.flush()
|
|
273
|
+
except Exception as e:
|
|
274
|
+
sys.stderr.write(f"Error streaming docker logs: {e}\n")
|
|
275
|
+
finally:
|
|
276
|
+
try:
|
|
277
|
+
self._stop_logs.set()
|
|
278
|
+
except Exception:
|
|
279
|
+
pass
|
|
280
|
+
|
|
281
|
+
def _wait_for_health(self, timeout: float = 120.0) -> None:
|
|
282
|
+
"""Wait for the Docker container to become healthy."""
|
|
283
|
+
start = time.time()
|
|
284
|
+
health_url = f"http://127.0.0.1:{self.host_port}/health"
|
|
285
|
+
|
|
286
|
+
while time.time() - start < timeout:
|
|
287
|
+
try:
|
|
288
|
+
with urlopen(health_url, timeout=1.0) as resp:
|
|
289
|
+
if 200 <= getattr(resp, "status", 200) < 300:
|
|
290
|
+
return
|
|
291
|
+
except Exception:
|
|
292
|
+
pass
|
|
293
|
+
|
|
294
|
+
# Check if container is still running
|
|
295
|
+
if self._container_id:
|
|
296
|
+
ps = execute_command(
|
|
297
|
+
[
|
|
298
|
+
"docker",
|
|
299
|
+
"inspect",
|
|
300
|
+
"-f",
|
|
301
|
+
"{{.State.Running}}",
|
|
302
|
+
self._container_id,
|
|
303
|
+
]
|
|
304
|
+
)
|
|
305
|
+
if ps.stdout.strip() != "true":
|
|
306
|
+
logs = execute_command(["docker", "logs", self._container_id])
|
|
307
|
+
msg = (
|
|
308
|
+
"Container stopped unexpectedly. Logs:\n"
|
|
309
|
+
f"{logs.stdout}\n{logs.stderr}"
|
|
310
|
+
)
|
|
311
|
+
raise RuntimeError(msg)
|
|
312
|
+
time.sleep(1)
|
|
313
|
+
raise RuntimeError("Container failed to become healthy in time")
|
|
314
|
+
|
|
315
|
+
def __enter__(self) -> "DockerWorkspace":
|
|
316
|
+
"""Context manager entry - returns the workspace itself."""
|
|
317
|
+
return self
|
|
318
|
+
|
|
319
|
+
def __exit__(self, exc_type, exc_val, exc_tb) -> None: # type: ignore[no-untyped-def]
|
|
320
|
+
"""Context manager exit - cleans up the Docker container."""
|
|
321
|
+
self.cleanup()
|
|
322
|
+
|
|
323
|
+
def __del__(self) -> None:
|
|
324
|
+
"""Clean up the Docker container when the workspace is destroyed."""
|
|
325
|
+
self.cleanup()
|
|
326
|
+
|
|
327
|
+
def cleanup(self) -> None:
|
|
328
|
+
"""Stop and remove the Docker container."""
|
|
329
|
+
if self._container_id:
|
|
330
|
+
# Stop logs streaming
|
|
331
|
+
self._stop_logs.set()
|
|
332
|
+
if self._logs_thread and self._logs_thread.is_alive():
|
|
333
|
+
self._logs_thread.join(timeout=2)
|
|
334
|
+
|
|
335
|
+
# Stop and remove the container
|
|
336
|
+
logger.info("Stopping container: %s", self._container_id)
|
|
337
|
+
execute_command(["docker", "stop", self._container_id])
|
|
338
|
+
self._container_id = None
|
|
File without changes
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
"""API-based remote workspace implementation using runtime API."""
|
|
2
|
+
|
|
3
|
+
import uuid
|
|
4
|
+
from typing import Any, Literal
|
|
5
|
+
from urllib.request import urlopen
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
import tenacity
|
|
9
|
+
from pydantic import Field, PrivateAttr
|
|
10
|
+
|
|
11
|
+
from openhands.sdk.logger import get_logger
|
|
12
|
+
from openhands.sdk.workspace.remote.base import RemoteWorkspace
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
logger = get_logger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class APIRemoteWorkspace(RemoteWorkspace):
|
|
19
|
+
"""Remote workspace using OpenHands runtime API.
|
|
20
|
+
|
|
21
|
+
Runtime API: https://runtime.all-hands.dev/
|
|
22
|
+
|
|
23
|
+
Example:
|
|
24
|
+
workspace = APIRemoteWorkspace(
|
|
25
|
+
runtime_api_url="https://runtime.eval.all-hands.dev",
|
|
26
|
+
runtime_api_key="your-api-key",
|
|
27
|
+
server_image="ghcr.io/openhands/agent-server:lastest-python",
|
|
28
|
+
)
|
|
29
|
+
""" # noqa: E501
|
|
30
|
+
|
|
31
|
+
# Parent fields
|
|
32
|
+
working_dir: str = Field(
|
|
33
|
+
default="/workspace",
|
|
34
|
+
description="Working directory inside the remote workspace",
|
|
35
|
+
)
|
|
36
|
+
host: str = Field(
|
|
37
|
+
default="undefined",
|
|
38
|
+
description="The remote host URL for the workspace."
|
|
39
|
+
" It will be set to the runtime URL after connecting.",
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
# Runtime API fields
|
|
43
|
+
runtime_api_url: str = Field(description="Base URL of the runtime API")
|
|
44
|
+
runtime_api_key: str = Field(description="API key for authentication")
|
|
45
|
+
server_image: str = Field(
|
|
46
|
+
description="Container image for the agent server. "
|
|
47
|
+
"It must be a public image or in a registry accessible by runtime API."
|
|
48
|
+
)
|
|
49
|
+
image_pull_policy: Literal["Always", "IfNotPresent", "Never"] = Field(
|
|
50
|
+
default="IfNotPresent",
|
|
51
|
+
description="Image pull policy for the API",
|
|
52
|
+
)
|
|
53
|
+
session_id: str | None = Field(
|
|
54
|
+
default_factory=lambda: f"agent-server-{uuid.uuid4()}",
|
|
55
|
+
description="Session ID (auto-generated if None)",
|
|
56
|
+
)
|
|
57
|
+
resource_factor: int = Field(
|
|
58
|
+
default=1, description="Resource scaling (1, 2, 4, or 8)"
|
|
59
|
+
)
|
|
60
|
+
runtime_class: str | None = Field(
|
|
61
|
+
default="sysbox-runc", description="Runtime class (e.g., 'sysbox')"
|
|
62
|
+
)
|
|
63
|
+
init_timeout: float = Field(
|
|
64
|
+
default=300.0, description="Runtime init timeout (seconds)"
|
|
65
|
+
)
|
|
66
|
+
api_timeout: float = Field(
|
|
67
|
+
default=60.0, description="API request timeout (seconds)"
|
|
68
|
+
)
|
|
69
|
+
keep_alive: bool = Field(default=False, description="Keep runtime alive on cleanup")
|
|
70
|
+
pause_on_close: bool = Field(
|
|
71
|
+
default=False, description="Pause instead of stop on cleanup"
|
|
72
|
+
)
|
|
73
|
+
target_type: Literal["binary", "source"] = Field(
|
|
74
|
+
default="binary",
|
|
75
|
+
description="Type of agent server target (binary or source)",
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
_runtime_id: str | None = PrivateAttr(default=None)
|
|
79
|
+
_runtime_url: str | None = PrivateAttr(default=None)
|
|
80
|
+
_session_api_key: str | None = PrivateAttr(default=None)
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def client(self) -> httpx.Client:
|
|
84
|
+
"""Override client property to use api_timeout for HTTP requests."""
|
|
85
|
+
client = self._client
|
|
86
|
+
if client is None:
|
|
87
|
+
# Use api_timeout for the read timeout to allow longer operations
|
|
88
|
+
timeout = httpx.Timeout(
|
|
89
|
+
connect=10.0,
|
|
90
|
+
read=self.api_timeout,
|
|
91
|
+
write=10.0,
|
|
92
|
+
pool=10.0,
|
|
93
|
+
)
|
|
94
|
+
client = httpx.Client(
|
|
95
|
+
base_url=self.host, timeout=timeout, headers=self._headers
|
|
96
|
+
)
|
|
97
|
+
self._client = client
|
|
98
|
+
return client
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def _api_headers(self):
|
|
102
|
+
"""Headers for runtime API requests."
|
|
103
|
+
|
|
104
|
+
This is used to manage new container runtimes via Runtime API.
|
|
105
|
+
|
|
106
|
+
For actual interaction with the remote agent server, the
|
|
107
|
+
`client` property is used, which includes the session API key
|
|
108
|
+
defined by ._headers property.
|
|
109
|
+
"""
|
|
110
|
+
headers = {}
|
|
111
|
+
if self.runtime_api_key:
|
|
112
|
+
headers["X-API-Key"] = self.runtime_api_key
|
|
113
|
+
return headers
|
|
114
|
+
|
|
115
|
+
def model_post_init(self, context: Any) -> None:
|
|
116
|
+
"""Set up the remote runtime and initialize the workspace."""
|
|
117
|
+
if self.resource_factor not in [1, 2, 4, 8]:
|
|
118
|
+
raise ValueError(
|
|
119
|
+
f"resource_factor must be 1, 2, 4, or 8, got {self.resource_factor}"
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
self.runtime_api_url = self.runtime_api_url.rstrip("/")
|
|
123
|
+
|
|
124
|
+
try:
|
|
125
|
+
self._start_or_attach_to_runtime()
|
|
126
|
+
super().model_post_init(context)
|
|
127
|
+
except Exception:
|
|
128
|
+
self.cleanup()
|
|
129
|
+
raise
|
|
130
|
+
|
|
131
|
+
def _start_or_attach_to_runtime(self) -> None:
|
|
132
|
+
"""Start or attach to an existing runtime."""
|
|
133
|
+
if not self._check_existing_runtime():
|
|
134
|
+
self._start_runtime()
|
|
135
|
+
|
|
136
|
+
assert self._runtime_id and self._runtime_url, "Runtime ID/URL not set"
|
|
137
|
+
self._wait_until_runtime_alive()
|
|
138
|
+
logger.info(f"Runtime ready at {self._runtime_url}")
|
|
139
|
+
self.host = self._runtime_url.rstrip("/")
|
|
140
|
+
self.api_key = self._session_api_key
|
|
141
|
+
# Reset HTTP client with new host and API key
|
|
142
|
+
self.reset_client()
|
|
143
|
+
# Verify client is properly initialized
|
|
144
|
+
assert self.client is not None
|
|
145
|
+
assert self.client.base_url == self.host
|
|
146
|
+
|
|
147
|
+
def _check_existing_runtime(self) -> bool:
|
|
148
|
+
"""Check if there's an existing runtime for this session."""
|
|
149
|
+
try:
|
|
150
|
+
resp = self._send_api_request(
|
|
151
|
+
"GET",
|
|
152
|
+
f"{self.runtime_api_url}/sessions/{self.session_id}",
|
|
153
|
+
headers=self._api_headers,
|
|
154
|
+
)
|
|
155
|
+
data = resp.json()
|
|
156
|
+
status = data.get("status")
|
|
157
|
+
logger.info(f"Runtime status: {status}")
|
|
158
|
+
|
|
159
|
+
if status in ("running", "paused"):
|
|
160
|
+
self._parse_runtime_response(resp)
|
|
161
|
+
if status == "paused":
|
|
162
|
+
try:
|
|
163
|
+
self._resume_runtime()
|
|
164
|
+
except Exception as e:
|
|
165
|
+
logger.error(f"Resume failed: {e}")
|
|
166
|
+
return False
|
|
167
|
+
return True
|
|
168
|
+
return False
|
|
169
|
+
except httpx.HTTPStatusError as e:
|
|
170
|
+
if e.response.status_code == 404:
|
|
171
|
+
return False
|
|
172
|
+
raise
|
|
173
|
+
|
|
174
|
+
def _start_runtime(self) -> None:
|
|
175
|
+
"""Start a new runtime."""
|
|
176
|
+
if self.target_type == "binary":
|
|
177
|
+
executable = "/usr/local/bin/openhands-agent-server"
|
|
178
|
+
else:
|
|
179
|
+
executable = "/agent-server/.venv/bin/python -m openhands.agent_server"
|
|
180
|
+
# For binary target, use the standalone binary
|
|
181
|
+
payload: dict[str, Any] = {
|
|
182
|
+
"image": self.server_image,
|
|
183
|
+
"command": f"{executable} --port 60000",
|
|
184
|
+
"working_dir": "/", # Match Dockerfile WORKDIR
|
|
185
|
+
"environment": {},
|
|
186
|
+
"session_id": self.session_id,
|
|
187
|
+
"run_as_user": 10001,
|
|
188
|
+
"fs_group": 10001,
|
|
189
|
+
"image_pull_policy": self.image_pull_policy,
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if self.runtime_class:
|
|
193
|
+
payload["runtime_class"] = self.runtime_class
|
|
194
|
+
if self.resource_factor != 1:
|
|
195
|
+
payload["resource_factor"] = self.resource_factor
|
|
196
|
+
|
|
197
|
+
logger.info(f"Starting runtime with {self.server_image}")
|
|
198
|
+
logger.info(f"Payload: {payload}")
|
|
199
|
+
resp = self._send_api_request(
|
|
200
|
+
"POST",
|
|
201
|
+
f"{self.runtime_api_url}/start",
|
|
202
|
+
json=payload,
|
|
203
|
+
timeout=self.init_timeout,
|
|
204
|
+
headers=self._api_headers,
|
|
205
|
+
)
|
|
206
|
+
self._parse_runtime_response(resp)
|
|
207
|
+
logger.info(f"Runtime {self._runtime_id} at {self._runtime_url}")
|
|
208
|
+
|
|
209
|
+
def _resume_runtime(self) -> None:
|
|
210
|
+
"""Resume a paused runtime."""
|
|
211
|
+
resp = self._send_api_request(
|
|
212
|
+
"POST",
|
|
213
|
+
f"{self.runtime_api_url}/resume",
|
|
214
|
+
json={"runtime_id": self._runtime_id},
|
|
215
|
+
timeout=self.init_timeout,
|
|
216
|
+
headers=self._api_headers,
|
|
217
|
+
)
|
|
218
|
+
self._parse_runtime_response(resp)
|
|
219
|
+
|
|
220
|
+
def _parse_runtime_response(self, response: httpx.Response) -> None:
|
|
221
|
+
"""Parse the runtime response and extract connection info."""
|
|
222
|
+
data = response.json()
|
|
223
|
+
self._runtime_id = data.get("runtime_id") or data.get("id")
|
|
224
|
+
self._runtime_url = data.get("url")
|
|
225
|
+
self._session_api_key = data.get("session_api_key")
|
|
226
|
+
if not self._runtime_id or not self._runtime_url:
|
|
227
|
+
raise ValueError(f"Invalid runtime response: {data}")
|
|
228
|
+
|
|
229
|
+
@tenacity.retry(
|
|
230
|
+
stop=tenacity.stop_after_delay(300),
|
|
231
|
+
wait=tenacity.wait_exponential(multiplier=1, min=2, max=10),
|
|
232
|
+
retry=tenacity.retry_if_exception_type(RuntimeError),
|
|
233
|
+
reraise=True,
|
|
234
|
+
)
|
|
235
|
+
def _wait_until_runtime_alive(self) -> None:
|
|
236
|
+
"""Wait until the runtime becomes alive and responsive."""
|
|
237
|
+
logger.info("Waiting for runtime to become alive...")
|
|
238
|
+
|
|
239
|
+
resp = self._send_api_request(
|
|
240
|
+
"GET",
|
|
241
|
+
f"{self.runtime_api_url}/sessions/{self.session_id}",
|
|
242
|
+
headers=self._api_headers,
|
|
243
|
+
)
|
|
244
|
+
data = resp.json()
|
|
245
|
+
pod_status = data.get("pod_status", "").lower()
|
|
246
|
+
logger.info(f"Pod status: {pod_status}")
|
|
247
|
+
|
|
248
|
+
# Log additional details for debugging
|
|
249
|
+
if pod_status == "pending":
|
|
250
|
+
container_statuses = data.get("container_statuses", [])
|
|
251
|
+
events = data.get("events", [])
|
|
252
|
+
if container_statuses:
|
|
253
|
+
logger.warning(f"Container statuses: {container_statuses}")
|
|
254
|
+
if events:
|
|
255
|
+
logger.warning(f"Pod events: {events}")
|
|
256
|
+
logger.debug(f"Full response: {data}")
|
|
257
|
+
|
|
258
|
+
restart_count = data.get("restart_count", 0)
|
|
259
|
+
if restart_count > 0:
|
|
260
|
+
restart_reasons = data.get("restart_reasons", [])
|
|
261
|
+
logger.warning(f"Pod restarts: {restart_count}, reasons: {restart_reasons}")
|
|
262
|
+
|
|
263
|
+
# Handle different pod states
|
|
264
|
+
if pod_status == "ready":
|
|
265
|
+
# Pod is ready, check health endpoint
|
|
266
|
+
health_url = f"{self._runtime_url}/health"
|
|
267
|
+
logger.info(f"Checking health at: {health_url}")
|
|
268
|
+
try:
|
|
269
|
+
with urlopen(health_url, timeout=5.0) as resp:
|
|
270
|
+
status = getattr(resp, "status", 200)
|
|
271
|
+
logger.info(f"Health check response: {status}")
|
|
272
|
+
if 200 <= status < 300:
|
|
273
|
+
logger.info("Runtime is alive!")
|
|
274
|
+
return
|
|
275
|
+
raise RuntimeError(f"Health check failed with status: {status}")
|
|
276
|
+
except Exception as e:
|
|
277
|
+
logger.warning(f"Health check failed: {e}")
|
|
278
|
+
raise RuntimeError(f"Runtime /health failed: {e}")
|
|
279
|
+
elif pod_status in ("not found", "pending", "running"):
|
|
280
|
+
# Transient states - continue retrying
|
|
281
|
+
logger.debug(f"Runtime not yet ready. Status: {pod_status}")
|
|
282
|
+
raise RuntimeError(f"Runtime not yet ready (status: {pod_status})")
|
|
283
|
+
elif pod_status in ("failed", "unknown", "crashloopbackoff"):
|
|
284
|
+
# Terminal failure states
|
|
285
|
+
pod_logs = data.get("pod_logs", "")
|
|
286
|
+
error_msg = f"Runtime failed (status: {pod_status})"
|
|
287
|
+
if pod_logs:
|
|
288
|
+
logger.error(f"Pod logs: {pod_logs}")
|
|
289
|
+
error_msg += f"\nPod logs: {pod_logs}"
|
|
290
|
+
if pod_status == "crashloopbackoff":
|
|
291
|
+
error_msg = (
|
|
292
|
+
"Runtime crashed and is restarting (possibly OOM). Try again."
|
|
293
|
+
)
|
|
294
|
+
raise ValueError(error_msg)
|
|
295
|
+
else:
|
|
296
|
+
# Unknown status - log and retry
|
|
297
|
+
logger.warning(f"Unknown pod status: {pod_status}, full response: {data}")
|
|
298
|
+
raise RuntimeError(f"Unknown pod status: {pod_status}")
|
|
299
|
+
|
|
300
|
+
def _send_api_request(self, method: str, url: str, **kwargs: Any) -> httpx.Response:
|
|
301
|
+
"""Send an API request with error handling."""
|
|
302
|
+
logger.debug(f"Sending {method} request to {url}")
|
|
303
|
+
logger.debug(f"Request kwargs: {kwargs.keys()}")
|
|
304
|
+
|
|
305
|
+
response = self.client.request(method, url, **kwargs)
|
|
306
|
+
try:
|
|
307
|
+
response.raise_for_status()
|
|
308
|
+
except httpx.HTTPStatusError:
|
|
309
|
+
# Log only header keys, not values (to avoid exposing API keys)
|
|
310
|
+
header_keys = list(response.request.headers.keys())
|
|
311
|
+
logger.debug(f"Request header keys: {header_keys}")
|
|
312
|
+
try:
|
|
313
|
+
error_detail = response.json()
|
|
314
|
+
logger.info(f"API request failed: {error_detail}")
|
|
315
|
+
except Exception:
|
|
316
|
+
logger.info(f"API request failed: {response.text}")
|
|
317
|
+
raise
|
|
318
|
+
return response
|
|
319
|
+
|
|
320
|
+
def cleanup(self) -> None:
|
|
321
|
+
"""Clean up the remote runtime."""
|
|
322
|
+
if not self._runtime_id:
|
|
323
|
+
return
|
|
324
|
+
|
|
325
|
+
try:
|
|
326
|
+
if self.keep_alive:
|
|
327
|
+
return
|
|
328
|
+
|
|
329
|
+
action = "pause" if self.pause_on_close else "stop"
|
|
330
|
+
logger.info(f"{action.capitalize()}ing runtime {self._runtime_id}")
|
|
331
|
+
self._send_api_request(
|
|
332
|
+
"POST",
|
|
333
|
+
f"{self.runtime_api_url}/{action}",
|
|
334
|
+
json={"runtime_id": self._runtime_id},
|
|
335
|
+
timeout=30.0,
|
|
336
|
+
headers=self._api_headers,
|
|
337
|
+
)
|
|
338
|
+
except Exception as e:
|
|
339
|
+
logger.warning(f"Cleanup error: {e}")
|
|
340
|
+
finally:
|
|
341
|
+
self._runtime_id = None
|
|
342
|
+
self._runtime_url = None
|
|
343
|
+
self._session_api_key = None
|
|
344
|
+
try:
|
|
345
|
+
self.client.close()
|
|
346
|
+
except Exception:
|
|
347
|
+
pass
|
|
348
|
+
|
|
349
|
+
def __del__(self) -> None:
|
|
350
|
+
self.cleanup()
|
|
351
|
+
|
|
352
|
+
def __enter__(self) -> "APIRemoteWorkspace":
|
|
353
|
+
return self
|
|
354
|
+
|
|
355
|
+
def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
|
|
356
|
+
self.cleanup()
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
pyproject.toml
|
|
2
|
+
./openhands/workspace/__init__.py
|
|
3
|
+
./openhands/workspace/py.typed
|
|
4
|
+
./openhands/workspace/docker/__init__.py
|
|
5
|
+
./openhands/workspace/docker/dev_workspace.py
|
|
6
|
+
./openhands/workspace/docker/workspace.py
|
|
7
|
+
./openhands/workspace/remote_api/__init__.py
|
|
8
|
+
./openhands/workspace/remote_api/workspace.py
|
|
9
|
+
openhands/workspace/__init__.py
|
|
10
|
+
openhands/workspace/py.typed
|
|
11
|
+
openhands/workspace/docker/__init__.py
|
|
12
|
+
openhands/workspace/docker/dev_workspace.py
|
|
13
|
+
openhands/workspace/docker/workspace.py
|
|
14
|
+
openhands/workspace/remote_api/__init__.py
|
|
15
|
+
openhands/workspace/remote_api/workspace.py
|
|
16
|
+
openhands_workspace.egg-info/PKG-INFO
|
|
17
|
+
openhands_workspace.egg-info/SOURCES.txt
|
|
18
|
+
openhands_workspace.egg-info/dependency_links.txt
|
|
19
|
+
openhands_workspace.egg-info/requires.txt
|
|
20
|
+
openhands_workspace.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
openhands
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "openhands-workspace"
|
|
3
|
+
version = "1.6.0"
|
|
4
|
+
description = "OpenHands Workspace - Docker and container-based workspace implementations"
|
|
5
|
+
|
|
6
|
+
requires-python = ">=3.12"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"openhands-sdk",
|
|
9
|
+
"pydantic>=2.11.7",
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
[build-system]
|
|
13
|
+
requires = ["setuptools>=61.0", "wheel"]
|
|
14
|
+
build-backend = "setuptools.build_meta"
|
|
15
|
+
|
|
16
|
+
[tool.setuptools.package-dir]
|
|
17
|
+
"" = "."
|
|
18
|
+
|
|
19
|
+
[tool.setuptools.packages.find]
|
|
20
|
+
include = ["openhands.workspace*"]
|
|
21
|
+
namespaces = true
|
|
22
|
+
|
|
23
|
+
[tool.setuptools.package-data]
|
|
24
|
+
"*" = ["py.typed"]
|