This commit is contained in:
X-iZhang
2026-02-06 19:16:37 +00:00
parent 64b2c56679
commit 5518f4b1f9
2 changed files with 127 additions and 32 deletions
+39 -32
View File
@@ -3,11 +3,13 @@
import os
import re
import subprocess
import uuid
from pathlib import Path
from deepagents.backends import FilesystemBackend
from deepagents.backends.filesystem import WriteResult, EditResult
from deepagents.backends.protocol import (
BackendProtocol,
ExecuteResponse,
FileDownloadResponse,
FileUploadResponse,
@@ -132,7 +134,7 @@ class ReadOnlyFilesystemBackend(FilesystemBackend):
)
class MergedReadOnlyBackend:
class MergedReadOnlyBackend(BackendProtocol):
"""Read-only backend that merges two directories.
Reads from *primary* first (user skills in workspace/skills/),
@@ -205,27 +207,7 @@ class MergedReadOnlyBackend:
error="This directory is read-only. Edit operations are not permitted here."
)
# -- async variants (required by middleware) --
async def aread(self, file_path: str, offset: int = 0, limit: int = 2000) -> str:
return self.read(file_path, offset, limit)
async def als_info(self, path: str = "/") -> list:
return self.ls_info(path)
async def agrep_raw(self, pattern: str, path: str | None = None, glob: str | None = None) -> list:
return self.grep_raw(pattern, path, glob)
async def aglob_info(self, pattern: str, path: str = "/") -> list:
return self.glob_info(pattern, path)
async def awrite(self, file_path: str, content: str) -> WriteResult:
return self.write(file_path, content)
async def aedit(self, file_path: str, old_string: str, new_string: str, replace_all: bool = False) -> EditResult:
return self.edit(file_path, old_string, new_string, replace_all)
# -- download / upload (required by BackendProtocol) --
# -- download / upload --
def download_files(self, paths: list[str]) -> list[FileDownloadResponse]:
"""Download files, trying primary then secondary."""
@@ -237,18 +219,12 @@ class MergedReadOnlyBackend:
responses.append(resp)
return responses
async def adownload_files(self, paths: list[str]) -> list[FileDownloadResponse]:
return self.download_files(paths)
def upload_files(self, files: list[tuple[str, bytes]]) -> list[FileUploadResponse]:
return [
FileUploadResponse(path=path, error="permission_denied")
for path, _ in files
]
async def aupload_files(self, files: list[tuple[str, bytes]]) -> list[FileUploadResponse]:
return self.upload_files(files)
class CustomSandboxBackend(FilesystemBackend, SandboxBackendProtocol):
"""
@@ -269,6 +245,9 @@ class CustomSandboxBackend(FilesystemBackend, SandboxBackendProtocol):
working_dir: str | None = None,
timeout: int = 300,
shell: str = "/bin/bash",
max_output_bytes: int = 100_000,
env: dict[str, str] | None = None,
inherit_env: bool = True,
):
"""
Initialize custom sandbox backend.
@@ -279,17 +258,33 @@ class CustomSandboxBackend(FilesystemBackend, SandboxBackendProtocol):
working_dir: Working directory for command execution (defaults to root_dir)
timeout: Command execution timeout in seconds
shell: Shell program to use
max_output_bytes: Max output size before truncation (default 100KB)
env: Extra environment variables for subprocess
inherit_env: Whether to inherit parent process env (default True)
"""
super().__init__(root_dir=root_dir, virtual_mode=virtual_mode)
self._sandbox_id = f"evosci-{uuid.uuid4().hex[:8]}"
self.working_dir = working_dir or root_dir
self.timeout = timeout
self.shell = shell
self.virtual_mode = virtual_mode
self._max_output_bytes = max_output_bytes
# Build subprocess environment
if inherit_env:
self._env = {**os.environ, **(env or {})}
else:
self._env = dict(env) if env else {}
# Ensure working directory exists
os.makedirs(self.working_dir, exist_ok=True)
@property
def id(self) -> str:
"""Unique identifier for the sandbox backend instance."""
return self._sandbox_id
def _resolve_path(self, key: str) -> Path:
"""Resolve path with sanitization to prevent nested directories.
@@ -359,18 +354,30 @@ class CustomSandboxBackend(FilesystemBackend, SandboxBackendProtocol):
capture_output=True,
text=True,
timeout=self.timeout,
env=self._env,
)
output = ""
output_parts = []
if result.stdout:
output += result.stdout
output_parts.append(result.stdout)
if result.stderr:
output += result.stderr
stderr_lines = result.stderr.strip().split("\n")
output_parts.extend(f"[stderr] {line}" for line in stderr_lines)
output = "\n".join(output_parts) if output_parts else ""
if result.returncode != 0:
output = f"{output.rstrip()}\n\nExit code: {result.returncode}"
truncated = False
if len(output) > self._max_output_bytes:
output = output[:self._max_output_bytes]
output += f"\n\n... Output truncated at {self._max_output_bytes} bytes."
truncated = True
return ExecuteResponse(
output=output,
exit_code=result.returncode,
truncated=False,
truncated=truncated,
)
except subprocess.TimeoutExpired:
+88
View File
@@ -1,6 +1,7 @@
"""Tests for EvoScientist/backends.py — validate_command, path conversion, resolve_path."""
import re
from EvoScientist.backends import (
validate_command,
@@ -118,3 +119,90 @@ class TestResolvePath:
backend = CustomSandboxBackend(root_dir=tmp_workspace, virtual_mode=True)
resolved = backend._resolve_path("/src/main.py")
assert str(resolved).endswith("src/main.py")
# === CustomSandboxBackend.id ===
class TestSandboxId:
def test_sandbox_has_id(self, tmp_workspace):
backend = CustomSandboxBackend(root_dir=tmp_workspace, virtual_mode=True)
assert isinstance(backend.id, str)
assert backend.id.startswith("evosci-")
assert len(backend.id) == len("evosci-") + 8
def test_sandbox_id_is_stable(self, tmp_workspace):
backend = CustomSandboxBackend(root_dir=tmp_workspace, virtual_mode=True)
assert backend.id == backend.id # same instance → same id
def test_sandbox_id_unique(self, tmp_workspace):
b1 = CustomSandboxBackend(root_dir=tmp_workspace, virtual_mode=True)
b2 = CustomSandboxBackend(root_dir=tmp_workspace, virtual_mode=True)
assert b1.id != b2.id
def test_sandbox_id_hex_suffix(self, tmp_workspace):
backend = CustomSandboxBackend(root_dir=tmp_workspace, virtual_mode=True)
suffix = backend.id[len("evosci-"):]
assert re.fullmatch(r"[0-9a-f]{8}", suffix)
# === execute() output truncation ===
class TestExecuteTruncation:
def test_execute_truncates_large_output(self, tmp_workspace):
backend = CustomSandboxBackend(
root_dir=tmp_workspace, working_dir=tmp_workspace,
virtual_mode=True, max_output_bytes=100,
)
# Generate output larger than 100 bytes
resp = backend.execute("python3 -c \"print('A' * 200)\"")
assert resp.truncated is True
assert "... Output truncated at 100 bytes" in resp.output
# Output body (before truncation message) should be ≤ 100 bytes
before_marker = resp.output.split("\n\n... Output truncated")[0]
assert len(before_marker) <= 100
def test_execute_no_truncation_small_output(self, tmp_workspace):
backend = CustomSandboxBackend(
root_dir=tmp_workspace, working_dir=tmp_workspace,
virtual_mode=True, max_output_bytes=100_000,
)
resp = backend.execute("echo hello")
assert resp.truncated is False
assert "truncated" not in resp.output.lower()
# === execute() stderr attribution ===
class TestExecuteStderr:
def test_execute_stderr_attribution(self, tmp_workspace):
backend = CustomSandboxBackend(
root_dir=tmp_workspace, working_dir=tmp_workspace, virtual_mode=True,
)
resp = backend.execute("python3 -c \"import sys; sys.stderr.write('warning\\n')\"")
assert "[stderr] warning" in resp.output
def test_execute_nonzero_exit_code_in_output(self, tmp_workspace):
backend = CustomSandboxBackend(
root_dir=tmp_workspace, working_dir=tmp_workspace, virtual_mode=True,
)
resp = backend.execute("python3 -c \"raise SystemExit(42)\"")
assert resp.exit_code == 42
assert "Exit code: 42" in resp.output
def test_execute_mixed_stdout_stderr(self, tmp_workspace):
backend = CustomSandboxBackend(
root_dir=tmp_workspace, working_dir=tmp_workspace, virtual_mode=True,
)
resp = backend.execute(
"python3 -c \"import sys; print('out'); sys.stderr.write('err\\n')\""
)
assert "out" in resp.output
assert "[stderr] err" in resp.output
def test_execute_success_no_exit_code(self, tmp_workspace):
backend = CustomSandboxBackend(
root_dir=tmp_workspace, working_dir=tmp_workspace, virtual_mode=True,
)
resp = backend.execute("echo ok")
assert resp.exit_code == 0
assert "Exit code:" not in resp.output