Pārlūkot izejas kodu

[Arch] `EventStreamRuntime` supports browser (#2899)

* fix the case when source and tmp are not on the same device

* always build a dev box (with updated source code) for development purpose

* tail the log before removing the container

* move browse function

* support browser!
Xingyao Wang 1 gadu atpakaļ
vecāks
revīzija
96b5cb78fd

+ 3 - 0
opendevin/runtime/browser/__init__.py

@@ -0,0 +1,3 @@
+from .utils import browse
+
+__all__ = ['browse']

+ 9 - 3
opendevin/runtime/server/browse.py → opendevin/runtime/browser/utils.py

@@ -2,25 +2,31 @@ import os
 
 from opendevin.core.exceptions import BrowserUnavailableException
 from opendevin.core.schema import ActionType
+from opendevin.events.action import BrowseInteractiveAction, BrowseURLAction
 from opendevin.events.observation import BrowserOutputObservation
 from opendevin.runtime.browser.browser_env import BrowserEnv
 
 
-async def browse(action, browser: BrowserEnv | None) -> BrowserOutputObservation:
+async def browse(
+    action: BrowseURLAction | BrowseInteractiveAction, browser: BrowserEnv | None
+) -> BrowserOutputObservation:
     if browser is None:
         raise BrowserUnavailableException()
-    if action.action == ActionType.BROWSE:
+
+    if isinstance(action, BrowseURLAction):
         # legacy BrowseURLAction
         asked_url = action.url
         if not asked_url.startswith('http'):
             asked_url = os.path.abspath(os.curdir) + action.url
         action_str = f'goto("{asked_url}")'
-    elif action.action == ActionType.BROWSE_INTERACTIVE:
+
+    elif isinstance(action, BrowseInteractiveAction):
         # new BrowseInteractiveAction, supports full featured BrowserGym actions
         # action in BrowserGym: see https://github.com/ServiceNow/BrowserGym/blob/main/core/src/browsergym/core/action/functions.py
         action_str = action.browser_actions
     else:
         raise ValueError(f'Invalid action type: {action.action}')
+
     try:
         # obs provided by BrowserGym: see https://github.com/ServiceNow/BrowserGym/blob/main/core/src/browsergym/core/env.py#L396
         obs = browser.step(action_str)

+ 12 - 0
opendevin/runtime/client/client.py

@@ -11,6 +11,8 @@ from uvicorn import run
 from opendevin.core.logger import opendevin_logger as logger
 from opendevin.events.action import (
     Action,
+    BrowseInteractiveAction,
+    BrowseURLAction,
     CmdRunAction,
     FileReadAction,
     FileWriteAction,
@@ -24,6 +26,8 @@ from opendevin.events.observation import (
     Observation,
 )
 from opendevin.events.serialization import event_from_dict, event_to_dict
+from opendevin.runtime.browser import browse
+from opendevin.runtime.browser.browser_env import BrowserEnv
 from opendevin.runtime.plugins import (
     ALL_PLUGINS,
     JupyterPlugin,
@@ -47,6 +51,7 @@ class RuntimeClient:
         self._init_bash_shell(work_dir)
         self.lock = asyncio.Lock()
         self.plugins: dict[str, Plugin] = {}
+        self.browser = BrowserEnv()
 
         for plugin in plugins_to_load:
             plugin.initialize()
@@ -174,8 +179,15 @@ class RuntimeClient:
             return ErrorObservation(f'Malformed paths not permitted: {filepath}')
         return FileWriteObservation(content='', path=filepath)
 
+    async def browse(self, action: BrowseURLAction) -> Observation:
+        return await browse(action, self.browser)
+
+    async def browse_interactive(self, action: BrowseInteractiveAction) -> Observation:
+        return await browse(action, self.browser)
+
     def close(self):
         self.shell.close()
+        self.browser.close()
 
 
 # def test_run_commond():

+ 8 - 2
opendevin/runtime/client/runtime.py

@@ -173,6 +173,11 @@ class EventStreamRuntime(Runtime):
         for container in containers:
             try:
                 if container.name.startswith(self.container_name_prefix):
+                    # tail the logs before removing the container
+                    logs = container.logs(tail=1000).decode('utf-8')
+                    logger.info(
+                        f'==== Container logs ====\n{logs}\n==== End of container logs ===='
+                    )
                     container.remove(force=True)
             except docker.errors.NotFound:
                 pass
@@ -180,10 +185,11 @@ class EventStreamRuntime(Runtime):
             self.docker_client.close()
 
     async def on_event(self, event: Event) -> None:
-        print('EventStreamRuntime: on_event triggered')
+        logger.info(f'EventStreamRuntime: on_event triggered: {event}')
         if isinstance(event, Action):
+            logger.info(event, extra={'msg_type': 'ACTION'})
             observation = await self.run_action(event)
-            print('EventStreamRuntime: observation', observation)
+            logger.info(observation, extra={'msg_type': 'OBSERVATION'})
             # observation._cause = event.id  # type: ignore[attr-defined]
             source = event.source if event.source else EventSource.AGENT
             await self.event_stream.add_event(observation, source)

+ 1 - 1
opendevin/runtime/server/runtime.py

@@ -20,7 +20,7 @@ from opendevin.runtime import Sandbox
 from opendevin.runtime.runtime import Runtime
 from opendevin.storage.local import LocalFileStore
 
-from .browse import browse
+from ..browser import browse
 from .files import read_file, write_file
 
 

+ 32 - 6
opendevin/runtime/utils/image_agnostic.py

@@ -1,4 +1,5 @@
 import os
+import shutil
 import tempfile
 
 import docker
@@ -48,7 +49,9 @@ def generate_dockerfile_for_eventstream_runtime(
     else:
         dockerfile_content = (
             f'FROM {base_image}\n'
+            # FIXME: make this more generic / cross-platform
             'RUN apt update && apt install -y wget sudo\n'
+            'RUN apt-get update && apt-get install -y libgl1-mesa-glx\n'  # Extra dependency for OpenCV
             'RUN mkdir -p /opendevin && mkdir -p /opendevin/logs && chmod 777 /opendevin/logs\n'
             'RUN echo "" > /opendevin/bash.bashrc\n'
             'RUN if [ ! -d /opendevin/miniforge3 ]; then \\\n'
@@ -58,8 +61,8 @@ def generate_dockerfile_for_eventstream_runtime(
             '        chmod -R g+w /opendevin/miniforge3 && \\\n'
             '        bash -c ". /opendevin/miniforge3/etc/profile.d/conda.sh && conda config --set changeps1 False && conda config --append channels conda-forge"; \\\n'
             '    fi\n'
-            'RUN /opendevin/miniforge3/bin/mamba install python=3.11\n'
-            'RUN /opendevin/miniforge3/bin/mamba install conda-forge::poetry\n'
+            'RUN /opendevin/miniforge3/bin/mamba install python=3.11 -y\n'
+            'RUN /opendevin/miniforge3/bin/mamba install conda-forge::poetry -y\n'
         )
 
     tarball_path = create_project_source_dist()
@@ -67,7 +70,9 @@ def generate_dockerfile_for_eventstream_runtime(
     filename = filename.removesuffix('.tar.gz')
 
     # move the tarball to temp_dir
-    os.rename(tarball_path, os.path.join(temp_dir, 'project.tar.gz'))
+    _res = shutil.copy(tarball_path, os.path.join(temp_dir, 'project.tar.gz'))
+    if _res:
+        os.remove(tarball_path)
     logger.info(
         f'Source distribution moved to {os.path.join(temp_dir, "project.tar.gz")}'
     )
@@ -88,6 +93,8 @@ def generate_dockerfile_for_eventstream_runtime(
         'RUN cd /opendevin/code && '
         '/opendevin/miniforge3/bin/mamba run -n base poetry env use python3.11 && '
         '/opendevin/miniforge3/bin/mamba run -n base poetry install\n'
+        # for browser (update if needed)
+        'RUN apt-get update && cd /opendevin/code && /opendevin/miniforge3/bin/mamba run -n base poetry run playwright install --with-deps chromium\n'
     )
     return dockerfile_content
 
@@ -162,10 +169,14 @@ def _build_sandbox_image(
         raise e
 
 
-def _get_new_image_name(base_image: str, is_eventstream_runtime: bool) -> str:
+def _get_new_image_name(
+    base_image: str, is_eventstream_runtime: bool, dev_mode: bool = False
+) -> str:
     prefix = 'od_sandbox'
     if is_eventstream_runtime:
         prefix = 'od_eventstream_runtime'
+    if dev_mode:
+        prefix += '_dev'
     if ':' not in base_image:
         base_image = base_image + ':latest'
 
@@ -202,10 +213,25 @@ def get_od_sandbox_image(
     skip_init = False
     if image_exists:
         if is_eventstream_runtime:
-            skip_init = True
+            # An eventstream runtime image is already built for the base image (with poetry and dev dependencies)
+            # but it might not contain the latest version of the source code and dependencies.
+            # So we need to build a new (dev) image with the latest source code and dependencies.
+            # FIXME: In production, we should just build once (since the source code will not change)
             base_image = new_image_name
+            new_image_name = _get_new_image_name(
+                base_image, is_eventstream_runtime, dev_mode=True
+            )
+
+            # Delete the existing image named `new_image_name` if any
+            images = docker_client.images.list()
+            for image in images:
+                if new_image_name in image.tags:
+                    docker_client.images.remove(image.id, force=True)
+
+            # We will reuse the existing image but will update the source code in it.
+            skip_init = True
             logger.info(
-                f'Reusing existing od_sandbox image [{new_image_name}] but will update the source code.'
+                f'Reusing existing od_sandbox image [{base_image}] but will update the source code into [{new_image_name}]'
             )
         else:
             return new_image_name