Skip to content

[Arch] EventStreamRuntime supports browser #2899

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Jul 11, 2024
Merged
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
3 changes: 3 additions & 0 deletions opendevin/runtime/browser/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from .utils import browse

__all__ = ['browse']
Original file line number Diff line number Diff line change
Expand Up @@ -2,25 +2,31 @@

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)
Expand Down
12 changes: 12 additions & 0 deletions opendevin/runtime/client/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
from opendevin.core.logger import opendevin_logger as logger
from opendevin.events.action import (
Action,
BrowseInteractiveAction,
BrowseURLAction,
CmdRunAction,
FileReadAction,
FileWriteAction,
Expand All @@ -24,6 +26,8 @@
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,
Expand All @@ -47,6 +51,7 @@ def __init__(self, plugins_to_load: list[Plugin], work_dir: str) -> None:
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()
Expand Down Expand Up @@ -174,8 +179,15 @@ async def write(self, action: FileWriteAction) -> Observation:
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():
Expand Down
10 changes: 8 additions & 2 deletions opendevin/runtime/client/runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -173,17 +173,23 @@ def close(self, close_client: bool = True):
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
if close_client:
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)
Expand Down
2 changes: 1 addition & 1 deletion opendevin/runtime/server/runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
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


Expand Down
38 changes: 32 additions & 6 deletions opendevin/runtime/utils/image_agnostic.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import os
import shutil
import tempfile

import docker
Expand Down Expand Up @@ -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'
Expand All @@ -58,16 +61,18 @@ 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()
filename = os.path.basename(tarball_path)
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")}'
)
Expand All @@ -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

Expand Down Expand Up @@ -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'

Expand Down Expand Up @@ -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
Comment on lines +216 to 220
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think for dev, we can just mount the project into the sandbox and manually launch it. Build a image maybe too heavy just for development.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point! But beyond the codebase, we might still need to update the dependency - mounting & poetry install at the launch time might make the code a little bit complex, but can help reduce unnecessary re-build of docker images. And how to effectively switch between dev and prod (via config?) is also a question..

I'll give it more thoughts!

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
Expand Down