diff --git a/EvoScientist/backends.py b/EvoScientist/backends.py index 32cba9b..f9f3dfd 100644 --- a/EvoScientist/backends.py +++ b/EvoScientist/backends.py @@ -69,30 +69,54 @@ def validate_command(command: str) -> str | None: return None -def convert_virtual_paths_in_command(command: str) -> str: +def convert_virtual_paths_in_command( + command: str, + workspace_name: str | None = None, +) -> str: """ Convert virtual paths (starting with /) in commands to relative paths. - Examples: - - "python /main.py" -> "python ./main.py" - - "cat /data/file.txt" -> "cat ./data/file.txt" - - "ls /" -> "ls ." - - "python main.py" -> "python main.py" (unchanged) + Also auto-corrects hallucinated system absolute paths that reference the + workspace directory (e.g. ``/Users/.../myproject/file.py`` → ``./file.py``). Args: - command: Original command + command: Original command. + workspace_name: Basename of the workspace directory (e.g. ``"workspace"``, + ``"my-project"``). When provided, system paths containing + ``//`` are auto-corrected. - Returns: - Converted command + Examples: + >>> convert_virtual_paths_in_command("python /main.py") + 'python ./main.py' + >>> convert_virtual_paths_in_command("ls /") + 'ls .' + >>> convert_virtual_paths_in_command( + ... "mkdir -p /Users/u/proj/dir", workspace_name="proj") + 'mkdir -p ./dir' """ - def replace_virtual_path(match): + def replace_virtual_path(match: re.Match[str]) -> str: path = match.group(0) # Skip content that looks like a URL if '://' in command[max(0, match.start() - 10):match.end() + 10]: return path + # Fix hallucinated system absolute paths that reference the workspace. + # E.g. /Users/user/.../myproject/file.py → ./file.py + # This mirrors _resolve_path() logic but for shell command strings. + if workspace_name: + for prefix in _SYSTEM_PATH_PREFIXES: + if path.startswith(prefix): + marker = f"/{workspace_name}/" + idx = path.find(marker) + if idx != -1: + relative = path[idx + len(marker):] + return "./" + relative if relative else "." + elif path.endswith(f"/{workspace_name}"): + return "." + break # Matched system prefix but no workspace → fall through + # Convert virtual path if path == '/': return '.' @@ -275,25 +299,27 @@ class CustomSandboxBackend(LocalShellBackend): Intercepts all file operations (read, write, edit, ls, grep, glob). Auto-corrects common LLM path mistakes instead of crashing: - 1. /workspace/file.py → /file.py - 2. /Users/name/.../workspace/f → /f (strip up to workspace/) - 3. /Users/name/file.py → /file.py (keep basename) + 1. //file.py → /file.py + 2. /Users/name/...//f → /f (strip up to ws dir) + 3. /Users/name/file.py → /file.py (keep basename) """ - # Auto-strip /workspace/ prefix to prevent nesting - if key.startswith("/workspace/"): - key = key[len("/workspace"):] # "/workspace/main.py" → "/main.py" - elif key == "/workspace": + ws_name = Path(str(self.cwd)).name # e.g. "workspace", "my-project" + + # Auto-strip // prefix to prevent nesting + ws_prefix = f"/{ws_name}/" + if key.startswith(ws_prefix): + key = key[len(ws_prefix) - 1:] # "//main.py" → "/main.py" + elif key == f"/{ws_name}": key = "/" # Auto-correct system absolute paths for prefix in _SYSTEM_PATH_PREFIXES: if key.startswith(prefix): - # Try to extract path after "workspace/" or "workspace" at end - marker = "/workspace/" - idx = key.find(marker) + # Try to extract path after "/" + idx = key.find(ws_prefix) if idx != -1: - key = "/" + key[idx + len(marker):] - elif key.endswith("/workspace"): + key = "/" + key[idx + len(ws_prefix):] + elif key.endswith(f"/{ws_name}"): key = "/" else: # Fall back to basename @@ -322,9 +348,18 @@ class CustomSandboxBackend(LocalShellBackend): truncated=False, ) + # Replace literal workspace-root absolute paths with ./ + # Catches cases where the agent uses the exact real path. + ws = str(self.cwd).rstrip("/") + "/" + if ws in command: + command = command.replace(ws, "./") + # Convert virtual paths to relative paths if self.virtual_mode: - command = convert_virtual_paths_in_command(command=command) + command = convert_virtual_paths_in_command( + command=command, + workspace_name=Path(str(self.cwd)).name, + ) # Delegate to parent for subprocess execution return super().execute(command) diff --git a/tests/test_backends.py b/tests/test_backends.py index 59e92c7..059e82d 100644 --- a/tests/test_backends.py +++ b/tests/test_backends.py @@ -2,6 +2,7 @@ import re +from pathlib import Path from EvoScientist.backends import ( validate_command, @@ -87,6 +88,66 @@ class TestConvertVirtualPaths: result = convert_virtual_paths_in_command("echo hello") assert result == "echo hello" + def test_system_path_with_workspace_converted(self): + """Hallucinated system path containing workspace dir should be fixed.""" + result = convert_virtual_paths_in_command( + "mkdir -p /Users/user/project/workspace/swarm-discussion", + workspace_name="workspace", + ) + assert result == "mkdir -p ./swarm-discussion" + + def test_system_path_with_workspace_nested(self): + result = convert_virtual_paths_in_command( + "python /home/user/workspace/src/main.py", + workspace_name="workspace", + ) + assert result == "python ./src/main.py" + + def test_system_path_workspace_only(self): + result = convert_virtual_paths_in_command( + "ls /Users/user/Downloads/project/workspace", + workspace_name="workspace", + ) + assert result == "ls ." + + def test_system_path_with_shell_expansion(self): + """Paths with $(whoami) or similar should still be caught.""" + result = convert_virtual_paths_in_command( + "mkdir -p /Users/$(whoami)/workspace/notes", + workspace_name="workspace", + ) + assert result == "mkdir -p ./notes" + + def test_system_path_custom_workspace_name(self): + """Should work with any workspace directory name, not just 'workspace'.""" + result = convert_virtual_paths_in_command( + "mkdir -p /Users/user/my-project/data", + workspace_name="my-project", + ) + assert result == "mkdir -p ./data" + + def test_system_path_custom_workspace_name_only(self): + result = convert_virtual_paths_in_command( + "ls /home/user/experiment-1", + workspace_name="experiment-1", + ) + assert result == "ls ." + + def test_system_path_no_workspace_name_fallthrough(self): + """Without workspace_name, system paths get normal ./ treatment.""" + result = convert_virtual_paths_in_command( + "cat /Users/user/workspace/file.txt", + workspace_name=None, + ) + assert result == "cat ./Users/user/workspace/file.txt" + + def test_system_path_without_workspace_unchanged(self): + """System paths not referencing workspace fall through to normal ./""" + result = convert_virtual_paths_in_command( + "cat /tmp/somefile", workspace_name="workspace", + ) + assert result == "cat ./tmp/somefile" + # === CustomSandboxBackend._resolve_path === @@ -115,6 +176,22 @@ class TestResolvePath: # Falls back to basename assert str(resolved).endswith("file.py") + def test_custom_workspace_name_prefix_stripped(self, tmp_path): + """_resolve_path uses the actual dir name, not hardcoded 'workspace'.""" + ws = tmp_path / "my-project" + ws.mkdir() + backend = CustomSandboxBackend(root_dir=str(ws), virtual_mode=True) + resolved = backend._resolve_path("/my-project/main.py") + assert str(resolved).endswith("main.py") + assert "my-project/my-project" not in str(resolved) + + def test_custom_workspace_name_system_path(self, tmp_path): + ws = tmp_path / "experiment-1" + ws.mkdir() + backend = CustomSandboxBackend(root_dir=str(ws), virtual_mode=True) + resolved = backend._resolve_path("/Users/someone/experiment-1/data/out.csv") + assert str(resolved).endswith("data/out.csv") + def test_normal_virtual_path(self, tmp_workspace): backend = CustomSandboxBackend(root_dir=tmp_workspace, virtual_mode=True) resolved = backend._resolve_path("/src/main.py") @@ -145,6 +222,20 @@ class TestSandboxId: assert re.fullmatch(r"[0-9a-f]{8}", suffix) +# === execute() literal cwd sanitization === + +class TestExecuteCwdSanitization: + def test_literal_workspace_path_replaced(self, tmp_workspace): + """execute() should replace literal workspace root path with ./""" + backend = CustomSandboxBackend(root_dir=tmp_workspace, virtual_mode=True) + # Create a subdir via the sanitized path + resp = backend.execute(f"mkdir -p {tmp_workspace}/test-sanitized && echo ok") + assert resp.exit_code == 0 + # The dir should be created at workspace/test-sanitized, not nested + assert (Path(tmp_workspace) / "test-sanitized").is_dir() + assert not (Path(tmp_workspace) / tmp_workspace.lstrip("/")).exists() + + # === execute() output truncation === class TestExecuteTruncation: