diff --git a/libs/deepagents-cli/README.md b/libs/deepagents-cli/README.md index dd787122..c7b7e302 100644 --- a/libs/deepagents-cli/README.md +++ b/libs/deepagents-cli/README.md @@ -47,7 +47,7 @@ deepagents --agent mybot deepagents --auto-approve # Execute code in a remote sandbox -deepagents --sandbox modal # or runloop, daytona +deepagents --sandbox modal # or runloop, daytona, docker deepagents --sandbox-id dbx_123 # reuse existing sandbox ``` diff --git a/libs/deepagents-cli/deepagents_cli/agent.py b/libs/deepagents-cli/deepagents_cli/agent.py index a273ca04..73abcb80 100644 --- a/libs/deepagents-cli/deepagents_cli/agent.py +++ b/libs/deepagents-cli/deepagents_cli/agent.py @@ -96,7 +96,7 @@ def get_system_prompt(assistant_id: str, sandbox_type: str | None = None) -> str Args: assistant_id: The agent identifier for path references - sandbox_type: Type of sandbox provider ("modal", "runloop", "daytona"). + sandbox_type: Type of sandbox provider ("modal", "runloop", "daytona", "docker"). If None, agent is operating in local mode. Returns: @@ -339,7 +339,7 @@ def create_agent_with_config( tools: Additional tools to provide to agent sandbox: Optional sandbox backend for remote execution (e.g., ModalBackend). If None, uses local filesystem + shell. - sandbox_type: Type of sandbox provider ("modal", "runloop", "daytona") + sandbox_type: Type of sandbox provider ("modal", "runloop", "daytona", "docker") Returns: 2-tuple of graph and backend diff --git a/libs/deepagents-cli/deepagents_cli/integrations/docker.py b/libs/deepagents-cli/deepagents_cli/integrations/docker.py new file mode 100644 index 00000000..60768c34 --- /dev/null +++ b/libs/deepagents-cli/deepagents_cli/integrations/docker.py @@ -0,0 +1,113 @@ +"""Docker sandbox backend implementation.""" + +from __future__ import annotations + +from deepagents.backends.protocol import ( + ExecuteResponse, + FileDownloadResponse, + FileUploadResponse, +) +from deepagents.backends.sandbox import BaseSandbox + +import io +import tarfile + + +class DockerBackend(BaseSandbox): + """Docker backend implementation conforming to SandboxBackendProtocol. + + This implementation inherits all file operation methods from BaseSandbox + and only implements the execute() method using Docker SDK. + """ + + def __init__(self, sandbox: Sandbox) -> None: + """Initialize the DockerBackend with a Docker sandbox client. + + Args: + sandbox: Docker sandbox instance + """ + self._sandbox = sandbox + self._timeout: int = 30 * 60 # 30 mins + + @property + def id(self) -> str: + """Unique identifier for the sandbox backend.""" + return self._sandbox.id + + def execute( + self, + command: str, + ) -> ExecuteResponse: + """Execute a command in the sandbox and return ExecuteResponse. + + Args: + command: Full shell command string to execute. + + Returns: + ExecuteResponse with combined output, exit code, optional signal, and truncation flag. + """ + result = self._sandbox.exec_run(cmd=command, user="root", workdir="/root") + + output = result.output.decode('utf-8', errors='replace') if result.output else "" + exit_code = result.exit_code + + return ExecuteResponse( + output=output, + exit_code=exit_code, + truncated=False, + ) + + def download_files(self, paths: list[str]) -> list[FileDownloadResponse]: + """Download multiple files from the Docker sandbox. + + Leverages Docker's get_archive functionality. + + Args: + paths: List of file paths to download. + + Returns: + List of FileDownloadResponse objects, one per input path. + Response order matches input order. + """ + + # Download files using Docker's get_archive + responses = [] + try: + for path in paths: + strm, stat = self._sandbox.get_archive(path) + file_like_object = io.BytesIO(b"".join(chunk for chunk in strm)) + print("Before tar") + with tarfile.open(fileobj=file_like_object, mode='r') as tar: + print(f"{tar.getnames()}") + with tar.extractfile(stat['name']) as f: + content = f.read() + responses.append(FileDownloadResponse(path=path, content=content, error=None)) + except Exception as e: + pass + + return responses + + def upload_files(self, files: list[tuple[str, bytes]]) -> list[FileUploadResponse]: + """Upload multiple files to the Docker sandbox. + + Leverages Docker's put_archiv functionality. + + Args: + files: List of (path, content) tuples to upload. + + Returns: + List of FileUploadResponse objects, one per input file. + Response order matches input order. + """ + + for path, content in files: + pw_tarstream = io.BytesIO() + with tarfile.TarFile(fileobj=pw_tarstream, mode='w') as tar: + data_size = len(content) + data_io = io.BytesIO(content) + info = tarfile.TarInfo(path) + info.size = data_size + tar.addfile(info, data_io) + self._sandbox.put_archive(path, pw_tarstream) + + return [FileUploadResponse(path=path, error=None) for path, _ in files] diff --git a/libs/deepagents-cli/deepagents_cli/integrations/sandbox_factory.py b/libs/deepagents-cli/deepagents_cli/integrations/sandbox_factory.py index cdf62348..cf82cb02 100644 --- a/libs/deepagents-cli/deepagents_cli/integrations/sandbox_factory.py +++ b/libs/deepagents-cli/deepagents_cli/integrations/sandbox_factory.py @@ -266,10 +266,114 @@ def create_daytona_sandbox( console.print(f"[yellow]⚠ Cleanup failed: {e}[/yellow]") +@contextmanager +def create_docker_sandbox( + *, sandbox_id: str | None = None, setup_script_path: str | None = None +) -> Generator[SandboxBackendProtocol, None, None]: + """Create or connect to Docker sandbox. + + Args: + sandbox_id: Optional existing sandbox ID to reuse + setup_script_path: Optional path to setup script to run after sandbox starts + + Yields: + (DockerBackend, sandbox_id) + + Raises: + ImportError: Docker SDK not installed + Exception: Sandbox creation/connection failed + FileNotFoundError: Setup script not found + RuntimeError: Setup script failed + """ + import docker + + from deepagents_cli.integrations.docker import DockerBackend + + sandbox_exists = sandbox_id != None + console.print(f"[yellow]{"Connecting to" if sandbox_exists else "Starting"} Docker sandbox...[/yellow]") + + # Create ephemeral app (auto-cleans up on exit) + client = docker.from_env() + + image_name = "python:3.12-slim" + project_level_deepagents_dir = f"{os.getcwd()}/.deepagents" + try: + container = client.containers.get(sandbox_id) if sandbox_exists else client.containers.run( + image_name, + command="tail -f /dev/null", # Keep container running + detach=True, + environment={"HOME": os.path.expanduser('~')}, + tty=True, + mem_limit="512m", + cpu_quota=50000, # Limits CPU usage (e.g., 50% of one core) + pids_limit=100, # Limit number of processes + # Temporarily allow network and root access for setup + network_mode="bridge", + # No user restriction for install step + read_only=False, # Temporarily allow writes + tmpfs={"/tmp": "rw,size=64m,noexec,nodev,nosuid"}, # Writable /tmp + volumes={ + os.path.expanduser('~/.deepagents'): {"bind": os.path.expanduser('~/.deepagents'), 'mode': 'rw'}, + os.getcwd(): {"bind": "/workspace", 'mode': 'rw'}, + **({project_level_deepagents_dir: {"bind": project_level_deepagents_dir, 'mode': 'rw'}} if os.path.isdir(project_level_deepagents_dir) else {}), # Needed for project skills to work + }, + ) + except docker.errors.ImageNotFound as e: + print(f"Error: The specified image '{image_name}' was not found.") + print(f"Details: {e}") + exit() + except docker.errors.ContainerError as e: + # This exception is raised if the container exits with a non-zero exit code + # and detach is False. + print(f"Error: The container exited with a non-zero exit code ({e.exit_status}).") + print(f"Command run: {e.command}") + print(f"Container logs: {e.logs.decode('utf-8')}") + print(f"Details: {e}") + exit() + except docker.errors.APIError as e: + # This covers other server-related errors, like connection issues or permission problems. + print(f"Error: A Docker API error occurred.") + print(f"Details: {e}") + exit() + except docker.errors.NotFound as e: + print("Container not found or not running.") + exit() + except Exception as e: + # General exception handler for any other unexpected errors + print(f"An unexpected error occurred: {e}") + exit() + + sandbox_id = container.id + + backend = DockerBackend(container) + console.print(f"[green]✓ Docker sandbox ready: {backend.id}[/green]") + + # Run setup script if provided + if setup_script_path: + _run_sandbox_setup(backend, setup_script_path) + try: + yield backend + finally: + if not sandbox_exists: + try: + console.print(f"[dim]Terminating Docker sandbox {sandbox_id}...[/dim]") + try: + container.stop(timeout=5) + container.remove(force=True) + except docker.errors.NotFound: + print(f"Container {sandbox_id} already removed.") + except docker.errors.APIError as e: + print(f"Error during container cleanup {sandbox_id}: {e}") + console.print(f"[dim]✓ Docker sandbox {sandbox_id} terminated[/dim]") + except Exception as e: + console.print(f"[yellow]⚠ Cleanup failed: {e}[/yellow]") + + _PROVIDER_TO_WORKING_DIR = { "modal": "/workspace", "runloop": "/home/user", "daytona": "/home/daytona", + "docker": "/workspace", } @@ -278,6 +382,7 @@ def create_daytona_sandbox( "modal": create_modal_sandbox, "runloop": create_runloop_sandbox, "daytona": create_daytona_sandbox, + "docker": create_docker_sandbox, } @@ -294,7 +399,7 @@ def create_sandbox( the appropriate provider-specific context manager. Args: - provider: Sandbox provider ("modal", "runloop", "daytona") + provider: Sandbox provider ("modal", "runloop", "daytona", "docker") sandbox_id: Optional existing sandbox ID to reuse setup_script_path: Optional path to setup script to run after sandbox starts @@ -318,7 +423,7 @@ def get_available_sandbox_types() -> list[str]: """Get list of available sandbox provider types. Returns: - List of sandbox type names (e.g., ["modal", "runloop", "daytona"]) + List of sandbox type names (e.g., ["modal", "runloop", "daytona", "docker"]) """ return list(_SANDBOX_PROVIDERS.keys()) @@ -327,7 +432,7 @@ def get_default_working_dir(provider: str) -> str: """Get the default working directory for a given sandbox provider. Args: - provider: Sandbox provider name ("modal", "runloop", "daytona") + provider: Sandbox provider name ("modal", "runloop", "daytona", "docker") Returns: Default working directory path as string diff --git a/libs/deepagents-cli/deepagents_cli/main.py b/libs/deepagents-cli/deepagents_cli/main.py index b556deaa..7378005b 100644 --- a/libs/deepagents-cli/deepagents_cli/main.py +++ b/libs/deepagents-cli/deepagents_cli/main.py @@ -109,7 +109,7 @@ def parse_args(): ) parser.add_argument( "--sandbox", - choices=["none", "modal", "daytona", "runloop"], + choices=["none", "modal", "daytona", "runloop", "docker"], default="none", help="Remote sandbox for code execution (default: none - local only)", ) @@ -144,7 +144,7 @@ async def simple_cli( Args: backend: Backend for file operations (CompositeBackend) - sandbox_type: Type of sandbox being used (e.g., "modal", "runloop", "daytona"). + sandbox_type: Type of sandbox being used (e.g., "modal", "runloop", "daytona", "docker"). If None, running in local mode. sandbox_id: ID of the active sandbox setup_script_path: Path to setup script that was run (if any) @@ -329,7 +329,7 @@ async def main( Args: assistant_id: Agent identifier for memory storage session_state: Session state with auto-approve settings - sandbox_type: Type of sandbox ("none", "modal", "runloop", "daytona") + sandbox_type: Type of sandbox ("none", "modal", "runloop", "daytona", "docker") sandbox_id: Optional existing sandbox ID to reuse setup_script_path: Optional path to setup script to run in sandbox """ diff --git a/libs/deepagents-cli/deepagents_cli/ui.py b/libs/deepagents-cli/deepagents_cli/ui.py index d1738548..c5094c8e 100644 --- a/libs/deepagents-cli/deepagents_cli/ui.py +++ b/libs/deepagents-cli/deepagents_cli/ui.py @@ -556,7 +556,7 @@ def show_help() -> None: console.print(" --agent NAME Agent identifier (default: agent)") console.print(" --auto-approve Auto-approve tool usage without prompting") console.print( - " --sandbox TYPE Remote sandbox for execution (modal, runloop, daytona)" + " --sandbox TYPE Remote sandbox for execution (modal, runloop, daytona, docker)" ) console.print(" --sandbox-id ID Reuse existing sandbox (skips creation/cleanup)") console.print() diff --git a/libs/deepagents-cli/pyproject.toml b/libs/deepagents-cli/pyproject.toml index d4a54717..60998ccd 100644 --- a/libs/deepagents-cli/pyproject.toml +++ b/libs/deepagents-cli/pyproject.toml @@ -18,6 +18,7 @@ dependencies = [ "markdownify>=0.13.0", "langchain>=1.0.7", "runloop-api-client>=0.69.0", + "docker>=7.1.0", ] [project.scripts] diff --git a/libs/deepagents-cli/tests/integration_tests/test_sandbox_factory.py b/libs/deepagents-cli/tests/integration_tests/test_sandbox_factory.py index c85c1822..4ca41621 100644 --- a/libs/deepagents-cli/tests/integration_tests/test_sandbox_factory.py +++ b/libs/deepagents-cli/tests/integration_tests/test_sandbox_factory.py @@ -1,6 +1,6 @@ """Test sandbox integrations with upload/download functionality. -This module tests sandbox backends (RunLoop, Daytona, Modal) with support for +This module tests sandbox backends (RunLoop, Daytona, Modal, Docker) with support for optional sandbox reuse to reduce test execution time. Set REUSE_SANDBOX=1 environment variable to reuse sandboxes across tests within @@ -320,3 +320,13 @@ def sandbox(self) -> Iterator[BaseSandbox]: """Provide a Modal sandbox instance.""" with create_sandbox("modal") as sandbox: yield sandbox + + +# class TestDockerIntegration(BaseSandboxIntegrationTest): +# """Test Docker backend integration.""" + +# @pytest.fixture(scope="class") +# def sandbox(self) -> Iterator[BaseSandbox]: +# """Provide a Docker sandbox instance.""" +# with create_sandbox("docker") as sandbox: +# yield sandbox diff --git a/libs/deepagents-cli/uv.lock b/libs/deepagents-cli/uv.lock index 3d99df6b..b7ec6f17 100644 --- a/libs/deepagents-cli/uv.lock +++ b/libs/deepagents-cli/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.11, <4.0" [[package]] @@ -700,6 +700,7 @@ source = { editable = "." } dependencies = [ { name = "daytona" }, { name = "deepagents" }, + { name = "docker" }, { name = "langchain" }, { name = "langchain-openai" }, { name = "markdownify" }, @@ -742,6 +743,7 @@ test = [ requires-dist = [ { name = "daytona", specifier = ">=0.113.0" }, { name = "deepagents", directory = "../deepagents" }, + { name = "docker", specifier = ">=7.1.0" }, { name = "langchain", specifier = ">=1.0.7" }, { name = "langchain-openai", specifier = ">=0.1.0" }, { name = "markdownify", specifier = ">=0.13.0" }, @@ -801,6 +803,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, ] +[[package]] +name = "docker" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "requests" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/91/9b/4a2ea29aeba62471211598dac5d96825bb49348fa07e906ea930394a83ce/docker-7.1.0.tar.gz", hash = "sha256:ad8c70e6e3f8926cb8a92619b832b4ea5299e2831c14284663184e200546fa6c", size = 117834, upload-time = "2024-05-23T11:13:57.216Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/26/57c6fb270950d476074c087527a558ccb6f4436657314bfb6cdf484114c4/docker-7.1.0-py3-none-any.whl", hash = "sha256:c96b93b7f0a746f9e77d325bcfb87422a3d8bd4f03136ae8a85b37f1898d5fc0", size = 147774, upload-time = "2024-05-23T11:13:55.01Z" }, +] + [[package]] name = "docstring-parser" version = "0.17.0" @@ -2221,6 +2237,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, ] +[[package]] +name = "pywin32" +version = "311" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/af/449a6a91e5d6db51420875c54f6aff7c97a86a3b13a0b4f1a5c13b988de3/pywin32-311-cp311-cp311-win32.whl", hash = "sha256:184eb5e436dea364dcd3d2316d577d625c0351bf237c4e9a5fabbcfa5a58b151", size = 8697031, upload-time = "2025-07-14T20:13:13.266Z" }, + { url = "https://files.pythonhosted.org/packages/51/8f/9bb81dd5bb77d22243d33c8397f09377056d5c687aa6d4042bea7fbf8364/pywin32-311-cp311-cp311-win_amd64.whl", hash = "sha256:3ce80b34b22b17ccbd937a6e78e7225d80c52f5ab9940fe0506a1a16f3dab503", size = 9508308, upload-time = "2025-07-14T20:13:15.147Z" }, + { url = "https://files.pythonhosted.org/packages/44/7b/9c2ab54f74a138c491aba1b1cd0795ba61f144c711daea84a88b63dc0f6c/pywin32-311-cp311-cp311-win_arm64.whl", hash = "sha256:a733f1388e1a842abb67ffa8e7aad0e70ac519e09b0f6a784e65a136ec7cefd2", size = 8703930, upload-time = "2025-07-14T20:13:16.945Z" }, + { url = "https://files.pythonhosted.org/packages/e7/ab/01ea1943d4eba0f850c3c61e78e8dd59757ff815ff3ccd0a84de5f541f42/pywin32-311-cp312-cp312-win32.whl", hash = "sha256:750ec6e621af2b948540032557b10a2d43b0cee2ae9758c54154d711cc852d31", size = 8706543, upload-time = "2025-07-14T20:13:20.765Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a8/a0e8d07d4d051ec7502cd58b291ec98dcc0c3fff027caad0470b72cfcc2f/pywin32-311-cp312-cp312-win_amd64.whl", hash = "sha256:b8c095edad5c211ff31c05223658e71bf7116daa0ecf3ad85f3201ea3190d067", size = 9495040, upload-time = "2025-07-14T20:13:22.543Z" }, + { url = "https://files.pythonhosted.org/packages/ba/3a/2ae996277b4b50f17d61f0603efd8253cb2d79cc7ae159468007b586396d/pywin32-311-cp312-cp312-win_arm64.whl", hash = "sha256:e286f46a9a39c4a18b319c28f59b61de793654af2f395c102b4f819e584b5852", size = 8710102, upload-time = "2025-07-14T20:13:24.682Z" }, + { url = "https://files.pythonhosted.org/packages/a5/be/3fd5de0979fcb3994bfee0d65ed8ca9506a8a1260651b86174f6a86f52b3/pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d", size = 8705700, upload-time = "2025-07-14T20:13:26.471Z" }, + { url = "https://files.pythonhosted.org/packages/e3/28/e0a1909523c6890208295a29e05c2adb2126364e289826c0a8bc7297bd5c/pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d", size = 9494700, upload-time = "2025-07-14T20:13:28.243Z" }, + { url = "https://files.pythonhosted.org/packages/04/bf/90339ac0f55726dce7d794e6d79a18a91265bdf3aa70b6b9ca52f35e022a/pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a", size = 8709318, upload-time = "2025-07-14T20:13:30.348Z" }, + { url = "https://files.pythonhosted.org/packages/c9/31/097f2e132c4f16d99a22bfb777e0fd88bd8e1c634304e102f313af69ace5/pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee", size = 8840714, upload-time = "2025-07-14T20:13:32.449Z" }, + { url = "https://files.pythonhosted.org/packages/90/4b/07c77d8ba0e01349358082713400435347df8426208171ce297da32c313d/pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87", size = 9656800, upload-time = "2025-07-14T20:13:34.312Z" }, + { url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" }, +] + [[package]] name = "pywin32-ctypes" version = "0.2.3"