modal_runtime.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302
  1. import os
  2. import tempfile
  3. import threading
  4. from pathlib import Path
  5. from typing import Callable, Generator
  6. import modal
  7. import requests
  8. import tenacity
  9. from openhands.core.config import AppConfig
  10. from openhands.events import EventStream
  11. from openhands.runtime.impl.eventstream.eventstream_runtime import (
  12. EventStreamRuntime,
  13. LogStreamer,
  14. )
  15. from openhands.runtime.plugins import PluginRequirement
  16. from openhands.runtime.utils.command import get_remote_startup_command
  17. from openhands.runtime.utils.runtime_build import (
  18. BuildFromImageType,
  19. prep_build_folder,
  20. )
  21. from openhands.utils.async_utils import call_sync_from_async
  22. # FIXME: this will not work in HA mode. We need a better way to track IDs
  23. MODAL_RUNTIME_IDS: dict[str, str] = {}
  24. # Modal's log generator returns strings, but the upstream LogBuffer expects bytes.
  25. def bytes_shim(string_generator) -> Generator[bytes, None, None]:
  26. for line in string_generator:
  27. yield line.encode('utf-8')
  28. class ModalLogStreamer(LogStreamer):
  29. """Streams Modal sandbox logs to stdout.
  30. This class provides a way to stream logs from a Modal sandbox directly to stdout
  31. through the provided logging function.
  32. """
  33. def __init__(
  34. self,
  35. sandbox: modal.Sandbox,
  36. logFn: Callable,
  37. ):
  38. self.log = logFn
  39. self._stop_event = threading.Event()
  40. self.log_generator = bytes_shim(sandbox.stderr)
  41. # Start the stdout streaming thread
  42. self.stdout_thread = threading.Thread(target=self._stream_logs)
  43. self.stdout_thread.daemon = True
  44. self.stdout_thread.start()
  45. def _stream_logs(self):
  46. """Stream logs from the Modal sandbox."""
  47. try:
  48. for log_line in self.log_generator:
  49. if self._stop_event.is_set():
  50. break
  51. if log_line:
  52. decoded_line = log_line.decode('utf-8').rstrip()
  53. self.log('debug', f'[inside sandbox] {decoded_line}')
  54. except Exception as e:
  55. self.log('error', f'Error streaming modal logs: {e}')
  56. class ModalRuntime(EventStreamRuntime):
  57. """This runtime will subscribe the event stream.
  58. When receive an event, it will send the event to runtime-client which run inside the Modal sandbox environment.
  59. Args:
  60. config (AppConfig): The application configuration.
  61. event_stream (EventStream): The event stream to subscribe to.
  62. sid (str, optional): The session ID. Defaults to 'default'.
  63. plugins (list[PluginRequirement] | None, optional): List of plugin requirements. Defaults to None.
  64. env_vars (dict[str, str] | None, optional): Environment variables to set. Defaults to None.
  65. """
  66. container_name_prefix = 'openhands-sandbox-'
  67. sandbox: modal.Sandbox | None
  68. def __init__(
  69. self,
  70. config: AppConfig,
  71. event_stream: EventStream,
  72. sid: str = 'default',
  73. plugins: list[PluginRequirement] | None = None,
  74. env_vars: dict[str, str] | None = None,
  75. status_callback: Callable | None = None,
  76. attach_to_existing: bool = False,
  77. headless_mode: bool = True,
  78. ):
  79. assert config.modal_api_token_id, 'Modal API token id is required'
  80. assert config.modal_api_token_secret, 'Modal API token secret is required'
  81. self.config = config
  82. self.sandbox = None
  83. self.modal_client = modal.Client.from_credentials(
  84. config.modal_api_token_id, config.modal_api_token_secret
  85. )
  86. self.app = modal.App.lookup(
  87. 'openhands', create_if_missing=True, client=self.modal_client
  88. )
  89. # workspace_base cannot be used because we can't bind mount into a sandbox.
  90. if self.config.workspace_base is not None:
  91. self.log(
  92. 'warning',
  93. 'Setting workspace_base is not supported in the modal runtime.',
  94. )
  95. # This value is arbitrary as it's private to the container
  96. self.container_port = 3000
  97. self.session = requests.Session()
  98. self.status_callback = status_callback
  99. self.base_container_image_id = self.config.sandbox.base_container_image
  100. self.runtime_container_image_id = self.config.sandbox.runtime_container_image
  101. self.action_semaphore = threading.Semaphore(1) # Ensure one action at a time
  102. # Buffer for container logs
  103. self.log_streamer: LogStreamer | None = None
  104. if self.config.sandbox.runtime_extra_deps:
  105. self.log(
  106. 'debug',
  107. f'Installing extra user-provided dependencies in the runtime image: {self.config.sandbox.runtime_extra_deps}',
  108. )
  109. self.init_base_runtime(
  110. config,
  111. event_stream,
  112. sid,
  113. plugins,
  114. env_vars,
  115. status_callback,
  116. attach_to_existing,
  117. headless_mode,
  118. )
  119. async def connect(self):
  120. self.send_status_message('STATUS$STARTING_RUNTIME')
  121. self.log('debug', f'ModalRuntime `{self.sid}`')
  122. self.image = self._get_image_definition(
  123. self.base_container_image_id,
  124. self.runtime_container_image_id,
  125. self.config.sandbox.runtime_extra_deps,
  126. )
  127. if self.attach_to_existing:
  128. if self.sid in MODAL_RUNTIME_IDS:
  129. sandbox_id = MODAL_RUNTIME_IDS[self.sid]
  130. self.log('debug', f'Attaching to existing Modal sandbox: {sandbox_id}')
  131. self.sandbox = modal.Sandbox.from_id(
  132. sandbox_id, client=self.modal_client
  133. )
  134. else:
  135. self.send_status_message('STATUS$PREPARING_CONTAINER')
  136. await call_sync_from_async(
  137. self._init_sandbox,
  138. sandbox_workspace_dir=self.config.workspace_mount_path_in_sandbox,
  139. plugins=self.plugins,
  140. )
  141. self.send_status_message('STATUS$CONTAINER_STARTED')
  142. self.log_streamer = ModalLogStreamer(self.sandbox, self.log)
  143. if self.sandbox is None:
  144. raise Exception('Sandbox not initialized')
  145. tunnel = self.sandbox.tunnels()[self.container_port]
  146. self.api_url = tunnel.url
  147. self.log('debug', f'Container started. Server url: {self.api_url}')
  148. if not self.attach_to_existing:
  149. self.log('debug', 'Waiting for client to become ready...')
  150. self.send_status_message('STATUS$WAITING_FOR_CLIENT')
  151. self._wait_until_alive()
  152. self.setup_initial_env()
  153. if not self.attach_to_existing:
  154. self.send_status_message(' ')
  155. def _get_image_definition(
  156. self,
  157. base_container_image_id: str | None,
  158. runtime_container_image_id: str | None,
  159. runtime_extra_deps: str | None,
  160. ) -> modal.Image:
  161. if runtime_container_image_id:
  162. base_runtime_image = modal.Image.from_registry(runtime_container_image_id)
  163. elif base_container_image_id:
  164. build_folder = tempfile.mkdtemp()
  165. prep_build_folder(
  166. build_folder=Path(build_folder),
  167. base_image=base_container_image_id,
  168. build_from=BuildFromImageType.SCRATCH,
  169. extra_deps=runtime_extra_deps,
  170. )
  171. base_runtime_image = modal.Image.from_dockerfile(
  172. path=os.path.join(build_folder, 'Dockerfile'),
  173. context_mount=modal.Mount.from_local_dir(
  174. local_path=build_folder,
  175. remote_path='.', # to current WORKDIR
  176. ),
  177. )
  178. else:
  179. raise ValueError(
  180. 'Neither runtime container image nor base container image is set'
  181. )
  182. return base_runtime_image.run_commands(
  183. """
  184. # Disable bracketed paste
  185. # https://github.com/pexpect/pexpect/issues/669
  186. echo "set enable-bracketed-paste off" >> /etc/inputrc && \\
  187. echo 'export INPUTRC=/etc/inputrc' >> /etc/bash.bashrc
  188. """.strip()
  189. )
  190. @tenacity.retry(
  191. stop=tenacity.stop_after_attempt(5),
  192. wait=tenacity.wait_exponential(multiplier=1, min=4, max=60),
  193. )
  194. def _init_sandbox(
  195. self,
  196. sandbox_workspace_dir: str,
  197. plugins: list[PluginRequirement] | None = None,
  198. ):
  199. try:
  200. self.log('debug', 'Preparing to start container...')
  201. plugin_args = []
  202. if plugins is not None and len(plugins) > 0:
  203. plugin_args.append('--plugins')
  204. plugin_args.extend([plugin.name for plugin in plugins])
  205. # Combine environment variables
  206. environment: dict[str, str | None] = {
  207. 'port': str(self.container_port),
  208. 'PYTHONUNBUFFERED': '1',
  209. }
  210. if self.config.debug:
  211. environment['DEBUG'] = 'true'
  212. browsergym_args = []
  213. if self.config.sandbox.browsergym_eval_env is not None:
  214. browsergym_args = [
  215. '-browsergym-eval-env',
  216. self.config.sandbox.browsergym_eval_env,
  217. ]
  218. env_secret = modal.Secret.from_dict(environment)
  219. self.log('debug', f'Sandbox workspace: {sandbox_workspace_dir}')
  220. sandbox_start_cmd = get_remote_startup_command(
  221. self.container_port,
  222. sandbox_workspace_dir,
  223. 'openhands' if self.config.run_as_openhands else 'root',
  224. self.config.sandbox.user_id,
  225. plugin_args,
  226. browsergym_args,
  227. is_root=not self.config.run_as_openhands, # is_root=True when running as root
  228. )
  229. self.log('debug', f'Starting container with command: {sandbox_start_cmd}')
  230. self.sandbox = modal.Sandbox.create(
  231. *sandbox_start_cmd,
  232. secrets=[env_secret],
  233. workdir='/openhands/code',
  234. encrypted_ports=[self.container_port],
  235. image=self.image,
  236. app=self.app,
  237. client=self.modal_client,
  238. timeout=60 * 60,
  239. )
  240. MODAL_RUNTIME_IDS[self.sid] = self.sandbox.object_id
  241. self.log('debug', 'Container started')
  242. except Exception as e:
  243. self.log(
  244. 'error', f'Error: Instance {self.sid} FAILED to start container!\n'
  245. )
  246. self.log('error', str(e))
  247. self.close()
  248. raise e
  249. def close(self):
  250. """Closes the ModalRuntime and associated objects."""
  251. if self.log_streamer:
  252. self.log_streamer.close()
  253. if self.session:
  254. self.session.close()
  255. if not self.attach_to_existing and self.sandbox:
  256. self.sandbox.terminate()