| import os
|
| import tempfile
|
| from pathlib import Path
|
| from typing import Callable
|
|
|
| import modal
|
| import requests
|
| import tenacity
|
|
|
| from openhands.core.config import AppConfig
|
| from openhands.events import EventStream
|
| from openhands.runtime.impl.action_execution.action_execution_client import (
|
| ActionExecutionClient,
|
| )
|
| from openhands.runtime.plugins import PluginRequirement
|
| from openhands.runtime.utils.command import get_action_execution_server_startup_command
|
| from openhands.runtime.utils.runtime_build import (
|
| BuildFromImageType,
|
| prep_build_folder,
|
| )
|
| from openhands.utils.async_utils import call_sync_from_async
|
| from openhands.utils.tenacity_stop import stop_if_should_exit
|
|
|
|
|
| MODAL_RUNTIME_IDS: dict[str, str] = {}
|
|
|
|
|
| class ModalRuntime(ActionExecutionClient):
|
| """This runtime will subscribe the event stream.
|
|
|
| When receive an event, it will send the event to runtime-client which run inside the Modal sandbox environment.
|
|
|
| Args:
|
| config (AppConfig): The application configuration.
|
| event_stream (EventStream): The event stream to subscribe to.
|
| sid (str, optional): The session ID. Defaults to 'default'.
|
| plugins (list[PluginRequirement] | None, optional): List of plugin requirements. Defaults to None.
|
| env_vars (dict[str, str] | None, optional): Environment variables to set. Defaults to None.
|
| """
|
|
|
| container_name_prefix = 'openhands-sandbox-'
|
| sandbox: modal.Sandbox | None
|
|
|
| def __init__(
|
| self,
|
| config: AppConfig,
|
| event_stream: EventStream,
|
| sid: str = 'default',
|
| plugins: list[PluginRequirement] | None = None,
|
| env_vars: dict[str, str] | None = None,
|
| status_callback: Callable | None = None,
|
| attach_to_existing: bool = False,
|
| headless_mode: bool = True,
|
| ):
|
| assert config.modal_api_token_id, 'Modal API token id is required'
|
| assert config.modal_api_token_secret, 'Modal API token secret is required'
|
|
|
| self.config = config
|
| self.sandbox = None
|
|
|
| self.modal_client = modal.Client.from_credentials(
|
| config.modal_api_token_id.get_secret_value(),
|
| config.modal_api_token_secret.get_secret_value(),
|
| )
|
| self.app = modal.App.lookup(
|
| 'openhands', create_if_missing=True, client=self.modal_client
|
| )
|
|
|
|
|
| if self.config.workspace_base is not None:
|
| self.log(
|
| 'warning',
|
| 'Setting workspace_base is not supported in the modal runtime.',
|
| )
|
|
|
|
|
| self.container_port = 3000
|
|
|
| self.status_callback = status_callback
|
| self.base_container_image_id = self.config.sandbox.base_container_image
|
| self.runtime_container_image_id = self.config.sandbox.runtime_container_image
|
|
|
| if self.config.sandbox.runtime_extra_deps:
|
| self.log(
|
| 'debug',
|
| f'Installing extra user-provided dependencies in the runtime image: {self.config.sandbox.runtime_extra_deps}',
|
| )
|
|
|
| super().__init__(
|
| config,
|
| event_stream,
|
| sid,
|
| plugins,
|
| env_vars,
|
| status_callback,
|
| attach_to_existing,
|
| headless_mode,
|
| )
|
|
|
| async def connect(self):
|
| self.send_status_message('STATUS$STARTING_RUNTIME')
|
|
|
| self.log('debug', f'ModalRuntime `{self.sid}`')
|
|
|
| self.image = self._get_image_definition(
|
| self.base_container_image_id,
|
| self.runtime_container_image_id,
|
| self.config.sandbox.runtime_extra_deps,
|
| )
|
|
|
| if self.attach_to_existing:
|
| if self.sid in MODAL_RUNTIME_IDS:
|
| sandbox_id = MODAL_RUNTIME_IDS[self.sid]
|
| self.log('debug', f'Attaching to existing Modal sandbox: {sandbox_id}')
|
| self.sandbox = modal.Sandbox.from_id(
|
| sandbox_id, client=self.modal_client
|
| )
|
| else:
|
| self.send_status_message('STATUS$PREPARING_CONTAINER')
|
| await call_sync_from_async(
|
| self._init_sandbox,
|
| sandbox_workspace_dir=self.config.workspace_mount_path_in_sandbox,
|
| plugins=self.plugins,
|
| )
|
|
|
| self.send_status_message('STATUS$CONTAINER_STARTED')
|
|
|
| if self.sandbox is None:
|
| raise Exception('Sandbox not initialized')
|
| tunnel = self.sandbox.tunnels()[self.container_port]
|
| self.api_url = tunnel.url
|
| self.log('debug', f'Container started. Server url: {self.api_url}')
|
|
|
| if not self.attach_to_existing:
|
| self.log('debug', 'Waiting for client to become ready...')
|
| self.send_status_message('STATUS$WAITING_FOR_CLIENT')
|
|
|
| self._wait_until_alive()
|
| self.setup_initial_env()
|
|
|
| if not self.attach_to_existing:
|
| self.send_status_message(' ')
|
|
|
| def _get_action_execution_server_host(self):
|
| return self.api_url
|
|
|
| @tenacity.retry(
|
| stop=tenacity.stop_after_delay(120) | stop_if_should_exit(),
|
| retry=tenacity.retry_if_exception_type(
|
| (ConnectionError, requests.exceptions.ConnectionError)
|
| ),
|
| reraise=True,
|
| wait=tenacity.wait_fixed(2),
|
| )
|
| def _wait_until_alive(self):
|
| self.check_if_alive()
|
|
|
| def _get_image_definition(
|
| self,
|
| base_container_image_id: str | None,
|
| runtime_container_image_id: str | None,
|
| runtime_extra_deps: str | None,
|
| ) -> modal.Image:
|
| if runtime_container_image_id:
|
| base_runtime_image = modal.Image.from_registry(runtime_container_image_id)
|
| elif base_container_image_id:
|
| build_folder = tempfile.mkdtemp()
|
| prep_build_folder(
|
| build_folder=Path(build_folder),
|
| base_image=base_container_image_id,
|
| build_from=BuildFromImageType.SCRATCH,
|
| extra_deps=runtime_extra_deps,
|
| )
|
|
|
| base_runtime_image = modal.Image.from_dockerfile(
|
| path=os.path.join(build_folder, 'Dockerfile'),
|
| context_mount=modal.Mount.from_local_dir(
|
| local_path=build_folder,
|
| remote_path='.',
|
| ),
|
| )
|
| else:
|
| raise ValueError(
|
| 'Neither runtime container image nor base container image is set'
|
| )
|
|
|
| return base_runtime_image.run_commands(
|
| """
|
| # Disable bracketed paste
|
| # https://github.com/pexpect/pexpect/issues/669
|
| echo "set enable-bracketed-paste off" >> /etc/inputrc && \\
|
| echo 'export INPUTRC=/etc/inputrc' >> /etc/bash.bashrc
|
| """.strip()
|
| )
|
|
|
| @tenacity.retry(
|
| stop=tenacity.stop_after_attempt(5),
|
| wait=tenacity.wait_exponential(multiplier=1, min=4, max=60),
|
| )
|
| def _init_sandbox(
|
| self,
|
| sandbox_workspace_dir: str,
|
| plugins: list[PluginRequirement] | None = None,
|
| ):
|
| try:
|
| self.log('debug', 'Preparing to start container...')
|
|
|
| environment: dict[str, str | None] = {
|
| 'port': str(self.container_port),
|
| 'PYTHONUNBUFFERED': '1',
|
| }
|
| if self.config.debug:
|
| environment['DEBUG'] = 'true'
|
|
|
| env_secret = modal.Secret.from_dict(environment)
|
|
|
| self.log('debug', f'Sandbox workspace: {sandbox_workspace_dir}')
|
| sandbox_start_cmd = get_action_execution_server_startup_command(
|
| server_port=self.container_port,
|
| plugins=self.plugins,
|
| app_config=self.config,
|
| )
|
| self.log('debug', f'Starting container with command: {sandbox_start_cmd}')
|
| self.sandbox = modal.Sandbox.create(
|
| *sandbox_start_cmd,
|
| secrets=[env_secret],
|
| workdir='/openhands/code',
|
| encrypted_ports=[self.container_port],
|
| image=self.image,
|
| app=self.app,
|
| client=self.modal_client,
|
| timeout=60 * 60,
|
| )
|
| MODAL_RUNTIME_IDS[self.sid] = self.sandbox.object_id
|
| self.log('debug', 'Container started')
|
|
|
| except Exception as e:
|
| self.log(
|
| 'error', f'Error: Instance {self.sid} FAILED to start container!\n'
|
| )
|
| self.log('error', str(e))
|
| self.close()
|
| raise e
|
|
|
| def close(self):
|
| """Closes the ModalRuntime and associated objects."""
|
| super().close()
|
|
|
| if not self.attach_to_existing and self.sandbox:
|
| self.sandbox.terminate()
|
|
|