diff --git a/EvoScientist/backends.py b/EvoScientist/backends.py index 69acef4..32cba9b 100644 --- a/EvoScientist/backends.py +++ b/EvoScientist/backends.py @@ -2,18 +2,16 @@ import os import re -import subprocess import uuid from pathlib import Path -from deepagents.backends import FilesystemBackend +from deepagents.backends import FilesystemBackend, LocalShellBackend from deepagents.backends.filesystem import WriteResult, EditResult from deepagents.backends.protocol import ( BackendProtocol, ExecuteResponse, FileDownloadResponse, FileUploadResponse, - SandboxBackendProtocol, ) # System path prefixes that should never appear in virtual paths. @@ -226,25 +224,24 @@ class MergedReadOnlyBackend(BackendProtocol): ] -class CustomSandboxBackend(FilesystemBackend, SandboxBackendProtocol): +class CustomSandboxBackend(LocalShellBackend): """ - Custom sandbox backend - inherits FilesystemBackend and implements execute method. + Custom sandbox backend - inherits LocalShellBackend with added safety. Features: - Inherits all file operations (ls, read, write, edit, grep, glob) - - Adds shell command execution capability - - Command validation prevents directory traversal and dangerous operations - - Runs commands in specified working directory + - Inherits shell command execution with output truncation and timeout + - Adds command validation to prevent directory traversal and dangerous operations + - Adds path sanitization to auto-correct common LLM path mistakes - Compatible with LangGraph checkpointer (no thread locks) """ def __init__( self, root_dir: str = ".", + *, virtual_mode: bool = True, - 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, @@ -255,35 +252,23 @@ class CustomSandboxBackend(FilesystemBackend, SandboxBackendProtocol): Args: root_dir: File system root directory virtual_mode: Whether to enable virtual path mode - 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) - + super().__init__( + root_dir=root_dir, + virtual_mode=virtual_mode, + timeout=timeout, + max_output_bytes=max_output_bytes, + env=env, + inherit_env=inherit_env, + ) + # Override parent's "local-" prefix with our own 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 + os.makedirs(str(self.cwd), exist_ok=True) def _resolve_path(self, key: str) -> Path: """Resolve path with sanitization to prevent nested directories. @@ -326,74 +311,20 @@ class CustomSandboxBackend(FilesystemBackend, SandboxBackendProtocol): - Access to paths outside workspace - Dangerous system commands - Args: - command: Command string to execute - - Returns: - ExecuteResponse containing output, exit_code, and truncated flag + Then delegates to LocalShellBackend.execute() for actual execution. """ - try: - # Validate command safety - error = validate_command(command) - if error: - return ExecuteResponse( - output=error, - exit_code=1, - truncated=False, - ) - - # Convert virtual paths to relative paths - if self.virtual_mode: - command = convert_virtual_paths_in_command(command=command) - - result = subprocess.run( - command, - shell=True, - executable=self.shell, - cwd=self.working_dir, - capture_output=True, - text=True, - timeout=self.timeout, - env=self._env, - ) - - output_parts = [] - if result.stdout: - output_parts.append(result.stdout) - if 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 - + # Validate command safety + error = validate_command(command) + if error: return ExecuteResponse( - output=output, - exit_code=result.returncode, - truncated=truncated, - ) - - except subprocess.TimeoutExpired: - return ExecuteResponse( - output=f"Command timed out after {self.timeout} seconds", - exit_code=-1, - truncated=False, - ) - except Exception as e: - return ExecuteResponse( - output=f"Error executing command: {str(e)}", - exit_code=-1, + output=error, + exit_code=1, truncated=False, ) - async def aexecute(self, command: str) -> ExecuteResponse: - """Async version of execute (runs sync version in thread).""" - import asyncio - return await asyncio.to_thread(self.execute, command) + # Convert virtual paths to relative paths + if self.virtual_mode: + command = convert_virtual_paths_in_command(command=command) + + # Delegate to parent for subprocess execution + return super().execute(command) diff --git a/EvoScientist/utils.py b/EvoScientist/utils.py index 55319b4..7222300 100644 --- a/EvoScientist/utils.py +++ b/EvoScientist/utils.py @@ -161,6 +161,9 @@ def load_subagents( if "model" in spec: subagent["model"] = spec["model"] + if "skills" in spec: + subagent["skills"] = spec["skills"] + if "tools" in spec: subagent["tools"] = [tool_registry[t] for t in spec["tools"]] diff --git a/pyproject.toml b/pyproject.toml index e61f71f..c701888 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,9 +16,9 @@ classifiers = [ "Programming Language :: Python :: 3", ] dependencies = [ - "deepagents>=0.3.6", - "langchain>=1.2", - "langchain-anthropic>=1.3", + "deepagents>=0.4.0", + "langchain>=1.2.7", + "langchain-anthropic>=1.3.1", "langchain-openai>=0.3", "langchain-nvidia-ai-endpoints>=0.3", "langchain-google-genai>=4.2", diff --git a/tests/test_backends.py b/tests/test_backends.py index 66b7341..59e92c7 100644 --- a/tests/test_backends.py +++ b/tests/test_backends.py @@ -150,7 +150,7 @@ class TestSandboxId: class TestExecuteTruncation: def test_execute_truncates_large_output(self, tmp_workspace): backend = CustomSandboxBackend( - root_dir=tmp_workspace, working_dir=tmp_workspace, + root_dir=tmp_workspace, virtual_mode=True, max_output_bytes=100, ) # Generate output larger than 100 bytes @@ -163,7 +163,7 @@ class TestExecuteTruncation: def test_execute_no_truncation_small_output(self, tmp_workspace): backend = CustomSandboxBackend( - root_dir=tmp_workspace, working_dir=tmp_workspace, + root_dir=tmp_workspace, virtual_mode=True, max_output_bytes=100_000, ) resp = backend.execute("echo hello") @@ -176,14 +176,14 @@ class TestExecuteTruncation: class TestExecuteStderr: def test_execute_stderr_attribution(self, tmp_workspace): backend = CustomSandboxBackend( - root_dir=tmp_workspace, working_dir=tmp_workspace, virtual_mode=True, + root_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, + root_dir=tmp_workspace, virtual_mode=True, ) resp = backend.execute("python3 -c \"raise SystemExit(42)\"") assert resp.exit_code == 42 @@ -191,7 +191,7 @@ class TestExecuteStderr: def test_execute_mixed_stdout_stderr(self, tmp_workspace): backend = CustomSandboxBackend( - root_dir=tmp_workspace, working_dir=tmp_workspace, virtual_mode=True, + root_dir=tmp_workspace, virtual_mode=True, ) resp = backend.execute( "python3 -c \"import sys; print('out'); sys.stderr.write('err\\n')\"" @@ -201,7 +201,7 @@ class TestExecuteStderr: def test_execute_success_no_exit_code(self, tmp_workspace): backend = CustomSandboxBackend( - root_dir=tmp_workspace, working_dir=tmp_workspace, virtual_mode=True, + root_dir=tmp_workspace, virtual_mode=True, ) resp = backend.execute("echo ok") assert resp.exit_code == 0