feat(backends): enhance convert_virtual_paths_in_command to handle workspace paths
This commit is contained in:
+58
-23
@@ -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
|
||||
``/<workspace_name>/`` 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. /<ws_name>/file.py → /file.py
|
||||
2. /Users/name/.../<ws_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 /<ws_name>/ prefix to prevent nesting
|
||||
ws_prefix = f"/{ws_name}/"
|
||||
if key.startswith(ws_prefix):
|
||||
key = key[len(ws_prefix) - 1:] # "/<ws>/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 "<ws_name>/"
|
||||
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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user