runtime.py 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202
  1. import atexit
  2. import copy
  3. import json
  4. import os
  5. from abc import abstractmethod
  6. from openhands.core.config import AppConfig, SandboxConfig
  7. from openhands.core.logger import openhands_logger as logger
  8. from openhands.events import EventSource, EventStream, EventStreamSubscriber
  9. from openhands.events.action import (
  10. Action,
  11. ActionConfirmationStatus,
  12. BrowseInteractiveAction,
  13. BrowseURLAction,
  14. CmdRunAction,
  15. FileReadAction,
  16. FileWriteAction,
  17. IPythonRunCellAction,
  18. )
  19. from openhands.events.event import Event
  20. from openhands.events.observation import (
  21. CmdOutputObservation,
  22. ErrorObservation,
  23. NullObservation,
  24. Observation,
  25. UserRejectObservation,
  26. )
  27. from openhands.events.serialization.action import ACTION_TYPE_TO_CLASS
  28. from openhands.runtime.plugins import JupyterRequirement, PluginRequirement
  29. def _default_env_vars(sandbox_config: SandboxConfig) -> dict[str, str]:
  30. ret = {}
  31. for key in os.environ:
  32. if key.startswith('SANDBOX_ENV_'):
  33. sandbox_key = key.removeprefix('SANDBOX_ENV_')
  34. ret[sandbox_key] = os.environ[key]
  35. if sandbox_config.enable_auto_lint:
  36. ret['ENABLE_AUTO_LINT'] = 'true'
  37. return ret
  38. class Runtime:
  39. """The runtime is how the agent interacts with the external environment.
  40. This includes a bash sandbox, a browser, and filesystem interactions.
  41. sid is the session id, which is used to identify the current user session.
  42. """
  43. sid: str
  44. config: AppConfig
  45. DEFAULT_ENV_VARS: dict[str, str]
  46. def __init__(
  47. self,
  48. config: AppConfig,
  49. event_stream: EventStream,
  50. sid: str = 'default',
  51. plugins: list[PluginRequirement] | None = None,
  52. env_vars: dict[str, str] | None = None,
  53. ):
  54. self.sid = sid
  55. self.event_stream = event_stream
  56. self.event_stream.subscribe(EventStreamSubscriber.RUNTIME, self.on_event)
  57. self.plugins = plugins if plugins is not None and len(plugins) > 0 else []
  58. self.config = copy.deepcopy(config)
  59. self.DEFAULT_ENV_VARS = _default_env_vars(config.sandbox)
  60. atexit.register(self.close)
  61. logger.debug(f'Runtime `{sid}`')
  62. if self.DEFAULT_ENV_VARS:
  63. logger.debug(f'Adding default env vars: {self.DEFAULT_ENV_VARS}')
  64. self.add_env_vars(self.DEFAULT_ENV_VARS)
  65. if env_vars is not None:
  66. logger.debug(f'Adding provided env vars: {env_vars}')
  67. self.add_env_vars(env_vars)
  68. def close(self) -> None:
  69. pass
  70. # ====================================================================
  71. def add_env_vars(self, env_vars: dict[str, str]) -> None:
  72. # Add env vars to the IPython shell (if Jupyter is used)
  73. if any(isinstance(plugin, JupyterRequirement) for plugin in self.plugins):
  74. code = 'import os\n'
  75. for key, value in env_vars.items():
  76. # Note: json.dumps gives us nice escaping for free
  77. code += f'os.environ["{key}"] = {json.dumps(value)}\n'
  78. code += '\n'
  79. obs = self.run_ipython(IPythonRunCellAction(code))
  80. logger.info(f'Added env vars to IPython: code={code}, obs={obs}')
  81. # Add env vars to the Bash shell
  82. cmd = ''
  83. for key, value in env_vars.items():
  84. # Note: json.dumps gives us nice escaping for free
  85. cmd += f'export {key}={json.dumps(value)}; '
  86. if not cmd:
  87. return
  88. cmd = cmd.strip()
  89. logger.debug(f'Adding env var: {cmd}')
  90. obs = self.run(CmdRunAction(cmd))
  91. if not isinstance(obs, CmdOutputObservation) or obs.exit_code != 0:
  92. raise RuntimeError(
  93. f'Failed to add env vars [{env_vars}] to environment: {obs.content}'
  94. )
  95. async def on_event(self, event: Event) -> None:
  96. if isinstance(event, Action):
  97. # set timeout to default if not set
  98. if event.timeout is None:
  99. event.timeout = self.config.sandbox.timeout
  100. assert event.timeout is not None
  101. observation = self.run_action(event)
  102. observation._cause = event.id # type: ignore[attr-defined]
  103. source = event.source if event.source else EventSource.AGENT
  104. self.event_stream.add_event(observation, source) # type: ignore[arg-type]
  105. def run_action(self, action: Action) -> Observation:
  106. """Run an action and return the resulting observation.
  107. If the action is not runnable in any runtime, a NullObservation is returned.
  108. If the action is not supported by the current runtime, an ErrorObservation is returned.
  109. """
  110. if not action.runnable:
  111. return NullObservation('')
  112. if (
  113. hasattr(action, 'is_confirmed')
  114. and action.is_confirmed == ActionConfirmationStatus.AWAITING_CONFIRMATION
  115. ):
  116. return NullObservation('')
  117. action_type = action.action # type: ignore[attr-defined]
  118. if action_type not in ACTION_TYPE_TO_CLASS:
  119. return ErrorObservation(f'Action {action_type} does not exist.')
  120. if not hasattr(self, action_type):
  121. return ErrorObservation(
  122. f'Action {action_type} is not supported in the current runtime.'
  123. )
  124. if (
  125. hasattr(action, 'is_confirmed')
  126. and action.is_confirmed == ActionConfirmationStatus.REJECTED
  127. ):
  128. return UserRejectObservation(
  129. 'Action has been rejected by the user! Waiting for further user input.'
  130. )
  131. observation = getattr(self, action_type)(action)
  132. return observation
  133. # ====================================================================
  134. # Context manager
  135. # ====================================================================
  136. def __enter__(self) -> 'Runtime':
  137. return self
  138. def __exit__(self, exc_type, exc_value, traceback) -> None:
  139. self.close()
  140. # ====================================================================
  141. # Action execution
  142. # ====================================================================
  143. @abstractmethod
  144. def run(self, action: CmdRunAction) -> Observation:
  145. pass
  146. @abstractmethod
  147. def run_ipython(self, action: IPythonRunCellAction) -> Observation:
  148. pass
  149. @abstractmethod
  150. def read(self, action: FileReadAction) -> Observation:
  151. pass
  152. @abstractmethod
  153. def write(self, action: FileWriteAction) -> Observation:
  154. pass
  155. @abstractmethod
  156. def browse(self, action: BrowseURLAction) -> Observation:
  157. pass
  158. @abstractmethod
  159. def browse_interactive(self, action: BrowseInteractiveAction) -> Observation:
  160. pass
  161. # ====================================================================
  162. # File operations
  163. # ====================================================================
  164. @abstractmethod
  165. def copy_to(self, host_src: str, sandbox_dest: str, recursive: bool = False):
  166. raise NotImplementedError('This method is not implemented in the base class.')
  167. @abstractmethod
  168. def list_files(self, path: str | None = None) -> list[str]:
  169. """List files in the sandbox.
  170. If path is None, list files in the sandbox's initial working directory (e.g., /workspace).
  171. """
  172. raise NotImplementedError('This method is not implemented in the base class.')