From 7c76759c3677c870ce446480c021266482e59665 Mon Sep 17 00:00:00 2001 From: Xingyao Wang Date: Sat, 20 Apr 2024 11:23:11 -0500 Subject: [PATCH 01/23] initialize plugin definition --- opendevin/config.py | 3 +++ opendevin/sandbox/plugins/mixin.py | 25 ++++++++++++++++++++++++ opendevin/sandbox/plugins/requirement.py | 8 ++++++++ 3 files changed, 36 insertions(+) create mode 100644 opendevin/sandbox/plugins/mixin.py create mode 100644 opendevin/sandbox/plugins/requirement.py diff --git a/opendevin/config.py b/opendevin/config.py index 45f33ae5f245..c2bbf98e0d1c 100644 --- a/opendevin/config.py +++ b/opendevin/config.py @@ -128,3 +128,6 @@ def get(key: str, required: bool = False): if not value and required: raise KeyError(f"Please set '{key}' in `config.toml` or `.env`.") return value + + +LIB_ROOT = os.path.dirname(os.path.abspath(__file__)) diff --git a/opendevin/sandbox/plugins/mixin.py b/opendevin/sandbox/plugins/mixin.py new file mode 100644 index 000000000000..f095dc696627 --- /dev/null +++ b/opendevin/sandbox/plugins/mixin.py @@ -0,0 +1,25 @@ +from typing import List, Protocol, Tuple, Dict +from opendevin.logger import opendevin_logger as logger +from opendevin.sandbox.plugins.requirement import PluginRequirement + + +class HasExecuteProtocol(Protocol): + # https://stackoverflow.com/questions/51930339/ how-do-i-correctly-add-type-hints-to-mixin-classes + + plugins: Dict[str, str] + + def execute(self, cmd: str) -> Tuple[int, str]: + ... + + +class PluginMixin: + """Mixin for Sandbox to support plugins.""" + + def load_plugins(self: HasExecuteProtocol, + requirements: List[PluginRequirement]): + """Load a plugin into the sandbox.""" + for requirement in requirements: + self.plugins[requirement.name] = requirement.bash_script_path + # Execute the bash script + logger.info(f"Initalizing plugin {requirement.name} by executing [{requirement.bash_script_path}]") + self.execute(requirement.bash_script_path) diff --git a/opendevin/sandbox/plugins/requirement.py b/opendevin/sandbox/plugins/requirement.py new file mode 100644 index 000000000000..2da88587bf23 --- /dev/null +++ b/opendevin/sandbox/plugins/requirement.py @@ -0,0 +1,8 @@ +from dataclasses import dataclass + + +@dataclass +class PluginRequirement: + """Requirement for a plugin.""" + name: str + bash_script_path: str From ba07415d72d681a801545d61d24e506a3e47b06a Mon Sep 17 00:00:00 2001 From: Xingyao Wang Date: Sat, 20 Apr 2024 11:24:22 -0500 Subject: [PATCH 02/23] initialize plugin definition --- opendevin/sandbox/plugins/mixin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/opendevin/sandbox/plugins/mixin.py b/opendevin/sandbox/plugins/mixin.py index f095dc696627..ec73163a8b62 100644 --- a/opendevin/sandbox/plugins/mixin.py +++ b/opendevin/sandbox/plugins/mixin.py @@ -21,5 +21,5 @@ def load_plugins(self: HasExecuteProtocol, for requirement in requirements: self.plugins[requirement.name] = requirement.bash_script_path # Execute the bash script - logger.info(f"Initalizing plugin {requirement.name} by executing [{requirement.bash_script_path}]") + logger.info(f'Initalizing plugin {requirement.name} by executing [{requirement.bash_script_path}]') self.execute(requirement.bash_script_path) From fdc5109c706329215ffb757490148f578373fe8c Mon Sep 17 00:00:00 2001 From: Xingyao Wang Date: Sat, 20 Apr 2024 11:30:43 -0500 Subject: [PATCH 03/23] simplify mixin --- opendevin/sandbox/plugins/__init__.py | 0 opendevin/sandbox/plugins/mixin.py | 7 ++----- 2 files changed, 2 insertions(+), 5 deletions(-) create mode 100644 opendevin/sandbox/plugins/__init__.py diff --git a/opendevin/sandbox/plugins/__init__.py b/opendevin/sandbox/plugins/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/opendevin/sandbox/plugins/mixin.py b/opendevin/sandbox/plugins/mixin.py index ec73163a8b62..a18a64f578d9 100644 --- a/opendevin/sandbox/plugins/mixin.py +++ b/opendevin/sandbox/plugins/mixin.py @@ -1,4 +1,4 @@ -from typing import List, Protocol, Tuple, Dict +from typing import List, Protocol, Tuple from opendevin.logger import opendevin_logger as logger from opendevin.sandbox.plugins.requirement import PluginRequirement @@ -6,8 +6,6 @@ class HasExecuteProtocol(Protocol): # https://stackoverflow.com/questions/51930339/ how-do-i-correctly-add-type-hints-to-mixin-classes - plugins: Dict[str, str] - def execute(self, cmd: str) -> Tuple[int, str]: ... @@ -15,11 +13,10 @@ def execute(self, cmd: str) -> Tuple[int, str]: class PluginMixin: """Mixin for Sandbox to support plugins.""" - def load_plugins(self: HasExecuteProtocol, + def init_plugins(self: HasExecuteProtocol, requirements: List[PluginRequirement]): """Load a plugin into the sandbox.""" for requirement in requirements: - self.plugins[requirement.name] = requirement.bash_script_path # Execute the bash script logger.info(f'Initalizing plugin {requirement.name} by executing [{requirement.bash_script_path}]') self.execute(requirement.bash_script_path) From 1ada5759429002b54e96d8c348263ab13cd2b890 Mon Sep 17 00:00:00 2001 From: Xingyao Wang Date: Sat, 20 Apr 2024 11:33:35 -0500 Subject: [PATCH 04/23] further improve plugin mixin --- opendevin/sandbox/plugins/mixin.py | 13 ++++++++++--- opendevin/sandbox/plugins/requirement.py | 1 + 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/opendevin/sandbox/plugins/mixin.py b/opendevin/sandbox/plugins/mixin.py index a18a64f578d9..b59950a1ceee 100644 --- a/opendevin/sandbox/plugins/mixin.py +++ b/opendevin/sandbox/plugins/mixin.py @@ -1,3 +1,4 @@ +import os from typing import List, Protocol, Tuple from opendevin.logger import opendevin_logger as logger from opendevin.sandbox.plugins.requirement import PluginRequirement @@ -14,9 +15,15 @@ class PluginMixin: """Mixin for Sandbox to support plugins.""" def init_plugins(self: HasExecuteProtocol, - requirements: List[PluginRequirement]): + requirements: List[PluginRequirement], + plugin_dir: str = '/opendevin/plugins' + ): """Load a plugin into the sandbox.""" for requirement in requirements: # Execute the bash script - logger.info(f'Initalizing plugin {requirement.name} by executing [{requirement.bash_script_path}]') - self.execute(requirement.bash_script_path) + abs_path_to_bash_script = os.path.join(plugin_dir, requirement.bash_script_path) + logger.info(f'Initalizing plugin {requirement.name} by executing [{abs_path_to_bash_script}] in the sandbox.') + exit_code, output = self.execute(abs_path_to_bash_script) + if exit_code != 0: + raise RuntimeError(f'Failed to initialize plugin {requirement.name} with exit code {exit_code} and output {output}') + logger.info(f'Plugin {requirement.name} initialized successfully\n:{output}') diff --git a/opendevin/sandbox/plugins/requirement.py b/opendevin/sandbox/plugins/requirement.py index 2da88587bf23..755c7bb6cd88 100644 --- a/opendevin/sandbox/plugins/requirement.py +++ b/opendevin/sandbox/plugins/requirement.py @@ -5,4 +5,5 @@ class PluginRequirement: """Requirement for a plugin.""" name: str + # NOTE: bash_script_path shoulds be relative to the `plugin` directory bash_script_path: str From 9bed95f224acc5beecb335d40b2364f5692d3c01 Mon Sep 17 00:00:00 2001 From: Xingyao Wang Date: Sat, 20 Apr 2024 12:05:35 -0500 Subject: [PATCH 05/23] add cache dir for pip --- opendevin/config.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/opendevin/config.py b/opendevin/config.py index c2bbf98e0d1c..1741297d0c98 100644 --- a/opendevin/config.py +++ b/opendevin/config.py @@ -131,3 +131,5 @@ def get(key: str, required: bool = False): LIB_ROOT = os.path.dirname(os.path.abspath(__file__)) +CACHE_DIR = os.path.join(LIB_ROOT, '.cache') +os.makedirs(CACHE_DIR, exist_ok=True) From abf0fbbc054ba3915b46965f989123f9cf094f8c Mon Sep 17 00:00:00 2001 From: Xingyao Wang Date: Sat, 20 Apr 2024 12:06:42 -0500 Subject: [PATCH 06/23] support clean up cache --- Makefile | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Makefile b/Makefile index fca4df539fc8..93a507f940ed 100644 --- a/Makefile +++ b/Makefile @@ -177,6 +177,12 @@ setup-config-prompts: workspace_dir=$${workspace_dir:-$(DEFAULT_WORKSPACE_DIR)}; \ echo "WORKSPACE_BASE=\"$$workspace_dir\"" >> $(CONFIG_FILE).tmp +# Clean up all caches +clean: + @echo "$(YELLOW)Cleaning up caches...$(RESET)" + @rm -rf opendevin/.cache + @echo "$(GREEN)Caches cleaned up successfully.$(RESET)" + # Help help: @echo "$(BLUE)Usage: make [target]$(RESET)" From bbac1a6cd04c0f6a01ed683bf52f80d241df5dd5 Mon Sep 17 00:00:00 2001 From: Xingyao Wang Date: Sat, 20 Apr 2024 12:09:40 -0500 Subject: [PATCH 07/23] add script for setup jupyter and execution server --- .../sandbox/plugins/jupyter/execute_server | 270 ++++++++++++++++++ opendevin/sandbox/plugins/jupyter/setup.sh | 32 +++ 2 files changed, 302 insertions(+) create mode 100755 opendevin/sandbox/plugins/jupyter/execute_server create mode 100755 opendevin/sandbox/plugins/jupyter/setup.sh diff --git a/opendevin/sandbox/plugins/jupyter/execute_server b/opendevin/sandbox/plugins/jupyter/execute_server new file mode 100755 index 000000000000..b7bda60ec40e --- /dev/null +++ b/opendevin/sandbox/plugins/jupyter/execute_server @@ -0,0 +1,270 @@ +#!/usr/bin/env python3 + +import os +import re +import asyncio +import tornado +import logging + +from tornado.escape import json_encode, json_decode, url_escape +from tornado.websocket import websocket_connect +from tornado.ioloop import PeriodicCallback +from tornado.httpclient import AsyncHTTPClient, HTTPRequest +from uuid import uuid4 + +logging.basicConfig(level=logging.INFO) + + +def strip_ansi(o: str) -> str: + """ + Removes ANSI escape sequences from `o`, as defined by ECMA-048 in + http://www.ecma-international.org/publications/files/ECMA-ST/Ecma-048.pdf + + # https://github.com/ewen-lbh/python-strip-ansi/blob/master/strip_ansi/__init__.py + + >>> strip_ansi("\\033[33mLorem ipsum\\033[0m") + 'Lorem ipsum' + + >>> strip_ansi("Lorem \\033[38;25mIpsum\\033[0m sit\\namet.") + 'Lorem Ipsum sit\\namet.' + + >>> strip_ansi("") + '' + + >>> strip_ansi("\\x1b[0m") + '' + + >>> strip_ansi("Lorem") + 'Lorem' + + >>> strip_ansi('\\x1b[38;5;32mLorem ipsum\\x1b[0m') + 'Lorem ipsum' + + >>> strip_ansi('\\x1b[1m\\x1b[46m\\x1b[31mLorem dolor sit ipsum\\x1b[0m') + 'Lorem dolor sit ipsum' + """ + + # pattern = re.compile(r'/(\x9B|\x1B\[)[0-?]*[ -\/]*[@-~]/') + pattern = re.compile(r'\x1B\[\d+(;\d+){0,2}m') + stripped = pattern.sub('', o) + return stripped + + +class JupyterKernel: + def __init__( + self, + url_suffix, + convid, + lang='python' + ): + self.base_url = f'http://{url_suffix}' + self.base_ws_url = f'ws://{url_suffix}' + self.lang = lang + self.kernel_id = None + self.ws = None + self.convid = convid + logging.info(f'Jupyter kernel created for conversation {convid} at {url_suffix}') + + self.heartbeat_interval = 10000 # 10 seconds + self.heartbeat_callback = None + + async def initialize(self): + await self.execute(r'%colors nocolor') + # pre-defined tools + self.tools_to_run = [ + # TODO: You can add code for your pre-defined tools here + ] + for tool in self.tools_to_run: + # logging.info(f'Tool initialized:\n{tool}') + await self.execute(tool) + + async def _send_heartbeat(self): + if not self.ws: + return + try: + self.ws.ping() + # logging.info('Heartbeat sent...') + except tornado.iostream.StreamClosedError: + # logging.info('Heartbeat failed, reconnecting...') + try: + await self._connect() + except ConnectionRefusedError: + logging.info('ConnectionRefusedError: Failed to reconnect to kernel websocket - Is the kernel still running?') + + async def _connect(self): + if self.ws: + self.ws.close() + self.ws = None + + client = AsyncHTTPClient() + if not self.kernel_id: + n_tries = 5 + while n_tries > 0: + try: + response = await client.fetch( + '{}/api/kernels'.format(self.base_url), + method='POST', + body=json_encode({'name': self.lang}), + ) + kernel = json_decode(response.body) + self.kernel_id = kernel['id'] + break + except Exception: + # kernels are not ready yet + n_tries -= 1 + await asyncio.sleep(1) + + if n_tries == 0: + raise ConnectionRefusedError('Failed to connect to kernel') + + ws_req = HTTPRequest( + url='{}/api/kernels/{}/channels'.format( + self.base_ws_url, url_escape(self.kernel_id) + ) + ) + self.ws = await websocket_connect(ws_req) + logging.info('Connected to kernel websocket') + + # Setup heartbeat + if self.heartbeat_callback: + self.heartbeat_callback.stop() + self.heartbeat_callback = PeriodicCallback(self._send_heartbeat, self.heartbeat_interval) + self.heartbeat_callback.start() + + async def execute(self, code, timeout=60): + if not self.ws: + await self._connect() + + msg_id = uuid4().hex + self.ws.write_message( + json_encode( + { + 'header': { + 'username': '', + 'version': '5.0', + 'session': '', + 'msg_id': msg_id, + 'msg_type': 'execute_request', + }, + 'parent_header': {}, + 'channel': 'shell', + 'content': { + 'code': code, + 'silent': False, + 'store_history': False, + 'user_expressions': {}, + 'allow_stdin': False, + }, + 'metadata': {}, + 'buffers': {}, + } + ) + ) + + outputs = [] + + async def wait_for_messages(): + execution_done = False + while not execution_done: + msg = await self.ws.read_message() + msg = json_decode(msg) + msg_type = msg['msg_type'] + parent_msg_id = msg['parent_header'].get('msg_id', None) + + if parent_msg_id != msg_id: + continue + + if os.environ.get('DEBUG', False): + logging.info(f"MSG TYPE: {msg_type.upper()} DONE:{execution_done}\nCONTENT: {msg['content']}") + + if msg_type == 'error': + traceback = '\n'.join(msg['content']['traceback']) + outputs.append(traceback) + execution_done = True + elif msg_type == 'stream': + outputs.append(msg['content']['text']) + elif msg_type in ['execute_result', 'display_data']: + outputs.append(msg['content']['data']['text/plain']) + if 'image/png' in msg['content']['data']: + # use markdone to display image (in case of large image) + # outputs.append(f"\n\n") + outputs.append(f"![image](data:image/png;base64,{msg['content']['data']['image / png']})") + + elif msg_type == 'execute_reply': + execution_done = True + return execution_done + + async def interrupt_kernel(): + client = AsyncHTTPClient() + interrupt_response = await client.fetch( + f'{self.base_url}/api/kernels/{self.kernel_id}/interrupt', + method='POST', + body=json_encode({'kernel_id': self.kernel_id}), + ) + logging.info(f'Kernel interrupted: {interrupt_response}') + + try: + execution_done = await asyncio.wait_for(wait_for_messages(), timeout) + except asyncio.TimeoutError: + await interrupt_kernel() + return f'[Execution timed out ({timeout} seconds).]' + + if not outputs and execution_done: + ret = '[Code executed successfully with no output]' + else: + ret = ''.join(outputs) + + # Remove ANSI + ret = strip_ansi(ret) + + if os.environ.get('DEBUG', False): + logging.info(f'OUTPUT:\n{ret}') + return ret + + async def shutdown_async(self): + if self.kernel_id: + client = AsyncHTTPClient() + await client.fetch( + '{}/api/kernels/{}'.format(self.base_url, self.kernel_id), + method='DELETE', + ) + self.kernel_id = None + if self.ws: + self.ws.close() + self.ws = None + + +class ExecuteHandler(tornado.web.RequestHandler): + def initialize(self, jupyter_kernel): + self.jupyter_kernel = jupyter_kernel + + async def post(self): + data = json_decode(self.request.body) + code = data.get('code') + + if not code: + self.set_status(400) + self.write('Missing code') + return + + output = await self.jupyter_kernel.execute(code) + + self.write(output) + + +def make_app(): + jupyter_kernel = JupyterKernel( + f"localhost:{os.environ.get('JUPYTER_GATEWAY_PORT')}", + os.environ.get('JUPYTER_GATEWAY_KERNEL_ID') + ) + asyncio.get_event_loop().run_until_complete(jupyter_kernel.initialize()) + + return tornado.web.Application([ + (r'/execute', ExecuteHandler, {'jupyter_kernel': jupyter_kernel}), + ]) + + +if __name__ == '__main__': + app = make_app() + app.listen(8000) + tornado.ioloop.IOLoop.current().start() diff --git a/opendevin/sandbox/plugins/jupyter/setup.sh b/opendevin/sandbox/plugins/jupyter/setup.sh new file mode 100755 index 000000000000..1c493e6004f1 --- /dev/null +++ b/opendevin/sandbox/plugins/jupyter/setup.sh @@ -0,0 +1,32 @@ +#!/bin/bash + +set -ex + +pip install jupyterlab notebook jupyter_kernel_gateway + +# ADD /opendevin/plugins to PATH to make `jupyter_cli` available +echo 'export PATH=$PATH:/opendevin/plugins/jupyter' >> ~/.bashrc +export PATH=$PATH:/opendevin/plugins/jupyter + +# if user name is `opendevin`, add '/home/opendevin/.local/bin' to PATH +if [ "$USER" = "opendevin" ]; then + echo 'export PATH=$PATH:/home/opendevin/.local/bin' >> ~/.bashrc + export PATH=$PATH:/home/opendevin/.local/bin +fi +# if user name is `root`, add '/root/.local/bin' to PATH +if [ "$USER" = "root" ]; then + echo 'export PATH=$PATH:/root/.local/bin' >> ~/.bashrc + export PATH=$PATH:/root/.local/bin +fi + +# Run background process to start jupyter kernel gateway +export JUPYTER_GATEWAY_PORT=18888 +jupyter kernelgateway --KernelGatewayApp.ip=0.0.0.0 --KernelGatewayApp.port=$JUPYTER_GATEWAY_PORT & +export JUPYTER_GATEWAY_PID=$! +export JUPYTER_GATEWAY_KERNEL_ID="default" +echo "JupyterKernelGateway started with PID: $JUPYTER_GATEWAY_PID" + +# Start the jupyter_server +/opendevin/plugins/jupyter/execute_server & +export JUPYTER_EXEC_SERVER_PID=$! +echo "Execution server started with PID: $JUPYTER_EXEC_SERVER_PID" From 18cf4baa0c36bc72a2960b154f6e9b331c7cf69d Mon Sep 17 00:00:00 2001 From: Xingyao Wang Date: Sat, 20 Apr 2024 12:11:06 -0500 Subject: [PATCH 08/23] integrate JupyterRequirement to ssh_box --- opendevin/sandbox/docker/ssh_box.py | 34 ++++++++++++++++--- opendevin/sandbox/plugins/jupyter/__init__.py | 10 ++++++ 2 files changed, 39 insertions(+), 5 deletions(-) create mode 100644 opendevin/sandbox/plugins/jupyter/__init__.py diff --git a/opendevin/sandbox/docker/ssh_box.py b/opendevin/sandbox/docker/ssh_box.py index 5f21821b2e6f..d3fdabc4aa01 100644 --- a/opendevin/sandbox/docker/ssh_box.py +++ b/opendevin/sandbox/docker/ssh_box.py @@ -15,6 +15,8 @@ from opendevin.sandbox.sandbox import Sandbox from opendevin.sandbox.process import Process from opendevin.sandbox.docker.process import DockerProcess +from opendevin.sandbox.plugins.mixin import PluginMixin +from opendevin.sandbox.plugins.jupyter import JupyterRequirement from opendevin.schema import ConfigType from opendevin.utils import find_available_tcp_port from opendevin.exceptions import SandboxInvalidBackgroundCommandError @@ -43,7 +45,7 @@ USER_ID = os.getuid() -class DockerSSHBox(Sandbox): +class DockerSSHBox(Sandbox, PluginMixin): instance_id: str container_image: str container_name_prefix = 'opendevin-sandbox-' @@ -58,10 +60,10 @@ class DockerSSHBox(Sandbox): background_commands: Dict[int, Process] = {} def __init__( - self, - container_image: str | None = None, - timeout: int = 120, - sid: str | None = None, + self, + container_image: str | None = None, + timeout: int = 120, + sid: str | None = None, ): # Initialize docker client. Throws an exception if Docker is not reachable. try: @@ -137,6 +139,15 @@ def setup_user(self): ) if exit_code != 0: raise Exception(f'Failed to set password in sandbox: {logs}') + + # chown the home directory + exit_code, logs = self.container.exec_run( + ['/bin/bash', '-c', 'chown opendevin:root /home/opendevin'], + workdir=SANDBOX_WORKSPACE_DIR, + ) + if exit_code != 0: + raise Exception( + f'Failed to chown home directory for opendevin in sandbox: {logs}') else: exit_code, logs = self.container.exec_run( # change password for root @@ -307,6 +318,16 @@ def restart_docker_container(self): 'bind': SANDBOX_WORKSPACE_DIR, 'mode': 'rw' }, + # mount plugins directory to /opendevin/plugins + os.path.join(config.LIB_ROOT, 'sandbox', 'plugins'): { + 'bind': '/opendevin/plugins', + 'mode': 'ro' + }, + # mount cache directory to /home/opendevin/.cache for pip cache reuse + config.CACHE_DIR: { + 'bind': '/home/opendevin/.cache' if RUN_AS_DEVIN else '/root/.cache', + 'mode': 'rw' + }, }, ) logger.info('Container started') @@ -355,6 +376,9 @@ def close(self): logger.info( "Interactive Docker container started. Type 'exit' or use Ctrl+C to exit.") + # Initialize required plugins + ssh_box.init_plugins([JupyterRequirement()]) + bg_cmd = ssh_box.execute_in_background( "while true; do echo 'dot ' && sleep 1; done" ) diff --git a/opendevin/sandbox/plugins/jupyter/__init__.py b/opendevin/sandbox/plugins/jupyter/__init__.py new file mode 100644 index 000000000000..29b0b493ccc4 --- /dev/null +++ b/opendevin/sandbox/plugins/jupyter/__init__.py @@ -0,0 +1,10 @@ +import os +from opendevin.sandbox.plugins.requirement import PluginRequirement + + +class JupyterRequirement(PluginRequirement): + def __init__(self): + super().__init__( + 'jupyter', + os.path.join('jupyter', 'setup.sh') + ) From b7f2f601c4b32bbcc2566720a1df7dd141091f7d Mon Sep 17 00:00:00 2001 From: Xingyao Wang Date: Sat, 20 Apr 2024 12:15:41 -0500 Subject: [PATCH 09/23] source bashrc at the end of plugin load --- opendevin/sandbox/plugins/jupyter/setup.sh | 4 ++-- opendevin/sandbox/plugins/mixin.py | 5 +++++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/opendevin/sandbox/plugins/jupyter/setup.sh b/opendevin/sandbox/plugins/jupyter/setup.sh index 1c493e6004f1..737b71163f26 100755 --- a/opendevin/sandbox/plugins/jupyter/setup.sh +++ b/opendevin/sandbox/plugins/jupyter/setup.sh @@ -1,12 +1,12 @@ #!/bin/bash -set -ex +set -e pip install jupyterlab notebook jupyter_kernel_gateway # ADD /opendevin/plugins to PATH to make `jupyter_cli` available echo 'export PATH=$PATH:/opendevin/plugins/jupyter' >> ~/.bashrc -export PATH=$PATH:/opendevin/plugins/jupyter +export PATH=/opendevin/plugins/jupyter:$PATH # if user name is `opendevin`, add '/home/opendevin/.local/bin' to PATH if [ "$USER" = "opendevin" ]; then diff --git a/opendevin/sandbox/plugins/mixin.py b/opendevin/sandbox/plugins/mixin.py index b59950a1ceee..524546d74c5d 100644 --- a/opendevin/sandbox/plugins/mixin.py +++ b/opendevin/sandbox/plugins/mixin.py @@ -27,3 +27,8 @@ def init_plugins(self: HasExecuteProtocol, if exit_code != 0: raise RuntimeError(f'Failed to initialize plugin {requirement.name} with exit code {exit_code} and output {output}') logger.info(f'Plugin {requirement.name} initialized successfully\n:{output}') + + exit_code, output = self.execute('source ~/.bashrc') + if exit_code != 0: + raise RuntimeError(f'Failed to source ~/.bashrc with exit code {exit_code} and output {output}') + logger.info('Sourced ~/.bashrc successfully') From cf33615274df9859325c91b1df320c213fec7366 Mon Sep 17 00:00:00 2001 From: Xingyao Wang Date: Sat, 20 Apr 2024 12:18:17 -0500 Subject: [PATCH 10/23] add execute_cli that accept code via stdin --- opendevin/sandbox/plugins/jupyter/execute_cli | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100755 opendevin/sandbox/plugins/jupyter/execute_cli diff --git a/opendevin/sandbox/plugins/jupyter/execute_cli b/opendevin/sandbox/plugins/jupyter/execute_cli new file mode 100755 index 000000000000..52debda68750 --- /dev/null +++ b/opendevin/sandbox/plugins/jupyter/execute_cli @@ -0,0 +1,13 @@ +#!/bin/bash + +# Read the Python code from STDIN +code=$(cat) + +# Set the default kernel ID +kernel_id=default + +# Send a POST request to the REST server +response=$(curl -s -X POST -H "Content-Type: application/json" -d "{\"kernel_id\": \"$kernel_id\", \"code\": \"$code\"}" http://localhost:$JUPYTER_EXEC_SERVER_PORT/execute) + +# Print the response +echo "$response" From 3908ae76efd408cef22af933aadc937c0054c204 Mon Sep 17 00:00:00 2001 From: Xingyao Wang Date: Sat, 20 Apr 2024 12:19:02 -0500 Subject: [PATCH 11/23] make JUPYTER_EXEC_SERVER_PORT configurable via env var --- opendevin/sandbox/plugins/jupyter/execute_server | 2 +- opendevin/sandbox/plugins/jupyter/setup.sh | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/opendevin/sandbox/plugins/jupyter/execute_server b/opendevin/sandbox/plugins/jupyter/execute_server index b7bda60ec40e..e5af086ea7fb 100755 --- a/opendevin/sandbox/plugins/jupyter/execute_server +++ b/opendevin/sandbox/plugins/jupyter/execute_server @@ -266,5 +266,5 @@ def make_app(): if __name__ == '__main__': app = make_app() - app.listen(8000) + app.listen(os.environ.get('JUPYTER_EXEC_SERVER_PORT')) tornado.ioloop.IOLoop.current().start() diff --git a/opendevin/sandbox/plugins/jupyter/setup.sh b/opendevin/sandbox/plugins/jupyter/setup.sh index 737b71163f26..976378007738 100755 --- a/opendevin/sandbox/plugins/jupyter/setup.sh +++ b/opendevin/sandbox/plugins/jupyter/setup.sh @@ -22,11 +22,20 @@ fi # Run background process to start jupyter kernel gateway export JUPYTER_GATEWAY_PORT=18888 jupyter kernelgateway --KernelGatewayApp.ip=0.0.0.0 --KernelGatewayApp.port=$JUPYTER_GATEWAY_PORT & + export JUPYTER_GATEWAY_PID=$! +echo "export JUPYTER_GATEWAY_PID=$JUPYTER_GATEWAY_PID" >> ~/.bashrc export JUPYTER_GATEWAY_KERNEL_ID="default" +echo "export JUPYTER_GATEWAY_KERNEL_ID=$JUPYTER_GATEWAY_KERNEL_ID" >> ~/.bashrc + echo "JupyterKernelGateway started with PID: $JUPYTER_GATEWAY_PID" # Start the jupyter_server +export JUPYTER_EXEC_SERVER_PORT=18889 +echo "export JUPYTER_EXEC_SERVER_PORT=$JUPYTER_EXEC_SERVER_PORT" >> ~/.bashrc /opendevin/plugins/jupyter/execute_server & + export JUPYTER_EXEC_SERVER_PID=$! +echo "export JUPYTER_EXEC_SERVER_PID=$JUPYTER_EXEC_SERVER_PID" >> ~/.bashrc + echo "Execution server started with PID: $JUPYTER_EXEC_SERVER_PID" From fadb609a9886f94daa25f5ea0c7a9d8bd9a83c40 Mon Sep 17 00:00:00 2001 From: Xingyao Wang Date: Sat, 20 Apr 2024 12:19:30 -0500 Subject: [PATCH 12/23] increase background cmd sleep time --- opendevin/sandbox/docker/ssh_box.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/opendevin/sandbox/docker/ssh_box.py b/opendevin/sandbox/docker/ssh_box.py index d3fdabc4aa01..a6097f17d809 100644 --- a/opendevin/sandbox/docker/ssh_box.py +++ b/opendevin/sandbox/docker/ssh_box.py @@ -380,7 +380,7 @@ def close(self): ssh_box.init_plugins([JupyterRequirement()]) bg_cmd = ssh_box.execute_in_background( - "while true; do echo 'dot ' && sleep 1; done" + "while true; do echo 'dot ' && sleep 10; done" ) sys.stdout.flush() From d3c829cd26e2012de03c6084ba5e237a11337da9 Mon Sep 17 00:00:00 2001 From: Xingyao Wang Date: Sun, 21 Apr 2024 10:31:03 +0800 Subject: [PATCH 13/23] Update opendevin/sandbox/plugins/mixin.py Co-authored-by: Robert Brennan --- opendevin/sandbox/plugins/mixin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/opendevin/sandbox/plugins/mixin.py b/opendevin/sandbox/plugins/mixin.py index 524546d74c5d..29c570cfa41a 100644 --- a/opendevin/sandbox/plugins/mixin.py +++ b/opendevin/sandbox/plugins/mixin.py @@ -5,7 +5,7 @@ class HasExecuteProtocol(Protocol): - # https://stackoverflow.com/questions/51930339/ how-do-i-correctly-add-type-hints-to-mixin-classes + # https://stackoverflow.com/questions/51930339/how-do-i-correctly-add-type-hints-to-mixin-classes def execute(self, cmd: str) -> Tuple[int, str]: ... From 9481c7b003acb6722ed9e6ddd21db7e3b5b261d2 Mon Sep 17 00:00:00 2001 From: Xingyao Wang Date: Sat, 20 Apr 2024 22:06:43 -0500 Subject: [PATCH 14/23] add mixin to base class --- opendevin/sandbox/docker/ssh_box.py | 3 +-- opendevin/sandbox/sandbox.py | 3 ++- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/opendevin/sandbox/docker/ssh_box.py b/opendevin/sandbox/docker/ssh_box.py index a6097f17d809..23b89d57996f 100644 --- a/opendevin/sandbox/docker/ssh_box.py +++ b/opendevin/sandbox/docker/ssh_box.py @@ -15,7 +15,6 @@ from opendevin.sandbox.sandbox import Sandbox from opendevin.sandbox.process import Process from opendevin.sandbox.docker.process import DockerProcess -from opendevin.sandbox.plugins.mixin import PluginMixin from opendevin.sandbox.plugins.jupyter import JupyterRequirement from opendevin.schema import ConfigType from opendevin.utils import find_available_tcp_port @@ -45,7 +44,7 @@ USER_ID = os.getuid() -class DockerSSHBox(Sandbox, PluginMixin): +class DockerSSHBox(Sandbox): instance_id: str container_image: str container_name_prefix = 'opendevin-sandbox-' diff --git a/opendevin/sandbox/sandbox.py b/opendevin/sandbox/sandbox.py index c8c1c7449ad0..72f084c3c1c7 100644 --- a/opendevin/sandbox/sandbox.py +++ b/opendevin/sandbox/sandbox.py @@ -3,9 +3,10 @@ from typing import Tuple from opendevin.sandbox.process import Process +from opendevin.sandbox.plugins.mixin import PluginMixin -class Sandbox(ABC): +class Sandbox(ABC, PluginMixin): background_commands: Dict[int, Process] = {} @abstractmethod From 456d9cbecdcdb70f6e4ddf2e295db08acb07608c Mon Sep 17 00:00:00 2001 From: Xingyao Wang Date: Sat, 20 Apr 2024 23:07:25 -0500 Subject: [PATCH 15/23] make jupyter requirement a dataclass --- opendevin/sandbox/plugins/__init__.py | 7 +++++++ opendevin/sandbox/plugins/jupyter/__init__.py | 9 ++++----- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/opendevin/sandbox/plugins/__init__.py b/opendevin/sandbox/plugins/__init__.py index e69de29bb2d1..79469b8fed2c 100644 --- a/opendevin/sandbox/plugins/__init__.py +++ b/opendevin/sandbox/plugins/__init__.py @@ -0,0 +1,7 @@ +from .mixin import PluginMixin +from .requirement import PluginRequirement + +# Requirements +from .jupyter import JupyterRequirement + +__all__ = ['PluginMixin', 'PluginRequirement', 'JupyterRequirement'] diff --git a/opendevin/sandbox/plugins/jupyter/__init__.py b/opendevin/sandbox/plugins/jupyter/__init__.py index 29b0b493ccc4..a8b5a3cef6ef 100644 --- a/opendevin/sandbox/plugins/jupyter/__init__.py +++ b/opendevin/sandbox/plugins/jupyter/__init__.py @@ -1,10 +1,9 @@ import os +from dataclasses import dataclass from opendevin.sandbox.plugins.requirement import PluginRequirement +@dataclass class JupyterRequirement(PluginRequirement): - def __init__(self): - super().__init__( - 'jupyter', - os.path.join('jupyter', 'setup.sh') - ) + name: str = 'jupyter' + bash_script_path: str = os.path.join('jupyter', 'setup.sh') From 0d788efef0039e840dc3442f677834f2d79a30ca Mon Sep 17 00:00:00 2001 From: Xingyao Wang Date: Sat, 20 Apr 2024 23:10:44 -0500 Subject: [PATCH 16/23] source plugins only when >0 requirements --- opendevin/sandbox/plugins/mixin.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/opendevin/sandbox/plugins/mixin.py b/opendevin/sandbox/plugins/mixin.py index 29c570cfa41a..e2f8b430fa74 100644 --- a/opendevin/sandbox/plugins/mixin.py +++ b/opendevin/sandbox/plugins/mixin.py @@ -28,7 +28,8 @@ def init_plugins(self: HasExecuteProtocol, raise RuntimeError(f'Failed to initialize plugin {requirement.name} with exit code {exit_code} and output {output}') logger.info(f'Plugin {requirement.name} initialized successfully\n:{output}') - exit_code, output = self.execute('source ~/.bashrc') - if exit_code != 0: - raise RuntimeError(f'Failed to source ~/.bashrc with exit code {exit_code} and output {output}') - logger.info('Sourced ~/.bashrc successfully') + if len(requirements) > 0: + exit_code, output = self.execute('source ~/.bashrc') + if exit_code != 0: + raise RuntimeError(f'Failed to source ~/.bashrc with exit code {exit_code} and output {output}') + logger.info('Sourced ~/.bashrc successfully') From a436c8b6b22d61d9e975fbd5b3f4af10521aafd5 Mon Sep 17 00:00:00 2001 From: Xingyao Wang Date: Sun, 21 Apr 2024 00:57:05 -0500 Subject: [PATCH 17/23] add `sandbox_plugins` for each agent & have controller take care of it --- agenthub/codeact_agent/codeact_agent.py | 3 +++ opendevin/agent.py | 2 ++ opendevin/controller/action_manager.py | 4 ++++ opendevin/controller/agent_controller.py | 2 ++ 4 files changed, 11 insertions(+) diff --git a/agenthub/codeact_agent/codeact_agent.py b/agenthub/codeact_agent/codeact_agent.py index 56bbb10bd0f9..d7faecd02546 100644 --- a/agenthub/codeact_agent/codeact_agent.py +++ b/agenthub/codeact_agent/codeact_agent.py @@ -15,6 +15,7 @@ ) from opendevin.parse_commands import parse_command_file from opendevin.state import State +from opendevin.sandbox.plugins import PluginRequirement, JupyterRequirement COMMAND_DOCS = parse_command_file() COMMAND_SEGMENT = ( @@ -69,6 +70,8 @@ class CodeActAgent(Agent): The agent works by passing the model a list of action-observation pairs and prompting the model to take the next step. """ + sandbox_plugins: List[PluginRequirement] = [JupyterRequirement()] + def __init__( self, llm: LLM, diff --git a/opendevin/agent.py b/opendevin/agent.py index 85eab06e7234..855d50ff7ff6 100644 --- a/opendevin/agent.py +++ b/opendevin/agent.py @@ -6,6 +6,7 @@ from opendevin.state import State from opendevin.llm.llm import LLM from opendevin.exceptions import AgentAlreadyRegisteredError, AgentNotRegisteredError +from opendevin.sandbox.plugins import PluginRequirement class Agent(ABC): @@ -17,6 +18,7 @@ class Agent(ABC): """ _registry: Dict[str, Type['Agent']] = {} + sandbox_plugins: List[PluginRequirement] = [] def __init__( self, diff --git a/opendevin/controller/action_manager.py b/opendevin/controller/action_manager.py index d6b694c7e359..8a0c90ad748a 100644 --- a/opendevin/controller/action_manager.py +++ b/opendevin/controller/action_manager.py @@ -14,6 +14,7 @@ AgentErrorObservation, NullObservation, ) +from opendevin.sandbox.plugins import PluginRequirement class ActionManager: @@ -41,6 +42,9 @@ def __init__( else: raise ValueError(f'Invalid sandbox type: {sandbox_type}') + def init_sandbox_plugins(self, plugins: List[PluginRequirement]): + self.sandbox.init_plugins(plugins) + async def run_action(self, action: Action, agent_controller) -> Observation: observation: Observation = NullObservation('') if not action.executable: diff --git a/opendevin/controller/agent_controller.py b/opendevin/controller/agent_controller.py index 01a6e31f648d..725ef56c78ae 100644 --- a/opendevin/controller/agent_controller.py +++ b/opendevin/controller/agent_controller.py @@ -54,6 +54,8 @@ def __init__( self.action_manager = ActionManager(self.id, container_image) self.max_chars = max_chars self.callbacks = callbacks + # Initialize agent-required plugins for sandbox (if any) + self.action_manager.init_sandbox_plugins(agent.sandbox_plugins) def update_state_for_step(self, i): if self.state is None: From bac492a4becd9f6831c0bca950c9cae92f1359cb Mon Sep 17 00:00:00 2001 From: Xingyao Wang Date: Sun, 21 Apr 2024 01:12:23 -0500 Subject: [PATCH 18/23] update build.sh to make logs available in /opendevin/logs --- opendevin/sandbox/plugins/jupyter/setup.sh | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/opendevin/sandbox/plugins/jupyter/setup.sh b/opendevin/sandbox/plugins/jupyter/setup.sh index 976378007738..e756fc4206f0 100755 --- a/opendevin/sandbox/plugins/jupyter/setup.sh +++ b/opendevin/sandbox/plugins/jupyter/setup.sh @@ -2,8 +2,6 @@ set -e -pip install jupyterlab notebook jupyter_kernel_gateway - # ADD /opendevin/plugins to PATH to make `jupyter_cli` available echo 'export PATH=$PATH:/opendevin/plugins/jupyter' >> ~/.bashrc export PATH=/opendevin/plugins/jupyter:$PATH @@ -19,23 +17,25 @@ if [ "$USER" = "root" ]; then export PATH=$PATH:/root/.local/bin fi +# Install dependencies +pip install jupyterlab notebook jupyter_kernel_gateway + +# Create logs directory +sudo mkdir -p /opendevin/logs && sudo chmod 777 /opendevin/logs + # Run background process to start jupyter kernel gateway export JUPYTER_GATEWAY_PORT=18888 -jupyter kernelgateway --KernelGatewayApp.ip=0.0.0.0 --KernelGatewayApp.port=$JUPYTER_GATEWAY_PORT & - +jupyter kernelgateway --KernelGatewayApp.ip=0.0.0.0 --KernelGatewayApp.port=$JUPYTER_GATEWAY_PORT > /opendevin/logs/jupyter_kernel_gateway.log 2>&1 & export JUPYTER_GATEWAY_PID=$! echo "export JUPYTER_GATEWAY_PID=$JUPYTER_GATEWAY_PID" >> ~/.bashrc export JUPYTER_GATEWAY_KERNEL_ID="default" echo "export JUPYTER_GATEWAY_KERNEL_ID=$JUPYTER_GATEWAY_KERNEL_ID" >> ~/.bashrc - echo "JupyterKernelGateway started with PID: $JUPYTER_GATEWAY_PID" # Start the jupyter_server export JUPYTER_EXEC_SERVER_PORT=18889 echo "export JUPYTER_EXEC_SERVER_PORT=$JUPYTER_EXEC_SERVER_PORT" >> ~/.bashrc -/opendevin/plugins/jupyter/execute_server & - +/opendevin/plugins/jupyter/execute_server > /opendevin/logs/jupyter_execute_server.log 2>&1 & export JUPYTER_EXEC_SERVER_PID=$! echo "export JUPYTER_EXEC_SERVER_PID=$JUPYTER_EXEC_SERVER_PID" >> ~/.bashrc - echo "Execution server started with PID: $JUPYTER_EXEC_SERVER_PID" From 3f5030de8e4b9cd972bf5883f827d8a3842a32d3 Mon Sep 17 00:00:00 2001 From: Xingyao Wang Date: Sun, 21 Apr 2024 06:45:56 -0500 Subject: [PATCH 19/23] switch to use config for lib and cache dir --- opendevin/config.py | 10 ++++++---- opendevin/sandbox/docker/ssh_box.py | 4 ++-- opendevin/schema/config.py | 2 ++ 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/opendevin/config.py b/opendevin/config.py index 1741297d0c98..d28e46442242 100644 --- a/opendevin/config.py +++ b/opendevin/config.py @@ -1,7 +1,7 @@ import os - import argparse import toml +import pathlib from dotenv import load_dotenv from opendevin.schema import ConfigType @@ -14,6 +14,8 @@ ConfigType.WORKSPACE_BASE: os.getcwd(), ConfigType.WORKSPACE_MOUNT_PATH: None, ConfigType.WORKSPACE_MOUNT_REWRITE: None, + ConfigType.LIB_ROOT_PATH: os.path.dirname(os.path.abspath(__file__)), + ConfigType.CACHE_DIR: os.path.join(os.path.dirname(os.path.abspath(__file__)), '.cache'), ConfigType.LLM_MODEL: 'gpt-3.5-turbo-1106', ConfigType.SANDBOX_CONTAINER_IMAGE: 'ghcr.io/opendevin/sandbox', ConfigType.RUN_AS_DEVIN: 'true', @@ -130,6 +132,6 @@ def get(key: str, required: bool = False): return value -LIB_ROOT = os.path.dirname(os.path.abspath(__file__)) -CACHE_DIR = os.path.join(LIB_ROOT, '.cache') -os.makedirs(CACHE_DIR, exist_ok=True) +_cache_dir = config.get('CACHE_DIR') +if _cache_dir: + pathlib.Path(_cache_dir).mkdir(parents=True, exist_ok=True) diff --git a/opendevin/sandbox/docker/ssh_box.py b/opendevin/sandbox/docker/ssh_box.py index 23b89d57996f..9380f3e19e6c 100644 --- a/opendevin/sandbox/docker/ssh_box.py +++ b/opendevin/sandbox/docker/ssh_box.py @@ -318,12 +318,12 @@ def restart_docker_container(self): 'mode': 'rw' }, # mount plugins directory to /opendevin/plugins - os.path.join(config.LIB_ROOT, 'sandbox', 'plugins'): { + os.path.join(config.get('LIB_ROOT_PATH'), 'sandbox', 'plugins'): { 'bind': '/opendevin/plugins', 'mode': 'ro' }, # mount cache directory to /home/opendevin/.cache for pip cache reuse - config.CACHE_DIR: { + config.get('CACHE_DIR'): { 'bind': '/home/opendevin/.cache' if RUN_AS_DEVIN else '/root/.cache', 'mode': 'rw' }, diff --git a/opendevin/schema/config.py b/opendevin/schema/config.py index 3daba52e556f..f66f8ce27f9c 100644 --- a/opendevin/schema/config.py +++ b/opendevin/schema/config.py @@ -7,6 +7,8 @@ class ConfigType(str, Enum): WORKSPACE_BASE = 'WORKSPACE_BASE' WORKSPACE_MOUNT_PATH = 'WORKSPACE_MOUNT_PATH' WORKSPACE_MOUNT_REWRITE = 'WORKSPACE_MOUNT_REWRITE' + LIB_ROOT_PATH = 'LIB_ROOT_PATH' + CACHE_DIR = 'CACHE_DIR' LLM_MODEL = 'LLM_MODEL' SANDBOX_CONTAINER_IMAGE = 'SANDBOX_CONTAINER_IMAGE' RUN_AS_DEVIN = 'RUN_AS_DEVIN' From a7b4f2ebac440a1f785a7653e3615a9bdd123321 Mon Sep 17 00:00:00 2001 From: Xingyao Wang Date: Sun, 21 Apr 2024 13:58:00 -0500 Subject: [PATCH 20/23] fix permission issue with /workspace --- opendevin/sandbox/docker/ssh_box.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/opendevin/sandbox/docker/ssh_box.py b/opendevin/sandbox/docker/ssh_box.py index 9380f3e19e6c..e6c3c96a6250 100644 --- a/opendevin/sandbox/docker/ssh_box.py +++ b/opendevin/sandbox/docker/ssh_box.py @@ -147,6 +147,13 @@ def setup_user(self): if exit_code != 0: raise Exception( f'Failed to chown home directory for opendevin in sandbox: {logs}') + exit_code, logs = self.container.exec_run( + ['/bin/bash', '-c', f'chown opendevin:root {SANDBOX_WORKSPACE_DIR}'], + workdir=SANDBOX_WORKSPACE_DIR, + ) + if exit_code != 0: + raise Exception( + f'Failed to chown workspace directory for opendevin in sandbox: {logs}') else: exit_code, logs = self.container.exec_run( # change password for root From 6be6ac8a19b304e58b5be4dc222520c0f7113f78 Mon Sep 17 00:00:00 2001 From: Xingyao Wang Date: Sun, 21 Apr 2024 14:34:10 -0500 Subject: [PATCH 21/23] use python to implement execute_cli to avoid stdin escape issue --- opendevin/sandbox/plugins/jupyter/execute_cli | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/opendevin/sandbox/plugins/jupyter/execute_cli b/opendevin/sandbox/plugins/jupyter/execute_cli index 52debda68750..3fca4c534f84 100755 --- a/opendevin/sandbox/plugins/jupyter/execute_cli +++ b/opendevin/sandbox/plugins/jupyter/execute_cli @@ -1,13 +1,25 @@ -#!/bin/bash +#!/usr/bin/env python3 +import os +import sys +import time +import requests # Read the Python code from STDIN -code=$(cat) +code = sys.stdin.read() # Set the default kernel ID -kernel_id=default +kernel_id = 'default' -# Send a POST request to the REST server -response=$(curl -s -X POST -H "Content-Type: application/json" -d "{\"kernel_id\": \"$kernel_id\", \"code\": \"$code\"}" http://localhost:$JUPYTER_EXEC_SERVER_PORT/execute) +# try 5 times until success +PORT = os.environ.get('JUPYTER_EXEC_SERVER_PORT') +POST_URL = f'http://localhost:{PORT}/execute' + +for i in range(5): + response = requests.post(POST_URL, json={'kernel_id': kernel_id, 'code': code}) + # if "500: Internal Server Error" is not in the response, break the loop + if '500: Internal Server Error' not in response.text: + break + time.sleep(1) # Print the response -echo "$response" +print(str(response.text)) From c1a4e56e4b96b3a8f4e3fc718a72c285dba09f54 Mon Sep 17 00:00:00 2001 From: Xingyao Wang Date: Mon, 22 Apr 2024 00:54:25 -0500 Subject: [PATCH 22/23] wait until jupyter is avaialble --- opendevin/sandbox/plugins/jupyter/setup.sh | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/opendevin/sandbox/plugins/jupyter/setup.sh b/opendevin/sandbox/plugins/jupyter/setup.sh index e756fc4206f0..23ad1d8f42b4 100755 --- a/opendevin/sandbox/plugins/jupyter/setup.sh +++ b/opendevin/sandbox/plugins/jupyter/setup.sh @@ -39,3 +39,13 @@ echo "export JUPYTER_EXEC_SERVER_PORT=$JUPYTER_EXEC_SERVER_PORT" >> ~/.bashrc export JUPYTER_EXEC_SERVER_PID=$! echo "export JUPYTER_EXEC_SERVER_PID=$JUPYTER_EXEC_SERVER_PID" >> ~/.bashrc echo "Execution server started with PID: $JUPYTER_EXEC_SERVER_PID" + +# Wait until /opendevin/logs/jupyter_kernel_gateway.log contains "is available" +while ! grep -q "is available" /opendevin/logs/jupyter_kernel_gateway.log; do + sleep 1 +done +# Wait until /opendevin/logs/jupyter_execute_server.log contains "Jupyter kernel created for conversation" +while ! grep -q "Jupyter kernel created for conversation" /opendevin/logs/jupyter_execute_server.log; do + sleep 1 +done +echo "Jupyter kernel ready." From da1b3bafde18a76baa37617ee00114c098267458 Mon Sep 17 00:00:00 2001 From: Xingyao Wang Date: Mon, 22 Apr 2024 01:57:51 -0500 Subject: [PATCH 23/23] support plugin via copying instead of mounting --- opendevin/config.py | 1 - opendevin/sandbox/docker/exec_box.py | 33 ++++++++++++++++ opendevin/sandbox/docker/local_box.py | 19 ++++++++++ opendevin/sandbox/docker/ssh_box.py | 38 ++++++++++++++++--- opendevin/sandbox/e2b/sandbox.py | 4 ++ opendevin/sandbox/plugins/jupyter/__init__.py | 4 +- opendevin/sandbox/plugins/mixin.py | 18 +++++---- opendevin/sandbox/plugins/requirement.py | 5 ++- opendevin/sandbox/sandbox.py | 4 ++ opendevin/schema/config.py | 1 - 10 files changed, 111 insertions(+), 16 deletions(-) diff --git a/opendevin/config.py b/opendevin/config.py index de4a752d40d3..39cf55f2083f 100644 --- a/opendevin/config.py +++ b/opendevin/config.py @@ -18,7 +18,6 @@ ConfigType.WORKSPACE_MOUNT_PATH: None, ConfigType.WORKSPACE_MOUNT_PATH_IN_SANDBOX: '/workspace', ConfigType.WORKSPACE_MOUNT_REWRITE: None, - ConfigType.LIB_ROOT_PATH: os.path.dirname(os.path.abspath(__file__)), ConfigType.CACHE_DIR: os.path.join(os.path.dirname(os.path.abspath(__file__)), '.cache'), ConfigType.LLM_MODEL: 'gpt-3.5-turbo-1106', ConfigType.SANDBOX_CONTAINER_IMAGE: 'ghcr.io/opendevin/sandbox', diff --git a/opendevin/sandbox/docker/exec_box.py b/opendevin/sandbox/docker/exec_box.py index b18aaeed6d37..bb62608889ca 100644 --- a/opendevin/sandbox/docker/exec_box.py +++ b/opendevin/sandbox/docker/exec_box.py @@ -4,6 +4,8 @@ import sys import time import uuid +import tarfile +from glob import glob from collections import namedtuple from typing import Dict, List, Tuple @@ -122,6 +124,37 @@ def run_command(container, command): return -1, f'Command: "{cmd}" timed out' return exit_code, logs.decode('utf-8') + def copy_to(self, host_src: str, sandbox_dest: str, recursive: bool = False): + # mkdir -p sandbox_dest if it doesn't exist + exit_code, logs = self.container.exec_run( + ['/bin/bash', '-c', f'mkdir -p {sandbox_dest}'], + workdir=SANDBOX_WORKSPACE_DIR, + ) + if exit_code != 0: + raise Exception( + f'Failed to create directory {sandbox_dest} in sandbox: {logs}') + + if recursive: + assert os.path.isdir(host_src), 'Source must be a directory when recursive is True' + files = glob(host_src + '/**/*', recursive=True) + srcname = os.path.basename(host_src) + tar_filename = os.path.join(os.path.dirname(host_src), srcname + '.tar') + with tarfile.open(tar_filename, mode='w') as tar: + for file in files: + tar.add(file, arcname=os.path.relpath(file, os.path.dirname(host_src))) + else: + assert os.path.isfile(host_src), 'Source must be a file when recursive is False' + srcname = os.path.basename(host_src) + tar_filename = os.path.join(os.path.dirname(host_src), srcname + '.tar') + with tarfile.open(tar_filename, mode='w') as tar: + tar.add(host_src, arcname=srcname) + + with open(tar_filename, 'rb') as f: + data = f.read() + + self.container.put_archive(os.path.dirname(sandbox_dest), data) + os.remove(tar_filename) + def execute_in_background(self, cmd: str) -> Process: result = self.container.exec_run( self.get_exec_cmd(cmd), socket=True, workdir=SANDBOX_WORKSPACE_DIR diff --git a/opendevin/sandbox/docker/local_box.py b/opendevin/sandbox/docker/local_box.py index 7d9775dc6ed3..99c814c5a776 100644 --- a/opendevin/sandbox/docker/local_box.py +++ b/opendevin/sandbox/docker/local_box.py @@ -39,6 +39,25 @@ def execute(self, cmd: str) -> Tuple[int, str]: except subprocess.TimeoutExpired: return -1, 'Command timed out' + def copy_to(self, host_src: str, sandbox_dest: str, recursive: bool = False): + # mkdir -p sandbox_dest if it doesn't exist + res = subprocess.run(f'mkdir -p {sandbox_dest}', shell=True, text=True, cwd=config.get('WORKSPACE_BASE')) + if res.returncode != 0: + raise RuntimeError(f'Failed to create directory {sandbox_dest} in sandbox') + + if recursive: + res = subprocess.run( + f'cp -r {host_src} {sandbox_dest}', shell=True, text=True, cwd=config.get('WORKSPACE_BASE') + ) + if res.returncode != 0: + raise RuntimeError(f'Failed to copy {host_src} to {sandbox_dest} in sandbox') + else: + res = subprocess.run( + f'cp {host_src} {sandbox_dest}', shell=True, text=True, cwd=config.get('WORKSPACE_BASE') + ) + if res.returncode != 0: + raise RuntimeError(f'Failed to copy {host_src} to {sandbox_dest} in sandbox') + def execute_in_background(self, cmd: str) -> Process: process = subprocess.Popen( cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, diff --git a/opendevin/sandbox/docker/ssh_box.py b/opendevin/sandbox/docker/ssh_box.py index 0b3fe307b247..462f4c8633f0 100644 --- a/opendevin/sandbox/docker/ssh_box.py +++ b/opendevin/sandbox/docker/ssh_box.py @@ -4,6 +4,8 @@ import sys import time import uuid +import tarfile +from glob import glob from collections import namedtuple from typing import Dict, List, Tuple, Union @@ -225,6 +227,37 @@ def execute(self, cmd: str) -> Tuple[int, str]: exit_code = int(exit_code.lstrip('echo $?').strip()) return exit_code, command_output + def copy_to(self, host_src: str, sandbox_dest: str, recursive: bool = False): + # mkdir -p sandbox_dest if it doesn't exist + exit_code, logs = self.container.exec_run( + ['/bin/bash', '-c', f'mkdir -p {sandbox_dest}'], + workdir=SANDBOX_WORKSPACE_DIR, + ) + if exit_code != 0: + raise Exception( + f'Failed to create directory {sandbox_dest} in sandbox: {logs}') + + if recursive: + assert os.path.isdir(host_src), 'Source must be a directory when recursive is True' + files = glob(host_src + '/**/*', recursive=True) + srcname = os.path.basename(host_src) + tar_filename = os.path.join(os.path.dirname(host_src), srcname + '.tar') + with tarfile.open(tar_filename, mode='w') as tar: + for file in files: + tar.add(file, arcname=os.path.relpath(file, os.path.dirname(host_src))) + else: + assert os.path.isfile(host_src), 'Source must be a file when recursive is False' + srcname = os.path.basename(host_src) + tar_filename = os.path.join(os.path.dirname(host_src), srcname + '.tar') + with tarfile.open(tar_filename, mode='w') as tar: + tar.add(host_src, arcname=srcname) + + with open(tar_filename, 'rb') as f: + data = f.read() + + self.container.put_archive(os.path.dirname(sandbox_dest), data) + os.remove(tar_filename) + def execute_in_background(self, cmd: str) -> Process: result = self.container.exec_run( self.get_exec_cmd(cmd), socket=True, workdir=SANDBOX_WORKSPACE_DIR @@ -324,11 +357,6 @@ def restart_docker_container(self): 'bind': SANDBOX_WORKSPACE_DIR, 'mode': 'rw' }, - # mount plugins directory to /opendevin/plugins - os.path.join(config.get('LIB_ROOT_PATH'), 'sandbox', 'plugins'): { - 'bind': '/opendevin/plugins', - 'mode': 'ro' - }, # mount cache directory to /home/opendevin/.cache for pip cache reuse config.get('CACHE_DIR'): { 'bind': '/home/opendevin/.cache' if RUN_AS_DEVIN else '/root/.cache', diff --git a/opendevin/sandbox/e2b/sandbox.py b/opendevin/sandbox/e2b/sandbox.py index 9cd8ac274446..484daa847ac6 100644 --- a/opendevin/sandbox/e2b/sandbox.py +++ b/opendevin/sandbox/e2b/sandbox.py @@ -61,6 +61,10 @@ def execute(self, cmd: str) -> Tuple[int, str]: assert process_output.exit_code is not None return process_output.exit_code, logs_str + def copy_to(self, host_src: str, sandbox_dest: str, recursive: bool = False): + # FIXME + raise NotImplementedError('Copying files to E2B sandbox is not implemented yet') + def execute_in_background(self, cmd: str) -> Process: process = self.sandbox.process.start(cmd) e2b_process = E2BProcess(process, cmd) diff --git a/opendevin/sandbox/plugins/jupyter/__init__.py b/opendevin/sandbox/plugins/jupyter/__init__.py index a8b5a3cef6ef..a9c3214cf20b 100644 --- a/opendevin/sandbox/plugins/jupyter/__init__.py +++ b/opendevin/sandbox/plugins/jupyter/__init__.py @@ -6,4 +6,6 @@ @dataclass class JupyterRequirement(PluginRequirement): name: str = 'jupyter' - bash_script_path: str = os.path.join('jupyter', 'setup.sh') + host_src: str = os.path.dirname(os.path.abspath(__file__)) # The directory of this file (sandbox/plugins/jupyter) + sandbox_dest: str = '/opendevin/plugins/jupyter' + bash_script_path: str = 'setup.sh' diff --git a/opendevin/sandbox/plugins/mixin.py b/opendevin/sandbox/plugins/mixin.py index e2f8b430fa74..af9f26972712 100644 --- a/opendevin/sandbox/plugins/mixin.py +++ b/opendevin/sandbox/plugins/mixin.py @@ -4,25 +4,29 @@ from opendevin.sandbox.plugins.requirement import PluginRequirement -class HasExecuteProtocol(Protocol): +class SandboxProtocol(Protocol): # https://stackoverflow.com/questions/51930339/how-do-i-correctly-add-type-hints-to-mixin-classes def execute(self, cmd: str) -> Tuple[int, str]: ... + def copy_to(self, host_src: str, sandbox_dest: str, recursive: bool = False): + ... + class PluginMixin: """Mixin for Sandbox to support plugins.""" - def init_plugins(self: HasExecuteProtocol, - requirements: List[PluginRequirement], - plugin_dir: str = '/opendevin/plugins' - ): + def init_plugins(self: SandboxProtocol, requirements: List[PluginRequirement]): """Load a plugin into the sandbox.""" for requirement in requirements: + # copy over the files + self.copy_to(requirement.host_src, requirement.sandbox_dest, recursive=True) + logger.info(f'Copied files from [{requirement.host_src}] to [{requirement.sandbox_dest}] inside sandbox.') + # Execute the bash script - abs_path_to_bash_script = os.path.join(plugin_dir, requirement.bash_script_path) - logger.info(f'Initalizing plugin {requirement.name} by executing [{abs_path_to_bash_script}] in the sandbox.') + abs_path_to_bash_script = os.path.join(requirement.sandbox_dest, requirement.bash_script_path) + logger.info(f'Initalizing plugin [{requirement.name}] by executing [{abs_path_to_bash_script}] in the sandbox.') exit_code, output = self.execute(abs_path_to_bash_script) if exit_code != 0: raise RuntimeError(f'Failed to initialize plugin {requirement.name} with exit code {exit_code} and output {output}') diff --git a/opendevin/sandbox/plugins/requirement.py b/opendevin/sandbox/plugins/requirement.py index 755c7bb6cd88..31731a085369 100644 --- a/opendevin/sandbox/plugins/requirement.py +++ b/opendevin/sandbox/plugins/requirement.py @@ -5,5 +5,8 @@ class PluginRequirement: """Requirement for a plugin.""" name: str - # NOTE: bash_script_path shoulds be relative to the `plugin` directory + # FOLDER/FILES to be copied to the sandbox + host_src: str + sandbox_dest: str + # NOTE: bash_script_path shoulds be relative to the `sandbox_dest` path bash_script_path: str diff --git a/opendevin/sandbox/sandbox.py b/opendevin/sandbox/sandbox.py index 72f084c3c1c7..ed648c355957 100644 --- a/opendevin/sandbox/sandbox.py +++ b/opendevin/sandbox/sandbox.py @@ -28,3 +28,7 @@ def read_logs(self, id: int) -> str: @abstractmethod def close(self): pass + + @abstractmethod + def copy_to(self, host_src: str, sandbox_dest: str, recursive: bool = False): + pass diff --git a/opendevin/schema/config.py b/opendevin/schema/config.py index 0239b603e315..e2d0f482f7be 100644 --- a/opendevin/schema/config.py +++ b/opendevin/schema/config.py @@ -8,7 +8,6 @@ class ConfigType(str, Enum): WORKSPACE_MOUNT_PATH = 'WORKSPACE_MOUNT_PATH' WORKSPACE_MOUNT_REWRITE = 'WORKSPACE_MOUNT_REWRITE' WORKSPACE_MOUNT_PATH_IN_SANDBOX = 'WORKSPACE_MOUNT_PATH_IN_SANDBOX' - LIB_ROOT_PATH = 'LIB_ROOT_PATH' CACHE_DIR = 'CACHE_DIR' LLM_MODEL = 'LLM_MODEL' SANDBOX_CONTAINER_IMAGE = 'SANDBOX_CONTAINER_IMAGE'