diff --git a/openhands/agenthub/codeact_agent/codeact_agent.py b/openhands/agenthub/codeact_agent/codeact_agent.py index f73ff2b601..83e72010c4 100644 --- a/openhands/agenthub/codeact_agent/codeact_agent.py +++ b/openhands/agenthub/codeact_agent/codeact_agent.py @@ -146,7 +146,8 @@ class CodeActAgent(Agent): elif self.config.enable_editor: tools.append( create_str_replace_editor_tool( - use_short_description=use_short_tool_desc + use_short_description=use_short_tool_desc, + runtime_type=self.config.runtime, ) ) return tools diff --git a/openhands/agenthub/codeact_agent/tools/str_replace_editor.py b/openhands/agenthub/codeact_agent/tools/str_replace_editor.py index 1e705888d1..b5c67c1025 100644 --- a/openhands/agenthub/codeact_agent/tools/str_replace_editor.py +++ b/openhands/agenthub/codeact_agent/tools/str_replace_editor.py @@ -1,9 +1,12 @@ +import os + from litellm import ChatCompletionToolParam, ChatCompletionToolParamFunctionChunk from openhands.agenthub.codeact_agent.tools.security_utils import ( RISK_LEVELS, SECURITY_RISK_DESC, ) +from openhands.core.config.config_utils import DEFAULT_WORKSPACE_MOUNT_PATH_IN_SANDBOX from openhands.llm.tool_names import STR_REPLACE_EDITOR_TOOL_NAME _DETAILED_STR_REPLACE_EDITOR_DESCRIPTION = """Custom editing tool for viewing, creating and editing files in plain-text format @@ -52,9 +55,51 @@ Notes for using the `str_replace` command: """ +def _get_workspace_mount_path_from_env(runtime_type: str | None = None) -> str: + """Get the workspace mount path from SANDBOX_VOLUMES environment variable. + + For LocalRuntime and CLIRuntime, returns the host path from SANDBOX_VOLUMES. + For other runtimes, returns the default container path (/workspace). + + Args: + runtime_type: The runtime type ('local', 'cli', 'docker', etc.) + + Returns: + The workspace mount path in sandbox, defaults to '/workspace' if not found. + """ + # For LocalRuntime/CLIRuntime, try to get host path from SANDBOX_VOLUMES + if runtime_type in ('local', 'cli'): + sandbox_volumes = os.environ.get('SANDBOX_VOLUMES') + if sandbox_volumes: + # Split by commas to handle multiple mounts + mounts = sandbox_volumes.split(',') + + # Check if any mount explicitly targets /workspace + for mount in mounts: + parts = mount.split(':') + if len(parts) >= 2 and parts[1] == '/workspace': + host_path = os.path.abspath(parts[0]) + return host_path + + # Fallback for local/CLI runtimes when SANDBOX_VOLUMES is not set: + # Use current working directory as it's likely the workspace root + return os.getcwd() + + # For all other runtimes (docker, remote, etc.), use default container path + return DEFAULT_WORKSPACE_MOUNT_PATH_IN_SANDBOX + + def create_str_replace_editor_tool( use_short_description: bool = False, + workspace_mount_path_in_sandbox: str | None = None, + runtime_type: str | None = None, ) -> ChatCompletionToolParam: + # If no workspace path is provided, try to get it from environment + if workspace_mount_path_in_sandbox is None: + workspace_mount_path_in_sandbox = _get_workspace_mount_path_from_env( + runtime_type + ) + description = ( _SHORT_STR_REPLACE_EDITOR_DESCRIPTION if use_short_description @@ -80,7 +125,7 @@ def create_str_replace_editor_tool( 'type': 'string', }, 'path': { - 'description': 'Absolute path to file or directory, e.g. `/workspace/file.py` or `/workspace`.', + 'description': f'Absolute path to file or directory, e.g. `{workspace_mount_path_in_sandbox}/file.py` or `{workspace_mount_path_in_sandbox}`.', 'type': 'string', }, 'file_text': { diff --git a/openhands/core/config/agent_config.py b/openhands/core/config/agent_config.py index fac57ce1fb..3c506c9382 100644 --- a/openhands/core/config/agent_config.py +++ b/openhands/core/config/agent_config.py @@ -62,6 +62,8 @@ class AgentConfig(BaseModel): """Model routing configuration settings.""" extended: ExtendedConfig = Field(default_factory=lambda: ExtendedConfig({})) """Extended configuration for the agent.""" + runtime: str | None = Field(default=None) + """Runtime type (e.g., 'docker', 'local', 'cli') used for runtime-specific tool behavior.""" model_config = ConfigDict(extra='forbid') diff --git a/openhands/core/setup.py b/openhands/core/setup.py index 38fa7e9f3d..855e765f59 100644 --- a/openhands/core/setup.py +++ b/openhands/core/setup.py @@ -202,6 +202,8 @@ def create_memory( def create_agent(config: OpenHandsConfig, llm_registry: LLMRegistry) -> Agent: agent_cls: type[Agent] = Agent.get_cls(config.default_agent) agent_config = config.get_agent_config(config.default_agent) + # Pass the runtime information from the main config to the agent config + agent_config.runtime = config.runtime config.get_llm_config_from_agent(config.default_agent) agent = agent_cls(config=agent_config, llm_registry=llm_registry) return agent diff --git a/openhands/server/session/session.py b/openhands/server/session/session.py index c38bba2122..46638e15b7 100644 --- a/openhands/server/session/session.py +++ b/openhands/server/session/session.py @@ -212,6 +212,8 @@ class WebSession: # TODO: override other LLM config & agent config groups (#2075) agent_config = self.config.get_agent_config(agent_cls) + # Pass runtime information to agent config for runtime-specific tool behavior + agent_config.runtime = self.config.runtime agent_name = agent_cls if agent_cls is not None else 'agent' llm_config = self.config.get_llm_config_from_agent(agent_name) if settings.enable_default_condenser: diff --git a/tests/unit/agenthub/test_str_replace_editor_tool.py b/tests/unit/agenthub/test_str_replace_editor_tool.py new file mode 100644 index 0000000000..28beeaeefd --- /dev/null +++ b/tests/unit/agenthub/test_str_replace_editor_tool.py @@ -0,0 +1,225 @@ +"""Tests for str_replace_editor tool workspace path detection.""" + +import os +import sys +from unittest.mock import patch + +import pytest + +# Import the functions directly to avoid dependency issues +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '../../..')) + +try: + from openhands.agenthub.codeact_agent.tools.str_replace_editor import ( + _get_workspace_mount_path_from_env, + create_str_replace_editor_tool, + ) +except ImportError: + # If import fails due to dependencies, skip these tests + pytest.skip( + 'Cannot import str_replace_editor due to missing dependencies', + allow_module_level=True, + ) + + +class TestWorkspacePathDetection: + """Test workspace path detection logic.""" + + def test_docker_runtime_ignores_sandbox_volumes(self): + """Test that DockerRuntime ignores SANDBOX_VOLUMES and uses /workspace.""" + with patch.dict(os.environ, {'SANDBOX_VOLUMES': '/host/app:/workspace:rw'}): + result = _get_workspace_mount_path_from_env(runtime_type='docker') + assert result == '/workspace' + + def test_remote_runtime_ignores_sandbox_volumes(self): + """Test that RemoteRuntime ignores SANDBOX_VOLUMES and uses /workspace.""" + with patch.dict(os.environ, {'SANDBOX_VOLUMES': '/host/app:/workspace:rw'}): + result = _get_workspace_mount_path_from_env(runtime_type='remote') + assert result == '/workspace' + + def test_e2b_runtime_ignores_sandbox_volumes(self): + """Test that E2BRuntime ignores SANDBOX_VOLUMES and uses /workspace.""" + with patch.dict(os.environ, {'SANDBOX_VOLUMES': '/host/app:/workspace:rw'}): + result = _get_workspace_mount_path_from_env(runtime_type='e2b') + assert result == '/workspace' + + def test_local_runtime_uses_sandbox_volumes(self): + """Test that LocalRuntime uses host path from SANDBOX_VOLUMES.""" + with patch.dict(os.environ, {'SANDBOX_VOLUMES': '/host/app:/workspace:rw'}): + result = _get_workspace_mount_path_from_env(runtime_type='local') + assert result == '/host/app' + + def test_local_runtime_multiple_mounts(self): + """Test LocalRuntime with multiple mounts, finds /workspace mount.""" + with patch.dict( + os.environ, + { + 'SANDBOX_VOLUMES': '/tmp:/tmp:rw,/home/user/project:/workspace:rw,/var:/var:rw' + }, + ): + result = _get_workspace_mount_path_from_env(runtime_type='local') + assert result == '/home/user/project' + + def test_cli_runtime_multiple_mounts(self): + """Test CLIRuntime with multiple mounts, finds /workspace mount.""" + with patch.dict( + os.environ, + { + 'SANDBOX_VOLUMES': '/tmp:/tmp:rw,/home/user/project:/workspace:rw,/var:/var:rw' + }, + ): + result = _get_workspace_mount_path_from_env(runtime_type='cli') + assert result == '/home/user/project' + + def test_local_runtime_no_workspace_mount(self): + """Test LocalRuntime with no /workspace mount falls back to current directory.""" + with patch.dict(os.environ, {'SANDBOX_VOLUMES': '/tmp:/tmp:rw,/var:/var:rw'}): + result = _get_workspace_mount_path_from_env(runtime_type='local') + assert result == os.getcwd() + + def test_local_runtime_no_sandbox_volumes(self): + """Test LocalRuntime with no SANDBOX_VOLUMES falls back to current directory.""" + with patch.dict(os.environ, {}, clear=True): + result = _get_workspace_mount_path_from_env(runtime_type='local') + assert result == os.getcwd() + + def test_local_runtime_empty_sandbox_volumes(self): + """Test LocalRuntime with empty SANDBOX_VOLUMES falls back to current directory.""" + with patch.dict(os.environ, {'SANDBOX_VOLUMES': ''}): + result = _get_workspace_mount_path_from_env(runtime_type='local') + assert result == os.getcwd() + + def test_relative_path_conversion(self): + """Test that relative paths in SANDBOX_VOLUMES are converted to absolute.""" + with patch.dict( + os.environ, {'SANDBOX_VOLUMES': './relative/path:/workspace:rw'} + ): + result = _get_workspace_mount_path_from_env(runtime_type='local') + # Should be converted to absolute path + assert os.path.isabs(result) + assert result.endswith('relative/path') + + def test_unknown_runtime_type(self): + """Test that unknown runtime types use default container path.""" + with patch.dict(os.environ, {'SANDBOX_VOLUMES': '/host/app:/workspace:rw'}): + result = _get_workspace_mount_path_from_env(runtime_type='unknown') + assert result == '/workspace' + + def test_none_runtime_type(self): + """Test that None runtime type uses default container path.""" + with patch.dict(os.environ, {'SANDBOX_VOLUMES': '/host/app:/workspace:rw'}): + result = _get_workspace_mount_path_from_env(runtime_type=None) + assert result == '/workspace' + + +class TestToolCreation: + """Test tool creation with dynamic workspace paths.""" + + def test_tool_creation_docker_runtime(self): + """Test tool creation in DockerRuntime shows /workspace in description.""" + with patch.dict(os.environ, {'SANDBOX_VOLUMES': '/host/app:/workspace:rw'}): + tool = create_str_replace_editor_tool(runtime_type='docker') + path_description = tool['function']['parameters']['properties']['path'][ + 'description' + ] + assert '/workspace/file.py' in path_description + assert '/workspace`.' in path_description + # Should not contain host paths + assert '/host/app' not in path_description + + def test_tool_creation_local_runtime(self): + """Test tool creation in LocalRuntime shows host path in description.""" + with patch.dict(os.environ, {'SANDBOX_VOLUMES': '/host/app:/workspace:rw'}): + tool = create_str_replace_editor_tool(runtime_type='local') + path_description = tool['function']['parameters']['properties']['path'][ + 'description' + ] + assert '/host/app/file.py' in path_description + assert '/host/app`.' in path_description + + def test_tool_creation_cli_runtime(self): + """Test tool creation in CLIRuntime shows host path in description.""" + with patch.dict(os.environ, {'SANDBOX_VOLUMES': '/host/app:/workspace:rw'}): + tool = create_str_replace_editor_tool(runtime_type='cli') + path_description = tool['function']['parameters']['properties']['path'][ + 'description' + ] + assert '/host/app/file.py' in path_description + assert '/host/app`.' in path_description + + def test_tool_creation_remote_runtime(self): + """Test tool creation in RemoteRuntime shows /workspace in description.""" + with patch.dict(os.environ, {'SANDBOX_VOLUMES': '/host/app:/workspace:rw'}): + tool = create_str_replace_editor_tool(runtime_type='remote') + path_description = tool['function']['parameters']['properties']['path'][ + 'description' + ] + assert '/workspace/file.py' in path_description + assert '/workspace`.' in path_description + # Should not contain host paths + assert '/host/app' not in path_description + + def test_tool_creation_e2b_runtime(self): + """Test tool creation in E2BRuntime shows /workspace in description.""" + with patch.dict(os.environ, {'SANDBOX_VOLUMES': '/host/app:/workspace:rw'}): + tool = create_str_replace_editor_tool(runtime_type='e2b') + path_description = tool['function']['parameters']['properties']['path'][ + 'description' + ] + assert '/workspace/file.py' in path_description + assert '/workspace`.' in path_description + # Should not contain host paths + assert '/host/app' not in path_description + + def test_tool_creation_explicit_workspace_path(self): + """Test tool creation with explicitly provided workspace path.""" + tool = create_str_replace_editor_tool( + workspace_mount_path_in_sandbox='/custom/path' + ) + path_description = tool['function']['parameters']['properties']['path'][ + 'description' + ] + assert '/custom/path/file.py' in path_description + assert '/custom/path`.' in path_description + + def test_tool_creation_short_description(self): + """Test tool creation with short description still has correct path.""" + with patch.dict(os.environ, {'SANDBOX_VOLUMES': '/host/app:/workspace:rw'}): + tool = create_str_replace_editor_tool( + use_short_description=True, runtime_type='local' + ) + path_description = tool['function']['parameters']['properties']['path'][ + 'description' + ] + assert '/host/app/file.py' in path_description + assert '/host/app`.' in path_description + + +class TestEdgeCases: + """Test edge cases and error conditions.""" + + def test_malformed_sandbox_volumes(self): + """Test handling of malformed SANDBOX_VOLUMES.""" + # Missing colon separator - falls back to current directory for local/CLI + with patch.dict(os.environ, {'SANDBOX_VOLUMES': '/host/app/workspace'}): + result = _get_workspace_mount_path_from_env(runtime_type='local') + assert result == os.getcwd() + + # Only one part - falls back to current directory for local/CLI + with patch.dict(os.environ, {'SANDBOX_VOLUMES': '/host/app:'}): + result = _get_workspace_mount_path_from_env(runtime_type='local') + assert result == os.getcwd() + + def test_workspace_mount_with_different_permissions(self): + """Test /workspace mount with different permission strings.""" + test_cases = [ + '/host/app:/workspace:ro', + '/host/app:/workspace:rw', + '/host/app:/workspace', # No permissions + '/host/app:/workspace:rw,size=100m', # Extra options + ] + + for sandbox_volumes in test_cases: + with patch.dict(os.environ, {'SANDBOX_VOLUMES': sandbox_volumes}): + result = _get_workspace_mount_path_from_env(runtime_type='local') + assert result == '/host/app', f'Failed for: {sandbox_volumes}'