mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
5 Commits
enyst/cli-
...
refactor/b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
adbfae2600 | ||
|
|
213d2dc056 | ||
|
|
da38890aaf | ||
|
|
edb373cea8 | ||
|
|
f8c5be917c |
@@ -1,6 +1,13 @@
|
||||
from openhands.runtime.base import Runtime
|
||||
from openhands.runtime.impl.cli.cli_runtime import CLIRuntime
|
||||
from openhands.runtime.impl.daytona.daytona_runtime import DaytonaRuntime
|
||||
|
||||
try:
|
||||
from openhands.runtime.impl.daytona.daytona_runtime import DaytonaRuntime
|
||||
|
||||
_DAYTONA_AVAILABLE = True
|
||||
except ImportError:
|
||||
_DAYTONA_AVAILABLE = False
|
||||
DaytonaRuntime = None # type: ignore
|
||||
from openhands.runtime.impl.docker.docker_runtime import (
|
||||
DockerRuntime,
|
||||
)
|
||||
@@ -20,7 +27,7 @@ _DEFAULT_RUNTIME_CLASSES: dict[str, type[Runtime]] = {
|
||||
'modal': ModalRuntime,
|
||||
'runloop': RunloopRuntime,
|
||||
'local': LocalRuntime,
|
||||
'daytona': DaytonaRuntime,
|
||||
**({'daytona': DaytonaRuntime} if _DAYTONA_AVAILABLE else {}),
|
||||
'cli': CLIRuntime,
|
||||
}
|
||||
|
||||
@@ -49,7 +56,9 @@ __all__ = [
|
||||
'ModalRuntime',
|
||||
'RunloopRuntime',
|
||||
'DockerRuntime',
|
||||
'DaytonaRuntime',
|
||||
'CLIRuntime',
|
||||
'get_runtime_cls',
|
||||
]
|
||||
|
||||
if _DAYTONA_AVAILABLE:
|
||||
__all__.append('DaytonaRuntime')
|
||||
|
||||
@@ -6,7 +6,14 @@ from openhands.runtime.impl.action_execution.action_execution_client import (
|
||||
ActionExecutionClient,
|
||||
)
|
||||
from openhands.runtime.impl.cli import CLIRuntime
|
||||
from openhands.runtime.impl.daytona.daytona_runtime import DaytonaRuntime
|
||||
|
||||
try:
|
||||
from openhands.runtime.impl.daytona.daytona_runtime import DaytonaRuntime
|
||||
|
||||
_DAYTONA_AVAILABLE = True
|
||||
except ImportError:
|
||||
_DAYTONA_AVAILABLE = False
|
||||
DaytonaRuntime = None # type: ignore
|
||||
from openhands.runtime.impl.docker.docker_runtime import DockerRuntime
|
||||
from openhands.runtime.impl.e2b.e2b_runtime import E2BRuntime
|
||||
from openhands.runtime.impl.local.local_runtime import LocalRuntime
|
||||
@@ -17,7 +24,6 @@ from openhands.runtime.impl.runloop.runloop_runtime import RunloopRuntime
|
||||
__all__ = [
|
||||
'ActionExecutionClient',
|
||||
'CLIRuntime',
|
||||
'DaytonaRuntime',
|
||||
'DockerRuntime',
|
||||
'E2BRuntime',
|
||||
'LocalRuntime',
|
||||
@@ -25,3 +31,6 @@ __all__ = [
|
||||
'RemoteRuntime',
|
||||
'RunloopRuntime',
|
||||
]
|
||||
|
||||
if _DAYTONA_AVAILABLE:
|
||||
__all__.append('DaytonaRuntime')
|
||||
|
||||
@@ -5,6 +5,7 @@ It does not implement browser functionality.
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import re
|
||||
import select
|
||||
import shutil
|
||||
import signal
|
||||
@@ -50,6 +51,7 @@ from openhands.integrations.provider import PROVIDER_TOKEN_TYPE
|
||||
from openhands.runtime.base import Runtime
|
||||
from openhands.runtime.plugins import PluginRequirement
|
||||
from openhands.runtime.runtime_status import RuntimeStatus
|
||||
from openhands.runtime.utils.bash import SubprocessBashSession
|
||||
|
||||
|
||||
class CLIRuntime(Runtime):
|
||||
@@ -119,6 +121,13 @@ class CLIRuntime(Runtime):
|
||||
self.file_editor = OHEditor(workspace_root=self._workspace_path)
|
||||
self._shell_stream_callback: Callable[[str], None] | None = None
|
||||
|
||||
# Initialize bash session
|
||||
self.bash_session = SubprocessBashSession(
|
||||
work_dir=self._workspace_path,
|
||||
username=None,
|
||||
no_change_timeout_seconds=30,
|
||||
)
|
||||
|
||||
logger.warning(
|
||||
'Initializing CLIRuntime. WARNING: NO SANDBOX IS USED. '
|
||||
'This runtime executes commands directly on the local system. '
|
||||
@@ -138,6 +147,9 @@ class CLIRuntime(Runtime):
|
||||
if not self.attach_to_existing:
|
||||
await asyncio.to_thread(self.setup_initial_env)
|
||||
|
||||
# Initialize bash session
|
||||
self.bash_session.initialize()
|
||||
|
||||
self._runtime_initialized = True
|
||||
self.set_runtime_status(RuntimeStatus.RUNTIME_STARTED)
|
||||
logger.info(f'CLIRuntime initialized with workspace at {self._workspace_path}')
|
||||
@@ -351,7 +363,7 @@ class CLIRuntime(Runtime):
|
||||
)
|
||||
|
||||
def run(self, action: CmdRunAction) -> Observation:
|
||||
"""Run a command using subprocess."""
|
||||
"""Run a command using the bash session."""
|
||||
if not self._runtime_initialized:
|
||||
return ErrorObservation(
|
||||
f'Runtime not initialized for command: {action.command}'
|
||||
@@ -369,18 +381,36 @@ class CLIRuntime(Runtime):
|
||||
)
|
||||
|
||||
try:
|
||||
effective_timeout = (
|
||||
action.timeout
|
||||
if action.timeout is not None
|
||||
else self.config.sandbox.timeout
|
||||
)
|
||||
# Set effective timeout if not already set
|
||||
if action.timeout is None:
|
||||
action.set_hard_timeout(self.config.sandbox.timeout)
|
||||
|
||||
logger.debug(
|
||||
f'Running command in CLIRuntime: "{action.command}" with effective timeout: {effective_timeout}s'
|
||||
)
|
||||
return self._execute_shell_command(
|
||||
action.command, timeout=effective_timeout
|
||||
f'Running command in CLIRuntime: "{action.command}" with effective timeout: {action.timeout}s'
|
||||
)
|
||||
|
||||
# Use the bash session to execute the command
|
||||
obs = self.bash_session.execute(action)
|
||||
|
||||
# For CLIRuntime, we need to adjust the timeout message format and working directory
|
||||
if isinstance(obs, CmdOutputObservation):
|
||||
# Fix timeout message format for CLIRuntime
|
||||
if obs.metadata.suffix and 'timed out after' in obs.metadata.suffix:
|
||||
# Extract timeout duration from the suffix
|
||||
match = re.search(
|
||||
r'timed out after ([\d.]+) seconds', obs.metadata.suffix
|
||||
)
|
||||
if match:
|
||||
timeout_duration = match.group(1)
|
||||
obs.metadata.suffix = (
|
||||
f'[The command timed out after {timeout_duration} seconds.]'
|
||||
)
|
||||
|
||||
# Fix working directory for CLIRuntime
|
||||
obs.metadata.working_dir = self._workspace_path
|
||||
|
||||
return obs
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f'Error in CLIRuntime.run for command "{action.command}": {str(e)}'
|
||||
@@ -737,6 +767,10 @@ class CLIRuntime(Runtime):
|
||||
raise RuntimeError(f'Error creating zip file: {str(e)}')
|
||||
|
||||
def close(self) -> None:
|
||||
# Clean up bash session
|
||||
if hasattr(self, 'bash_session'):
|
||||
self.bash_session.close()
|
||||
|
||||
self._runtime_initialized = False
|
||||
super().close()
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import time
|
||||
import traceback
|
||||
import uuid
|
||||
@@ -167,6 +168,7 @@ class BashCommandStatus(Enum):
|
||||
COMPLETED = 'completed'
|
||||
NO_CHANGE_TIMEOUT = 'no_change_timeout'
|
||||
HARD_TIMEOUT = 'hard_timeout'
|
||||
INTERRUPTED = 'interrupted'
|
||||
|
||||
|
||||
def _remove_command_prefix(command_output: str, command: str) -> str:
|
||||
@@ -654,3 +656,247 @@ class BashSession:
|
||||
logger.debug(f'SLEEPING for {self.POLL_INTERVAL} seconds for next poll')
|
||||
time.sleep(self.POLL_INTERVAL)
|
||||
raise RuntimeError('Bash session was likely interrupted...')
|
||||
|
||||
|
||||
class SubprocessBashSession(BashSession):
|
||||
"""
|
||||
A bash session implementation using individual subprocess calls
|
||||
instead of tmux, while maintaining the same interface as BashSession.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
work_dir: str,
|
||||
username: str | None = None,
|
||||
no_change_timeout_seconds: int = 30,
|
||||
max_memory_mb: int | None = None,
|
||||
allow_multiple_commands: bool = True,
|
||||
):
|
||||
# Initialize parent class attributes
|
||||
self.work_dir = work_dir
|
||||
self.username = username
|
||||
self.no_change_timeout_seconds = no_change_timeout_seconds
|
||||
self.max_memory_mb = max_memory_mb
|
||||
self.allow_multiple_commands = allow_multiple_commands
|
||||
self._initialized = False
|
||||
|
||||
# Set initial state
|
||||
self.prev_status: BashCommandStatus | None = None
|
||||
self.prev_output: str = ''
|
||||
self._closed: bool = False
|
||||
self._cwd = os.path.abspath(self.work_dir)
|
||||
self._current_process: subprocess.Popen | None = None
|
||||
|
||||
def initialize(self) -> None:
|
||||
"""Initialize the bash session."""
|
||||
logger.debug(
|
||||
f'Initializing subprocess bash session with work dir: {self.work_dir}'
|
||||
)
|
||||
|
||||
# Set initial state
|
||||
self._initialized = True
|
||||
|
||||
logger.debug(
|
||||
f'Subprocess bash session initialized with work dir: {self.work_dir}'
|
||||
)
|
||||
|
||||
def close(self) -> None:
|
||||
"""Clean up the session."""
|
||||
if self._current_process and self._current_process.poll() is None:
|
||||
self._current_process.terminate()
|
||||
try:
|
||||
self._current_process.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
self._current_process.kill()
|
||||
self._closed = True
|
||||
|
||||
def interrupt(self) -> None:
|
||||
"""Interrupt the currently running command (Ctrl+C equivalent)."""
|
||||
if self._current_process and self._current_process.poll() is None:
|
||||
logger.debug('Interrupting current command')
|
||||
self._current_process.terminate()
|
||||
self.prev_status = BashCommandStatus.INTERRUPTED
|
||||
|
||||
def get_status(self) -> BashCommandStatus | None:
|
||||
"""Get the status of the last command."""
|
||||
return self.prev_status
|
||||
|
||||
def is_running(self) -> bool:
|
||||
"""Check if a command is currently running."""
|
||||
return (
|
||||
self._current_process is not None and self._current_process.poll() is None
|
||||
)
|
||||
|
||||
def execute(self, action: CmdRunAction) -> CmdOutputObservation | ErrorObservation:
|
||||
"""Execute a command in the bash session using subprocess."""
|
||||
from openhands.events.observation.commands import CmdOutputMetadata
|
||||
|
||||
if not self._initialized:
|
||||
return ErrorObservation(content='Subprocess bash session not initialized')
|
||||
|
||||
command = action.command
|
||||
|
||||
# Handle interactive input (not supported in subprocess mode)
|
||||
if action.is_input:
|
||||
return ErrorObservation(
|
||||
content=f"Subprocess bash session does not support interactive input. The command '{command}' was not sent to any process."
|
||||
)
|
||||
|
||||
# Handle empty commands
|
||||
if command == '':
|
||||
return CmdOutputObservation(
|
||||
content='ERROR: No command provided.',
|
||||
command='',
|
||||
metadata=CmdOutputMetadata(),
|
||||
)
|
||||
|
||||
# Check for multiple commands based on configuration
|
||||
if not self.allow_multiple_commands:
|
||||
splited_commands = split_bash_commands(command)
|
||||
if len(splited_commands) > 1:
|
||||
return ErrorObservation(
|
||||
content=(
|
||||
f'ERROR: Cannot execute multiple commands at once.\n'
|
||||
f'Please run each command separately OR chain them into a single command via && or ;\n'
|
||||
f'Provided commands:\n{"\n".join(f"({i + 1}) {cmd}" for i, cmd in enumerate(splited_commands))}'
|
||||
)
|
||||
)
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
try:
|
||||
# Prepare the command
|
||||
escaped_command = escape_bash_special_chars(command)
|
||||
logger.debug(f'EXECUTING COMMAND: {escaped_command!r}')
|
||||
|
||||
# Set effective timeout
|
||||
effective_timeout = action.timeout if action.timeout else 30.0
|
||||
|
||||
# Check if this is a background command (ends with &)
|
||||
is_background = command.strip().endswith('&')
|
||||
|
||||
# Execute the command using subprocess
|
||||
self._current_process = subprocess.Popen(
|
||||
['bash', '-c', escaped_command],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
cwd=self._cwd,
|
||||
)
|
||||
|
||||
try:
|
||||
if is_background:
|
||||
# For background commands, wait a short time to see if bash exits quickly
|
||||
# Background commands should cause bash to return immediately with exit code 0
|
||||
try:
|
||||
stdout, stderr = self._current_process.communicate(timeout=0.5)
|
||||
exit_code = self._current_process.returncode
|
||||
except subprocess.TimeoutExpired:
|
||||
# If bash doesn't exit quickly, it means the command is still running
|
||||
# This shouldn't happen for proper background commands, but handle it
|
||||
self._current_process.kill()
|
||||
stdout, stderr = self._current_process.communicate()
|
||||
exit_code = 0 # Treat as successful background launch
|
||||
else:
|
||||
stdout, stderr = self._current_process.communicate(
|
||||
timeout=effective_timeout
|
||||
)
|
||||
exit_code = self._current_process.returncode
|
||||
|
||||
# Check if process was interrupted (negative exit codes indicate signals)
|
||||
if exit_code < 0:
|
||||
self.prev_status = BashCommandStatus.INTERRUPTED
|
||||
else:
|
||||
self.prev_status = BashCommandStatus.COMPLETED
|
||||
|
||||
# Combine output and error
|
||||
combined_output = stdout
|
||||
if stderr:
|
||||
combined_output += f'\n{stderr}'
|
||||
|
||||
# Update working directory if it's a cd command
|
||||
if command.strip().startswith('cd '):
|
||||
try:
|
||||
# Try to get the new working directory
|
||||
pwd_process = subprocess.run(
|
||||
['bash', '-c', f'{escaped_command}; pwd'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=self._cwd,
|
||||
timeout=5,
|
||||
)
|
||||
if pwd_process.returncode == 0:
|
||||
new_cwd = pwd_process.stdout.strip()
|
||||
if os.path.isdir(new_cwd):
|
||||
self._cwd = new_cwd
|
||||
except Exception as e:
|
||||
logger.debug(f'Failed to update working directory: {e}')
|
||||
|
||||
# Create metadata
|
||||
metadata = CmdOutputMetadata()
|
||||
metadata.exit_code = exit_code
|
||||
metadata.working_dir = self._cwd
|
||||
|
||||
self.prev_output = ''
|
||||
|
||||
return CmdOutputObservation(
|
||||
content=combined_output.rstrip() if combined_output else '',
|
||||
command=command,
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
# Handle timeout
|
||||
self._current_process.kill()
|
||||
elapsed_time = time.time() - start_time
|
||||
|
||||
# Try to get partial output
|
||||
try:
|
||||
stdout, stderr = self._current_process.communicate(timeout=1.0)
|
||||
partial_output = stdout
|
||||
if stderr:
|
||||
partial_output += f'\n{stderr}'
|
||||
except subprocess.TimeoutExpired:
|
||||
partial_output = ''
|
||||
|
||||
metadata = CmdOutputMetadata()
|
||||
metadata.suffix = (
|
||||
f'\n[The command timed out after {elapsed_time:.1f} seconds. '
|
||||
f'{TIMEOUT_MESSAGE_TEMPLATE}]'
|
||||
)
|
||||
|
||||
self.prev_status = BashCommandStatus.HARD_TIMEOUT
|
||||
|
||||
return CmdOutputObservation(
|
||||
content=partial_output.rstrip() if partial_output else '',
|
||||
command=command,
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
finally:
|
||||
# Clear current process reference
|
||||
self._current_process = None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Error executing command "{command}": {e}')
|
||||
return ErrorObservation(
|
||||
content=f'Error executing command "{command}": {str(e)}'
|
||||
)
|
||||
|
||||
def _ready_for_next_command(self) -> None:
|
||||
"""Reset state for next command."""
|
||||
pass
|
||||
|
||||
def _get_pane_content(self) -> str:
|
||||
"""Get current output."""
|
||||
return ''
|
||||
|
||||
@property
|
||||
def cwd(self) -> str:
|
||||
"""Get current working directory."""
|
||||
return self._cwd
|
||||
|
||||
@property
|
||||
def initialized(self) -> bool:
|
||||
"""Check if the session is initialized."""
|
||||
return self._initialized
|
||||
|
||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
0
tests/runtime/__init__.py
Normal file
0
tests/runtime/__init__.py
Normal file
Reference in New Issue
Block a user