client.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311
  1. """
  2. This is the main file for the runtime client.
  3. It is responsible for executing actions received from OpenDevin backend and producing observations.
  4. NOTE: this will be executed inside the docker sandbox.
  5. If you already have pre-build docker image yet you changed the code in this file OR dependencies, you need to rebuild the docker image to update the source code.
  6. You should add SANDBOX_UPDATE_SOURCE_CODE=True to any `python XXX.py` command you run to update the source code.
  7. """
  8. import argparse
  9. import asyncio
  10. import os
  11. import re
  12. from pathlib import Path
  13. import pexpect
  14. from fastapi import FastAPI, HTTPException, Request
  15. from pydantic import BaseModel
  16. from uvicorn import run
  17. from opendevin.core.logger import opendevin_logger as logger
  18. from opendevin.events.action import (
  19. Action,
  20. BrowseInteractiveAction,
  21. BrowseURLAction,
  22. CmdRunAction,
  23. FileReadAction,
  24. FileWriteAction,
  25. IPythonRunCellAction,
  26. )
  27. from opendevin.events.observation import (
  28. CmdOutputObservation,
  29. ErrorObservation,
  30. FileReadObservation,
  31. FileWriteObservation,
  32. Observation,
  33. )
  34. from opendevin.events.serialization import event_from_dict, event_to_dict
  35. from opendevin.runtime.browser import browse
  36. from opendevin.runtime.browser.browser_env import BrowserEnv
  37. from opendevin.runtime.plugins import (
  38. ALL_PLUGINS,
  39. JupyterPlugin,
  40. Plugin,
  41. )
  42. from opendevin.runtime.server.files import insert_lines, read_lines
  43. from opendevin.runtime.utils import split_bash_commands
  44. app = FastAPI()
  45. class ActionRequest(BaseModel):
  46. action: dict
  47. class RuntimeClient:
  48. """RuntimeClient is running inside docker sandbox.
  49. It is responsible for executing actions received from OpenDevin backend and producing observations.
  50. """
  51. def __init__(self, plugins_to_load: list[Plugin], work_dir: str) -> None:
  52. self._init_bash_shell(work_dir)
  53. self.lock = asyncio.Lock()
  54. self.plugins: dict[str, Plugin] = {}
  55. self.browser = BrowserEnv()
  56. for plugin in plugins_to_load:
  57. plugin.initialize()
  58. self.plugins[plugin.name] = plugin
  59. logger.info(f'Initializing plugin: {plugin.name}')
  60. def _init_bash_shell(self, work_dir: str) -> None:
  61. self.shell = pexpect.spawn('/bin/bash', encoding='utf-8', echo=False)
  62. self.__bash_PS1 = r'[PEXPECT_BEGIN] \u@\h:\w [PEXPECT_END]'
  63. # This should NOT match "PS1=\u@\h:\w [PEXPECT]$" when `env` is executed
  64. self.__bash_expect_regex = (
  65. r'\[PEXPECT_BEGIN\] ([a-z0-9_-]*)@([a-zA-Z0-9.-]*):(.+) \[PEXPECT_END\]'
  66. )
  67. self.shell.sendline(f'export PS1="{self.__bash_PS1}"; export PS2=""')
  68. self.shell.expect(self.__bash_expect_regex)
  69. self.shell.sendline(f'cd {work_dir}')
  70. self.shell.expect(self.__bash_expect_regex)
  71. def _get_bash_prompt(self):
  72. ps1 = self.shell.after
  73. # begin at the last occurence of '[PEXPECT_BEGIN]'.
  74. # In multi-line bash commands, the prompt will be repeated
  75. # and the matched regex captures all of them
  76. # - we only want the last one (newest prompt)
  77. _begin_pos = ps1.rfind('[PEXPECT_BEGIN]')
  78. if _begin_pos != -1:
  79. ps1 = ps1[_begin_pos:]
  80. # parse the ps1 to get username, hostname, and working directory
  81. matched = re.match(self.__bash_expect_regex, ps1)
  82. assert (
  83. matched is not None
  84. ), f'Failed to parse bash prompt: {ps1}. This should not happen.'
  85. username, hostname, working_dir = matched.groups()
  86. # re-assemble the prompt
  87. prompt = f'{username}@{hostname}:{working_dir} '
  88. if username == 'root':
  89. prompt += '#'
  90. else:
  91. prompt += '$'
  92. return prompt + ' '
  93. def _execute_bash(self, command: str, keep_prompt: bool = True) -> tuple[str, int]:
  94. logger.debug(f'Executing command: {command}')
  95. self.shell.sendline(command)
  96. self.shell.expect(self.__bash_expect_regex)
  97. output = self.shell.before
  98. if keep_prompt:
  99. output += '\r\n' + self._get_bash_prompt()
  100. logger.debug(f'Command output: {output}')
  101. # Get exit code
  102. self.shell.sendline('echo $?')
  103. logger.debug(f'Executing command for exit code: {command}')
  104. self.shell.expect(self.__bash_expect_regex)
  105. _exit_code_output = self.shell.before
  106. logger.debug(f'Exit code Output: {_exit_code_output}')
  107. exit_code = int(_exit_code_output.strip())
  108. return output, exit_code
  109. async def run_action(self, action) -> Observation:
  110. action_type = action.action
  111. observation = await getattr(self, action_type)(action)
  112. observation._parent = action.id
  113. return observation
  114. async def run(self, action: CmdRunAction) -> CmdOutputObservation:
  115. try:
  116. commands = split_bash_commands(action.command)
  117. all_output = ''
  118. for command in commands:
  119. output, exit_code = self._execute_bash(command)
  120. if all_output:
  121. # previous output already exists with prompt "user@hostname:working_dir #""
  122. # we need to add the command to the previous output,
  123. # so model knows the following is the output of another action)
  124. all_output = all_output.rstrip() + ' ' + command + '\r\n'
  125. all_output += str(output) + '\r\n'
  126. if exit_code != 0:
  127. break
  128. return CmdOutputObservation(
  129. command_id=-1,
  130. content=all_output.rstrip('\r\n'),
  131. command=action.command,
  132. exit_code=exit_code,
  133. )
  134. except UnicodeDecodeError:
  135. raise RuntimeError('Command output could not be decoded as utf-8')
  136. async def run_ipython(self, action: IPythonRunCellAction) -> Observation:
  137. if 'jupyter' in self.plugins:
  138. _jupyter_plugin: JupyterPlugin = self.plugins['jupyter'] # type: ignore
  139. return await _jupyter_plugin.run(action)
  140. else:
  141. raise RuntimeError(
  142. 'JupyterRequirement not found. Unable to run IPython action.'
  143. )
  144. def get_working_directory(self):
  145. result, exit_code = self._execute_bash('pwd', keep_prompt=False)
  146. if exit_code != 0:
  147. raise RuntimeError('Failed to get working directory')
  148. return result.strip()
  149. def _resolve_path(self, path: str, working_dir: str) -> str:
  150. filepath = Path(path)
  151. if not filepath.is_absolute():
  152. return str(Path(working_dir) / filepath)
  153. return str(filepath)
  154. async def read(self, action: FileReadAction) -> Observation:
  155. # NOTE: the client code is running inside the sandbox,
  156. # so there's no need to check permission
  157. working_dir = self.get_working_directory()
  158. filepath = self._resolve_path(action.path, working_dir)
  159. try:
  160. with open(filepath, 'r', encoding='utf-8') as file:
  161. lines = read_lines(file.readlines(), action.start, action.end)
  162. except FileNotFoundError:
  163. return ErrorObservation(
  164. f'File not found: {filepath}. Your current working directory is {working_dir}.'
  165. )
  166. except UnicodeDecodeError:
  167. return ErrorObservation(f'File could not be decoded as utf-8: {filepath}.')
  168. except IsADirectoryError:
  169. return ErrorObservation(
  170. f'Path is a directory: {filepath}. You can only read files'
  171. )
  172. code_view = ''.join(lines)
  173. return FileReadObservation(path=filepath, content=code_view)
  174. async def write(self, action: FileWriteAction) -> Observation:
  175. working_dir = self.get_working_directory()
  176. filepath = self._resolve_path(action.path, working_dir)
  177. insert = action.content.split('\n')
  178. try:
  179. if not os.path.exists(os.path.dirname(filepath)):
  180. os.makedirs(os.path.dirname(filepath))
  181. mode = 'w' if not os.path.exists(filepath) else 'r+'
  182. try:
  183. with open(filepath, mode, encoding='utf-8') as file:
  184. if mode != 'w':
  185. all_lines = file.readlines()
  186. new_file = insert_lines(
  187. insert, all_lines, action.start, action.end
  188. )
  189. else:
  190. new_file = [i + '\n' for i in insert]
  191. file.seek(0)
  192. file.writelines(new_file)
  193. file.truncate()
  194. except FileNotFoundError:
  195. return ErrorObservation(f'File not found: {filepath}')
  196. except IsADirectoryError:
  197. return ErrorObservation(
  198. f'Path is a directory: {filepath}. You can only write to files'
  199. )
  200. except UnicodeDecodeError:
  201. return ErrorObservation(
  202. f'File could not be decoded as utf-8: {filepath}'
  203. )
  204. except PermissionError:
  205. return ErrorObservation(f'Malformed paths not permitted: {filepath}')
  206. return FileWriteObservation(content='', path=filepath)
  207. async def browse(self, action: BrowseURLAction) -> Observation:
  208. return await browse(action, self.browser)
  209. async def browse_interactive(self, action: BrowseInteractiveAction) -> Observation:
  210. return await browse(action, self.browser)
  211. def close(self):
  212. self.shell.close()
  213. self.browser.close()
  214. # def test_run_commond():
  215. # client = RuntimeClient()
  216. # command = CmdRunAction(command='ls -l')
  217. # obs = client.run_action(command)
  218. # print(obs)
  219. # def test_shell(message):
  220. # shell = pexpect.spawn('/bin/bash', encoding='utf-8')
  221. # shell.expect(r'[$#] ')
  222. # print(f'Received command: {message}')
  223. # shell.sendline(message)
  224. # shell.expect(r'[$#] ')
  225. # output = shell.before.strip().split('\r\n', 1)[1].strip()
  226. # print(f'Output: {output}')
  227. # shell.close()
  228. if __name__ == '__main__':
  229. parser = argparse.ArgumentParser()
  230. parser.add_argument('port', type=int, help='Port to listen on')
  231. parser.add_argument('--working-dir', type=str, help='Working directory')
  232. parser.add_argument('--plugins', type=str, help='Plugins to initialize', nargs='+')
  233. # example: python client.py 8000 --working-dir /workspace --plugins JupyterRequirement
  234. args = parser.parse_args()
  235. plugins_to_load: list[Plugin] = []
  236. if args.plugins:
  237. for plugin in args.plugins:
  238. if plugin not in ALL_PLUGINS:
  239. raise ValueError(f'Plugin {plugin} not found')
  240. plugins_to_load.append(ALL_PLUGINS[plugin]()) # type: ignore
  241. client = RuntimeClient(plugins_to_load, work_dir=args.working_dir)
  242. @app.middleware('http')
  243. async def one_request_at_a_time(request: Request, call_next):
  244. async with client.lock:
  245. response = await call_next(request)
  246. return response
  247. @app.post('/execute_action')
  248. async def execute_action(action_request: ActionRequest):
  249. try:
  250. action = event_from_dict(action_request.action)
  251. if not isinstance(action, Action):
  252. raise HTTPException(status_code=400, detail='Invalid action type')
  253. observation = await client.run_action(action)
  254. return event_to_dict(observation)
  255. except Exception as e:
  256. logger.error(f'Error processing command: {str(e)}')
  257. raise HTTPException(status_code=500, detail=str(e))
  258. @app.get('/alive')
  259. async def alive():
  260. return {'status': 'ok'}
  261. logger.info(f'Starting action execution API on port {args.port}')
  262. print(f'Starting action execution API on port {args.port}')
  263. run(app, host='0.0.0.0', port=args.port)