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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
get_logger,
)
from openhands.tools.preset.default import get_default_agent
from openhands.workspace import DockerWorkspace
from openhands.workspace import DockerDevWorkspace


logger = get_logger(__name__)
Expand All @@ -38,7 +38,7 @@ def detect_platform():

# 2) Create a Docker-based remote workspace that will set up and manage
# the Docker container automatically
with DockerWorkspace(
with DockerDevWorkspace(
# dynamically build agent-server image
base_image="nikolaik/python-nodejs:python3.12-nodejs22",
# use pre-built image for faster startup
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from openhands.sdk import LLM, Conversation, get_logger
from openhands.sdk.conversation.impl.remote_conversation import RemoteConversation
from openhands.tools.preset.default import get_default_agent
from openhands.workspace import DockerWorkspace
from openhands.workspace import DockerDevWorkspace


logger = get_logger(__name__)
Expand All @@ -32,7 +32,7 @@ def detect_platform():


# Create a Docker-based remote workspace with extra ports for browser access
with DockerWorkspace(
with DockerDevWorkspace(
base_image="nikolaik/python-nodejs:python3.12-nodejs22",
host_port=8011,
platform=detect_platform(),
Expand Down Expand Up @@ -85,7 +85,7 @@ def event_callback(event) -> None:
y = None
while y != "y":
y = input(
"Because you've enabled extra_ports=True in DockerWorkspace, "
"Because you've enabled extra_ports=True in DockerDevWorkspace, "
"you can open a browser tab to see the *actual* browser OpenHands "
"is interacting with via VNC.\n\n"
"Link: http://localhost:8012/vnc.html?autoconnect=1&resize=remote\n\n"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from openhands.sdk import LLM, Conversation, get_logger
from openhands.sdk.conversation.impl.remote_conversation import RemoteConversation
from openhands.tools.preset.default import get_default_agent
from openhands.workspace import DockerWorkspace
from openhands.workspace import DockerDevWorkspace


logger = get_logger(__name__)
Expand Down Expand Up @@ -35,7 +35,7 @@ def detect_platform():
return "linux/amd64"


with DockerWorkspace(
with DockerDevWorkspace(
base_image="nikolaik/python-nodejs:python3.12-nodejs22",
host_port=18010,
platform=detect_platform(),
Expand Down Expand Up @@ -97,7 +97,7 @@ def event_callback(event) -> None:
while y != "y":
y = input(
"\n"
"Because you've enabled extra_ports=True in DockerWorkspace, "
"Because you've enabled extra_ports=True in DockerDevWorkspace, "
"you can open VSCode Web to see the workspace.\n\n"
f"VSCode URL: {vscode_url}\n\n"
"The VSCode should have the OpenHands settings extension installed:\n"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,18 +26,16 @@
import tomllib
from contextlib import chdir
from pathlib import Path
from typing import Literal

from pydantic import BaseModel, Field, field_validator

from openhands.sdk.logger import IN_CI, get_logger, rolling_log_view
from openhands.sdk.workspace import PlatformType, TargetType


logger = get_logger(__name__)

VALID_TARGETS = {"binary", "binary-minimal", "source", "source-minimal"}
TargetType = Literal["binary", "binary-minimal", "source", "source-minimal"]
PlatformType = Literal["linux/amd64", "linux/arm64"]


# --- helpers ---
Expand Down
1 change: 1 addition & 0 deletions openhands-agent-server/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ dependencies = [
"alembic>=1.13",
"docker>=7.1,<8",
"fastapi>=0.104",
"openhands-sdk",
"pydantic>=2",
"sqlalchemy>=2",
"uvicorn>=0.31.1",
Expand Down
4 changes: 3 additions & 1 deletion openhands-sdk/openhands/sdk/workspace/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from .base import BaseWorkspace
from .local import LocalWorkspace
from .models import CommandResult, FileOperationResult
from .models import CommandResult, FileOperationResult, PlatformType, TargetType
from .remote import RemoteWorkspace
from .workspace import Workspace

Expand All @@ -10,6 +10,8 @@
"CommandResult",
"FileOperationResult",
"LocalWorkspace",
"PlatformType",
"RemoteWorkspace",
"TargetType",
"Workspace",
]
8 changes: 7 additions & 1 deletion openhands-sdk/openhands/sdk/workspace/models.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
"""Pydantic models for workspace operation results."""
"""Pydantic models for workspace operation results and build types."""

from typing import Literal

from pydantic import BaseModel, Field


TargetType = Literal["binary", "binary-minimal", "source", "source-minimal"]
PlatformType = Literal["linux/amd64", "linux/arm64"]


class CommandResult(BaseModel):
"""Result of executing a command in the workspace."""

Expand Down
21 changes: 20 additions & 1 deletion openhands-workspace/openhands/workspace/__init__.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,29 @@
"""OpenHands Workspace - Docker and container-based workspace implementations."""

from typing import TYPE_CHECKING

from openhands.sdk.workspace import PlatformType, TargetType

from .docker import DockerWorkspace
from .remote_api import APIRemoteWorkspace


if TYPE_CHECKING:
from .docker import DockerDevWorkspace

__all__ = [
"DockerWorkspace",
"APIRemoteWorkspace",
"DockerDevWorkspace",
"DockerWorkspace",
"PlatformType",
"TargetType",
]


def __getattr__(name: str):
"""Lazy import DockerDevWorkspace to avoid build module imports."""
if name == "DockerDevWorkspace":
from .docker import DockerDevWorkspace

return DockerDevWorkspace
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
16 changes: 15 additions & 1 deletion openhands-workspace/openhands/workspace/docker/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,20 @@
"""Docker workspace implementation."""

from typing import TYPE_CHECKING

from .workspace import DockerWorkspace


__all__ = ["DockerWorkspace"]
if TYPE_CHECKING:
from .dev_workspace import DockerDevWorkspace

__all__ = ["DockerWorkspace", "DockerDevWorkspace"]


def __getattr__(name: str):
"""Lazy import DockerDevWorkspace to avoid build module imports."""
if name == "DockerDevWorkspace":
from .dev_workspace import DockerDevWorkspace

return DockerDevWorkspace
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
94 changes: 94 additions & 0 deletions openhands-workspace/openhands/workspace/docker/dev_workspace.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
"""Docker development workspace with on-the-fly image building capability."""

from pydantic import Field, model_validator

from openhands.agent_server.docker.build import BuildOptions, build
from openhands.sdk.workspace import PlatformType, TargetType

from .workspace import DockerWorkspace


class DockerDevWorkspace(DockerWorkspace):
"""Docker workspace with on-the-fly image building capability.

This workspace extends DockerWorkspace to support building Docker images
on-the-fly from a base image. This is useful for development and testing
scenarios where you need to customize the agent server environment.

Note: This class requires the OpenHands SDK workspace structure and should
only be used within the OpenHands development environment or when you have
the full SDK source code available.

For production use cases with pre-built images, use DockerWorkspace instead.

Example:
with DockerDevWorkspace(
base_image="python:3.12",
target="source"
) as workspace:
result = workspace.execute_command("ls -la")
"""

# Add base_image support
base_image: str | None = Field(
default=None,
description=(
"Base Docker image to build the agent server from. "
"Mutually exclusive with server_image."
),
)

# Add build-specific options
target: TargetType = Field(
default="source", description="Build target for the Docker image."
)

# Override platform with stricter type for building
platform: PlatformType = Field( # type: ignore[assignment]
default="linux/amd64",
description=(
"Platform for the Docker image. "
"Only linux/amd64 and linux/arm64 are supported for building."
),
)

@model_validator(mode="after")
def _validate_images(self):
"""Ensure exactly one of base_image or server_image is provided."""
if (self.base_image is None) == (self.server_image is None):
raise ValueError(
"Exactly one of 'base_image' or 'server_image' must be set."
)
if self.base_image and "ghcr.io/openhands/agent-server" in self.base_image:
raise ValueError(
"base_image cannot be a pre-built agent-server image. "
"Use server_image=... instead."
)
return self

def _get_image(self) -> str:
"""Build the image if base_image is provided, otherwise use server_image.

This overrides the parent method to add on-the-fly image building
capability.

Returns:
The Docker image tag to use.
"""
if self.base_image:
# Build the image from base_image
build_opts = BuildOptions(
base_image=self.base_image,
target=self.target,
platforms=[self.platform],
push=False,
)
tags = build(opts=build_opts)
if not tags or len(tags) == 0:
raise RuntimeError("Build failed, no image tags returned")
return tags[0]
elif self.server_image:
# Use pre-built image
return self.server_image
else:
raise ValueError("Either base_image or server_image must be set")
Loading
Loading