Skip to content

Commit 1030fff

Browse files
committed
Add docker sandbox integration
1 parent 676d455 commit 1030fff

File tree

9 files changed

+275
-12
lines changed

9 files changed

+275
-12
lines changed

libs/deepagents-cli/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ deepagents --agent mybot
4747
deepagents --auto-approve
4848

4949
# Execute code in a remote sandbox
50-
deepagents --sandbox modal # or runloop, daytona
50+
deepagents --sandbox modal # or runloop, daytona, docker
5151
deepagents --sandbox-id dbx_123 # reuse existing sandbox
5252
```
5353

libs/deepagents-cli/deepagents_cli/agent.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ def get_system_prompt(assistant_id: str, sandbox_type: str | None = None) -> str
9696
9797
Args:
9898
assistant_id: The agent identifier for path references
99-
sandbox_type: Type of sandbox provider ("modal", "runloop", "daytona").
99+
sandbox_type: Type of sandbox provider ("modal", "runloop", "daytona", "docker").
100100
If None, agent is operating in local mode.
101101
102102
Returns:
@@ -339,7 +339,7 @@ def create_agent_with_config(
339339
tools: Additional tools to provide to agent
340340
sandbox: Optional sandbox backend for remote execution (e.g., ModalBackend).
341341
If None, uses local filesystem + shell.
342-
sandbox_type: Type of sandbox provider ("modal", "runloop", "daytona")
342+
sandbox_type: Type of sandbox provider ("modal", "runloop", "daytona", "docker")
343343
344344
Returns:
345345
2-tuple of graph and backend
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
"""Docker sandbox backend implementation."""
2+
3+
from __future__ import annotations
4+
5+
from deepagents.backends.protocol import (
6+
ExecuteResponse,
7+
FileDownloadResponse,
8+
FileUploadResponse,
9+
)
10+
from deepagents.backends.sandbox import BaseSandbox
11+
12+
import io
13+
import tarfile
14+
15+
16+
class DockerBackend(BaseSandbox):
17+
"""Docker backend implementation conforming to SandboxBackendProtocol.
18+
19+
This implementation inherits all file operation methods from BaseSandbox
20+
and only implements the execute() method using Docker SDK.
21+
"""
22+
23+
def __init__(self, sandbox: Sandbox) -> None:
24+
"""Initialize the DockerBackend with a Docker sandbox client.
25+
26+
Args:
27+
sandbox: Docker sandbox instance
28+
"""
29+
self._sandbox = sandbox
30+
self._timeout: int = 30 * 60 # 30 mins
31+
32+
@property
33+
def id(self) -> str:
34+
"""Unique identifier for the sandbox backend."""
35+
return self._sandbox.id
36+
37+
def execute(
38+
self,
39+
command: str,
40+
) -> ExecuteResponse:
41+
"""Execute a command in the sandbox and return ExecuteResponse.
42+
43+
Args:
44+
command: Full shell command string to execute.
45+
46+
Returns:
47+
ExecuteResponse with combined output, exit code, optional signal, and truncation flag.
48+
"""
49+
result = self._sandbox.exec_run(cmd=command, user="root", workdir="/root")
50+
51+
output = result.output.decode('utf-8', errors='replace') if result.output else ""
52+
exit_code = result.exit_code
53+
54+
return ExecuteResponse(
55+
output=output,
56+
exit_code=exit_code,
57+
truncated=False,
58+
)
59+
60+
def download_files(self, paths: list[str]) -> list[FileDownloadResponse]:
61+
"""Download multiple files from the Docker sandbox.
62+
63+
Leverages Docker's get_archive functionality.
64+
65+
Args:
66+
paths: List of file paths to download.
67+
68+
Returns:
69+
List of FileDownloadResponse objects, one per input path.
70+
Response order matches input order.
71+
"""
72+
73+
# Download files using Docker's get_archive
74+
responses = []
75+
try:
76+
for path in paths:
77+
strm, stat = self._sandbox.get_archive(path)
78+
file_like_object = io.BytesIO(b"".join(chunk for chunk in strm))
79+
print("Before tar")
80+
with tarfile.open(fileobj=file_like_object, mode='r') as tar:
81+
print(f"{tar.getnames()}")
82+
with tar.extractfile(stat['name']) as f:
83+
content = f.read()
84+
responses.append(FileDownloadResponse(path=path, content=content, error=None))
85+
except Exception as e:
86+
pass
87+
88+
return responses
89+
90+
def upload_files(self, files: list[tuple[str, bytes]]) -> list[FileUploadResponse]:
91+
"""Upload multiple files to the Docker sandbox.
92+
93+
Leverages Docker's put_archiv functionality.
94+
95+
Args:
96+
files: List of (path, content) tuples to upload.
97+
98+
Returns:
99+
List of FileUploadResponse objects, one per input file.
100+
Response order matches input order.
101+
"""
102+
103+
for path, content in files:
104+
pw_tarstream = io.BytesIO()
105+
with tarfile.TarFile(fileobj=pw_tarstream, mode='w') as tar:
106+
data_size = len(content)
107+
data_io = io.BytesIO(content)
108+
info = tarfile.TarInfo(path)
109+
info.size = data_size
110+
tar.addfile(info, data_io)
111+
self._sandbox.put_archive(path, pw_tarstream)
112+
113+
return [FileUploadResponse(path=path, error=None) for path, _ in files]

libs/deepagents-cli/deepagents_cli/integrations/sandbox_factory.py

Lines changed: 107 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -266,10 +266,113 @@ def create_daytona_sandbox(
266266
console.print(f"[yellow]⚠ Cleanup failed: {e}[/yellow]")
267267

268268

269+
@contextmanager
270+
def create_docker_sandbox(
271+
*, sandbox_id: str | None = None, setup_script_path: str | None = None
272+
) -> Generator[SandboxBackendProtocol, None, None]:
273+
"""Create or connect to Docker sandbox.
274+
275+
Args:
276+
sandbox_id: Optional existing sandbox ID to reuse
277+
setup_script_path: Optional path to setup script to run after sandbox starts
278+
279+
Yields:
280+
(DockerBackend, sandbox_id)
281+
282+
Raises:
283+
ImportError: Docker SDK not installed
284+
Exception: Sandbox creation/connection failed
285+
FileNotFoundError: Setup script not found
286+
RuntimeError: Setup script failed
287+
"""
288+
import docker
289+
290+
from deepagents_cli.integrations.docker import DockerBackend
291+
292+
sandbox_exists = sandbox_id != None
293+
console.print(f"[yellow]{"Connecting to" if sandbox_exists else "Starting"} Docker sandbox...[/yellow]")
294+
295+
# Create ephemeral app (auto-cleans up on exit)
296+
client = docker.from_env()
297+
298+
image_name = "python:3.12-slim"
299+
try:
300+
container = client.containers.get(sandbox_id) if sandbox_exists else client.containers.run(
301+
image_name,
302+
command="tail -f /dev/null", # Keep container running
303+
detach=True,
304+
environment={"HOME": os.path.expanduser('~')},
305+
tty=True,
306+
mem_limit="512m",
307+
cpu_quota=50000, # Limits CPU usage (e.g., 50% of one core)
308+
pids_limit=100, # Limit number of processes
309+
# Temporarily allow network and root access for setup
310+
network_mode="bridge",
311+
# No user restriction for install step
312+
read_only=False, # Temporarily allow writes
313+
tmpfs={"/tmp": "rw,size=64m,noexec,nodev,nosuid"}, # Writable /tmp
314+
volumes={
315+
os.path.expanduser('~/.deepagents'): {"bind": os.path.expanduser('~/.deepagents'), 'mode': 'rw'},
316+
os.getcwd(): {"bind": "/workspace", 'mode': 'rw'},
317+
os.getcwd() + "/.deepagents": {"bind": os.getcwd() + "/.deepagents", 'mode': 'rw'}, # Needed for project skills to work
318+
},
319+
)
320+
except docker.errors.ImageNotFound as e:
321+
print(f"Error: The specified image '{image_name}' was not found.")
322+
print(f"Details: {e}")
323+
exit()
324+
except docker.errors.ContainerError as e:
325+
# This exception is raised if the container exits with a non-zero exit code
326+
# and detach is False.
327+
print(f"Error: The container exited with a non-zero exit code ({e.exit_status}).")
328+
print(f"Command run: {e.command}")
329+
print(f"Container logs: {e.logs.decode('utf-8')}")
330+
print(f"Details: {e}")
331+
exit()
332+
except docker.errors.APIError as e:
333+
# This covers other server-related errors, like connection issues or permission problems.
334+
print(f"Error: A Docker API error occurred.")
335+
print(f"Details: {e}")
336+
exit()
337+
except docker.errors.NotFound as e:
338+
print("Container not found or not running.")
339+
exit()
340+
except Exception as e:
341+
# General exception handler for any other unexpected errors
342+
print(f"An unexpected error occurred: {e}")
343+
exit()
344+
345+
sandbox_id = container.id
346+
347+
backend = DockerBackend(container)
348+
console.print(f"[green]✓ Docker sandbox ready: {backend.id}[/green]")
349+
350+
# Run setup script if provided
351+
if setup_script_path:
352+
_run_sandbox_setup(backend, setup_script_path)
353+
try:
354+
yield backend
355+
finally:
356+
if not sandbox_exists:
357+
try:
358+
console.print(f"[dim]Terminating Docker sandbox {sandbox_id}...[/dim]")
359+
try:
360+
container.stop(timeout=5)
361+
container.remove(force=True)
362+
except docker.errors.NotFound:
363+
print(f"Container {sandbox_id} already removed.")
364+
except docker.errors.APIError as e:
365+
print(f"Error during container cleanup {sandbox_id}: {e}")
366+
console.print(f"[dim]✓ Docker sandbox {sandbox_id} terminated[/dim]")
367+
except Exception as e:
368+
console.print(f"[yellow]⚠ Cleanup failed: {e}[/yellow]")
369+
370+
269371
_PROVIDER_TO_WORKING_DIR = {
270372
"modal": "/workspace",
271373
"runloop": "/home/user",
272374
"daytona": "/home/daytona",
375+
"docker": "/workspace",
273376
}
274377

275378

@@ -278,6 +381,7 @@ def create_daytona_sandbox(
278381
"modal": create_modal_sandbox,
279382
"runloop": create_runloop_sandbox,
280383
"daytona": create_daytona_sandbox,
384+
"docker": create_docker_sandbox,
281385
}
282386

283387

@@ -294,7 +398,7 @@ def create_sandbox(
294398
the appropriate provider-specific context manager.
295399
296400
Args:
297-
provider: Sandbox provider ("modal", "runloop", "daytona")
401+
provider: Sandbox provider ("modal", "runloop", "daytona", "docker")
298402
sandbox_id: Optional existing sandbox ID to reuse
299403
setup_script_path: Optional path to setup script to run after sandbox starts
300404
@@ -318,7 +422,7 @@ def get_available_sandbox_types() -> list[str]:
318422
"""Get list of available sandbox provider types.
319423
320424
Returns:
321-
List of sandbox type names (e.g., ["modal", "runloop", "daytona"])
425+
List of sandbox type names (e.g., ["modal", "runloop", "daytona", "docker"])
322426
"""
323427
return list(_SANDBOX_PROVIDERS.keys())
324428

@@ -327,7 +431,7 @@ def get_default_working_dir(provider: str) -> str:
327431
"""Get the default working directory for a given sandbox provider.
328432
329433
Args:
330-
provider: Sandbox provider name ("modal", "runloop", "daytona")
434+
provider: Sandbox provider name ("modal", "runloop", "daytona", "docker")
331435
332436
Returns:
333437
Default working directory path as string

libs/deepagents-cli/deepagents_cli/main.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ def parse_args():
109109
)
110110
parser.add_argument(
111111
"--sandbox",
112-
choices=["none", "modal", "daytona", "runloop"],
112+
choices=["none", "modal", "daytona", "runloop", "docker"],
113113
default="none",
114114
help="Remote sandbox for code execution (default: none - local only)",
115115
)
@@ -144,7 +144,7 @@ async def simple_cli(
144144
145145
Args:
146146
backend: Backend for file operations (CompositeBackend)
147-
sandbox_type: Type of sandbox being used (e.g., "modal", "runloop", "daytona").
147+
sandbox_type: Type of sandbox being used (e.g., "modal", "runloop", "daytona", "docker").
148148
If None, running in local mode.
149149
sandbox_id: ID of the active sandbox
150150
setup_script_path: Path to setup script that was run (if any)
@@ -329,7 +329,7 @@ async def main(
329329
Args:
330330
assistant_id: Agent identifier for memory storage
331331
session_state: Session state with auto-approve settings
332-
sandbox_type: Type of sandbox ("none", "modal", "runloop", "daytona")
332+
sandbox_type: Type of sandbox ("none", "modal", "runloop", "daytona", "docker")
333333
sandbox_id: Optional existing sandbox ID to reuse
334334
setup_script_path: Optional path to setup script to run in sandbox
335335
"""

libs/deepagents-cli/deepagents_cli/ui.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -556,7 +556,7 @@ def show_help() -> None:
556556
console.print(" --agent NAME Agent identifier (default: agent)")
557557
console.print(" --auto-approve Auto-approve tool usage without prompting")
558558
console.print(
559-
" --sandbox TYPE Remote sandbox for execution (modal, runloop, daytona)"
559+
" --sandbox TYPE Remote sandbox for execution (modal, runloop, daytona, docker)"
560560
)
561561
console.print(" --sandbox-id ID Reuse existing sandbox (skips creation/cleanup)")
562562
console.print()

libs/deepagents-cli/pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ dependencies = [
1818
"markdownify>=0.13.0",
1919
"langchain>=1.0.7",
2020
"runloop-api-client>=0.69.0",
21+
"docker>=7.1.0",
2122
]
2223

2324
[project.scripts]

libs/deepagents-cli/tests/integration_tests/test_sandbox_factory.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""Test sandbox integrations with upload/download functionality.
22
3-
This module tests sandbox backends (RunLoop, Daytona, Modal) with support for
3+
This module tests sandbox backends (RunLoop, Daytona, Modal, Docker) with support for
44
optional sandbox reuse to reduce test execution time.
55
66
Set REUSE_SANDBOX=1 environment variable to reuse sandboxes across tests within
@@ -320,3 +320,13 @@ def sandbox(self) -> Iterator[BaseSandbox]:
320320
"""Provide a Modal sandbox instance."""
321321
with create_sandbox("modal") as sandbox:
322322
yield sandbox
323+
324+
325+
# class TestDockerIntegration(BaseSandboxIntegrationTest):
326+
# """Test Docker backend integration."""
327+
328+
# @pytest.fixture(scope="class")
329+
# def sandbox(self) -> Iterator[BaseSandbox]:
330+
# """Provide a Docker sandbox instance."""
331+
# with create_sandbox("docker") as sandbox:
332+
# yield sandbox

0 commit comments

Comments
 (0)