runtime.py 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201
  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. initial_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. atexit.register(self.close)
  60. self.initial_env_vars = _default_env_vars(config.sandbox)
  61. if env_vars is not None:
  62. self.initial_env_vars.update(env_vars)
  63. def setup_initial_env(self) -> None:
  64. logger.debug(f'Adding env vars: {self.initial_env_vars}')
  65. self.add_env_vars(self.initial_env_vars)
  66. def close(self) -> None:
  67. pass
  68. # ====================================================================
  69. def add_env_vars(self, env_vars: dict[str, str]) -> None:
  70. # Add env vars to the IPython shell (if Jupyter is used)
  71. if any(isinstance(plugin, JupyterRequirement) for plugin in self.plugins):
  72. code = 'import os\n'
  73. for key, value in env_vars.items():
  74. # Note: json.dumps gives us nice escaping for free
  75. code += f'os.environ["{key}"] = {json.dumps(value)}\n'
  76. code += '\n'
  77. obs = self.run_ipython(IPythonRunCellAction(code))
  78. logger.info(f'Added env vars to IPython: code={code}, obs={obs}')
  79. # Add env vars to the Bash shell
  80. cmd = ''
  81. for key, value in env_vars.items():
  82. # Note: json.dumps gives us nice escaping for free
  83. cmd += f'export {key}={json.dumps(value)}; '
  84. if not cmd:
  85. return
  86. cmd = cmd.strip()
  87. logger.debug(f'Adding env var: {cmd}')
  88. obs = self.run(CmdRunAction(cmd))
  89. if not isinstance(obs, CmdOutputObservation) or obs.exit_code != 0:
  90. raise RuntimeError(
  91. f'Failed to add env vars [{env_vars}] to environment: {obs.content}'
  92. )
  93. async def on_event(self, event: Event) -> None:
  94. if isinstance(event, Action):
  95. # set timeout to default if not set
  96. if event.timeout is None:
  97. event.timeout = self.config.sandbox.timeout
  98. assert event.timeout is not None
  99. observation = self.run_action(event)
  100. observation._cause = event.id # type: ignore[attr-defined]
  101. source = event.source if event.source else EventSource.AGENT
  102. self.event_stream.add_event(observation, source) # type: ignore[arg-type]
  103. def run_action(self, action: Action) -> Observation:
  104. """Run an action and return the resulting observation.
  105. If the action is not runnable in any runtime, a NullObservation is returned.
  106. If the action is not supported by the current runtime, an ErrorObservation is returned.
  107. """
  108. if not action.runnable:
  109. return NullObservation('')
  110. if (
  111. hasattr(action, 'is_confirmed')
  112. and action.is_confirmed == ActionConfirmationStatus.AWAITING_CONFIRMATION
  113. ):
  114. return NullObservation('')
  115. action_type = action.action # type: ignore[attr-defined]
  116. if action_type not in ACTION_TYPE_TO_CLASS:
  117. return ErrorObservation(f'Action {action_type} does not exist.')
  118. if not hasattr(self, action_type):
  119. return ErrorObservation(
  120. f'Action {action_type} is not supported in the current runtime.'
  121. )
  122. if (
  123. hasattr(action, 'is_confirmed')
  124. and action.is_confirmed == ActionConfirmationStatus.REJECTED
  125. ):
  126. return UserRejectObservation(
  127. 'Action has been rejected by the user! Waiting for further user input.'
  128. )
  129. observation = getattr(self, action_type)(action)
  130. return observation
  131. # ====================================================================
  132. # Context manager
  133. # ====================================================================
  134. def __enter__(self) -> 'Runtime':
  135. return self
  136. def __exit__(self, exc_type, exc_value, traceback) -> None:
  137. self.close()
  138. # ====================================================================
  139. # Action execution
  140. # ====================================================================
  141. @abstractmethod
  142. def run(self, action: CmdRunAction) -> Observation:
  143. pass
  144. @abstractmethod
  145. def run_ipython(self, action: IPythonRunCellAction) -> Observation:
  146. pass
  147. @abstractmethod
  148. def read(self, action: FileReadAction) -> Observation:
  149. pass
  150. @abstractmethod
  151. def write(self, action: FileWriteAction) -> Observation:
  152. pass
  153. @abstractmethod
  154. def browse(self, action: BrowseURLAction) -> Observation:
  155. pass
  156. @abstractmethod
  157. def browse_interactive(self, action: BrowseInteractiveAction) -> Observation:
  158. pass
  159. # ====================================================================
  160. # File operations
  161. # ====================================================================
  162. @abstractmethod
  163. def copy_to(self, host_src: str, sandbox_dest: str, recursive: bool = False):
  164. raise NotImplementedError('This method is not implemented in the base class.')
  165. @abstractmethod
  166. def list_files(self, path: str | None = None) -> list[str]:
  167. """List files in the sandbox.
  168. If path is None, list files in the sandbox's initial working directory (e.g., /workspace).
  169. """
  170. raise NotImplementedError('This method is not implemented in the base class.')