Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
2a1348a
feat: add sandbox imlementation
maksymbuleshnyi Feb 4, 2026
c0445b4
fix: revert changes
maksymbuleshnyi Feb 4, 2026
54f96b8
feat: add sandbox abstraction
maksymbuleshnyi Feb 4, 2026
8873d02
Merge branch 'main' into feat/sandbox
maksymbuleshnyi Feb 4, 2026
fd37b25
feat: add e2b sandbox
maksymbuleshnyi Feb 5, 2026
ba5f620
fix: merge branch
maksymbuleshnyi Feb 5, 2026
43c3d13
Merge branch 'main' into feat/sandbox
maksymbuleshnyi Feb 5, 2026
16c0c59
fix: comments
maksymbuleshnyi Feb 5, 2026
532ddb8
fix: merge branch
maksymbuleshnyi Feb 5, 2026
ea72e94
fix: comments
maksymbuleshnyi Feb 5, 2026
34ea5b8
fix: renamed folder and shell method
maksymbuleshnyi Feb 5, 2026
51d6078
feat: add yaml example
maksymbuleshnyi Feb 6, 2026
54040fd
fix: imports
maksymbuleshnyi Feb 6, 2026
bcecaf2
fix: update gitignore
maksymbuleshnyi Feb 6, 2026
0c9bbd0
fix: remove key
maksymbuleshnyi Feb 6, 2026
f0476b1
feat: add sandbox reconnection and files upload
maksymbuleshnyi Feb 9, 2026
1bfa311
fix: environment variable and type hings
maksymbuleshnyi Feb 9, 2026
23550d3
fix: files upload
maksymbuleshnyi Feb 10, 2026
f22599f
feat: add tests
maksymbuleshnyi Feb 10, 2026
4683d97
feat: add tracing example
maksymbuleshnyi Feb 10, 2026
b384694
fix: test
maksymbuleshnyi Feb 10, 2026
61f78d7
fix: serialization
maksymbuleshnyi Feb 10, 2026
bd20567
fix: tools static list
maksymbuleshnyi Feb 10, 2026
6da66e3
fix: merge branch 'main'
maksymbuleshnyi Feb 10, 2026
f6a6d84
fix: serialization
maksymbuleshnyi Feb 10, 2026
427f8ea
fix: remove redundant parameter
maksymbuleshnyi Feb 10, 2026
1fd8c8c
fix: tests
maksymbuleshnyi Feb 10, 2026
3a2ef19
feat: add template support
maksymbuleshnyi Feb 10, 2026
0e69b7c
fix: docstring
maksymbuleshnyi Feb 10, 2026
a060448
feat: add e2b integration test
maksymbuleshnyi Feb 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@ cython_debug/
.DS_Store
.local
sandbox
!dynamiq/sandbox/

# Chainlit
.chainlit
Expand Down
2 changes: 1 addition & 1 deletion dynamiq/nodes/agents/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@
from dynamiq.nodes.agents.utils import SummarizationConfig, ToolCacheEntry, XMLParser
from dynamiq.nodes.node import Node, NodeDependency
from dynamiq.nodes.tools.context_manager import ContextManagerTool
from dynamiq.nodes.tools.todo_tools import TodoItem, TodoWriteTool
from dynamiq.nodes.tools.parallel_tool_calls import PARALLEL_TOOL_NAME, ParallelToolCallsInputSchema
from dynamiq.nodes.tools.todo_tools import TodoItem, TodoWriteTool
from dynamiq.nodes.types import Behavior, InferenceMode
from dynamiq.prompts import Message, MessageRole, VisionMessage, VisionMessageTextContent
from dynamiq.runnables import RunnableConfig
Expand Down
60 changes: 48 additions & 12 deletions dynamiq/nodes/agents/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
from dynamiq.nodes.tools.python_code_executor import PythonCodeExecutor
from dynamiq.prompts import Message, MessageRole, Prompt, VisionMessage, VisionMessageTextContent
from dynamiq.runnables import RunnableConfig, RunnableResult, RunnableStatus
from dynamiq.sandbox.base import SandboxConfig
from dynamiq.storages.file.base import FileStore, FileStoreConfig
from dynamiq.storages.file.in_memory import InMemoryFileStore
from dynamiq.utils.logger import logger
Expand Down Expand Up @@ -212,6 +213,7 @@ class Agent(Node):
default_factory=lambda: FileStoreConfig(enabled=False, backend=InMemoryFileStore()),
description="Configuration for file storage used by the agent.",
)
sandbox: SandboxConfig = Field(default=None, description="Configuration for sandbox used by the agent.")
file_attachment_preview_bytes: int = Field(
default=512,
description="Maximum number of bytes/characters from each uploaded file to surface as an inline preview.",
Expand Down Expand Up @@ -295,14 +297,26 @@ def __init__(self, **kwargs):

self.tools = expanded_tools

if self.file_store_backend:
if self.sandbox:
# Add sandbox tools when sandbox is enabled

tools = self.sandbox.backend.get_tools(
llm=self.llm,
)
self.tools.extend(tools)

elif self.file_store_backend:
# Add file tools when file store is enabled
self.tools.extend(
[
FileReadTool(file_store=self.file_store_backend, llm=self.llm),
FileSearchTool(file_store=self.file_store_backend),
FileListTool(file_store=self.file_store_backend),
]
)
if self.file_store.agent_file_write_enabled:
self.tools.append(FileWriteTool(file_store=self.file_store_backend))

self.tools.append(FileReadTool(file_store=self.file_store_backend, llm=self.llm))
self.tools.append(FileSearchTool(file_store=self.file_store_backend))
self.tools.append(FileListTool(file_store=self.file_store_backend))

if self.parallel_tool_calls_enabled:
# Filter out any user tools with the reserved parallel tool name
self.tools = [t for t in self.tools if t.name != PARALLEL_TOOL_NAME]
Expand Down Expand Up @@ -444,6 +458,7 @@ def execute(

logger.info(f"Agent {self.name} - {self.id}: started with input {log_data}")
self.reset_run_state()

config = ensure_config(config)
self.run_on_node_execute_run(config.callbacks, **kwargs)

Expand Down Expand Up @@ -490,9 +505,14 @@ def execute(
if files:
if not self.file_store_backend:
self.file_store = FileStoreConfig(enabled=True, backend=InMemoryFileStore())
self.tools.append(FileReadTool(file_store=self.file_store.backend, llm=self.llm))
self.tools.append(FileSearchTool(file_store=self.file_store.backend))
self.tools.append(FileListTool(file_store=self.file_store.backend))
# Add file tools
self.tools.extend(
[
FileReadTool(file_store=self.file_store_backend, llm=self.llm),
FileSearchTool(file_store=self.file_store_backend),
FileListTool(file_store=self.file_store_backend),
]
)

new_tool_description = self.tool_description
self.system_prompt_manager.set_initial_variable("tool_description", new_tool_description)
Expand Down Expand Up @@ -546,7 +566,7 @@ def execute(
if filtered_files:
execution_result["files"] = filtered_files
logger.info(
f"Agent {self.name} - {self.id}: returning {len(filtered_files)} generated file(s) in FileStore"
f"Agent {self.name} - {self.id}: returning {len(filtered_files)} generated file(s) in file store"
)

logger.info(f"Node {self.name} - {self.id}: finished with RESULT:\n{str(result)[:200]}...")
Expand Down Expand Up @@ -962,7 +982,7 @@ def _run_tool(
return tool_result_content_processed, output_files

def _ensure_named_files(self, files: list[io.BytesIO | bytes]) -> list[io.BytesIO | bytes]:
"""Ensure all uploaded files have name and description attributes and store them in file_store if available."""
"""Ensure all uploaded files have name and description attributes and store them in file store if available."""
named = []
for i, f in enumerate(files):
if isinstance(f, bytes):
Expand All @@ -980,7 +1000,7 @@ def _ensure_named_files(self, files: list[io.BytesIO | bytes]) -> list[io.BytesI
overwrite=True,
)
except Exception as e:
logger.warning(f"Failed to store file {bio.name} in file_store: {e}")
logger.warning(f"Failed to store file {bio.name} in file store: {e}")

named.append(bio)
elif isinstance(f, io.BytesIO):
Expand All @@ -1002,7 +1022,7 @@ def _ensure_named_files(self, files: list[io.BytesIO | bytes]) -> list[io.BytesI
overwrite=True,
)
except Exception as e:
logger.warning(f"Failed to store file {f.name} in file_store: {e}")
logger.warning(f"Failed to store file {f.name} in file store: {e}")

named.append(f)
else:
Expand Down Expand Up @@ -1234,6 +1254,22 @@ def _build_child_agent_context(self, child_agent: "Agent") -> dict[str, Any]:

return child_context

def cleanup(self) -> None:
"""Cleanup agent resources (sandbox, etc.)."""
if self.sandbox and hasattr(self.sandbox.backend, "close"):
try:
self.sandbox.backend.close()
except Exception as e:
logger.warning(f"Agent {self.name} - {self.id}: failed to close sandbox: {e}")

def __del__(self):
"""Destructor - attempt to cleanup on garbage collection."""
try:
self.cleanup()
except Exception:
# Cannot reliably log in __del__, just suppress
... # noqa: E701

def get_clone_attr_initializers(self) -> dict[str, Callable[[Node], Any]]:
base = super().get_clone_attr_initializers()
from dynamiq.prompts import Prompt
Expand Down
9 changes: 9 additions & 0 deletions dynamiq/sandbox/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
from .base import Sandbox, SandboxConfig, ShellCommandResult
from .e2b import E2BSandbox

__all__ = [
"Sandbox",
"SandboxConfig",
"ShellCommandResult",
"E2BSandbox",
]
129 changes: 129 additions & 0 deletions dynamiq/sandbox/base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
"""Base file storage interface and common data structures."""

import abc
from functools import cached_property
from typing import Any

from pydantic import BaseModel, ConfigDict, Field, computed_field

from dynamiq.nodes.node import Node


class ShellCommandResult(BaseModel):
"""Result of a shell command execution."""

stdout: str
stderr: str
exit_code: int | None


class Sandbox(abc.ABC, BaseModel):
"""Abstract base class for sandbox implementations.

This interface provides a unified way to interact with different
sandbox backends (in-memory, file system, E2B, Docker, etc.).
Sandboxes provide file storage and can be extended to support
code execution and other isolated environment capabilities.
"""

@computed_field
@cached_property
def type(self) -> str:
"""Returns the backend type as a string."""
return f"{self.__module__.rsplit('.', 1)[0]}.{self.__class__.__name__}"

def to_dict(self, **kwargs) -> dict[str, Any]:
"""Convert the Sandbox instance to a dictionary.

Returns:
dict: Dictionary representation of the Sandbox instance.
"""
for param in ("include_secure_params", "for_tracing"):
kwargs.pop(param, None)
data = self.model_dump(**kwargs)
data["type"] = self.type
return data

def run_command(
self,
command: str,
timeout: int = 60,
background: bool = False,
) -> ShellCommandResult:
"""Execute a shell command in the sandbox.

This is an optional capability. Subclasses that support command execution
should override this method. The base implementation raises NotImplementedError.

Args:
command: Shell command or script to execute.
timeout: Timeout in seconds (default 60).
background: If True, run command in background (no output).

Returns:
ShellCommandResult with stdout, stderr, and exit_code.

Raises:
NotImplementedError: If the sandbox does not support command execution.
"""
raise NotImplementedError(
f"{self.__class__.__name__} does not support command execution. "
"Use a sandbox backend that supports shell commands (e.g., E2BSandbox)."
)

def get_tools(
self,
llm: Any = None,
file_write_enabled: bool = False,
) -> list[Node]:
"""Return tools this sandbox provides for agent use.

Base implementation returns an empty list. Subclasses can override
to add tools specific to their sandbox type (e.g., shell execution).

Args:
llm: LLM instance for tools that need it.
file_write_enabled: Whether to include file write tool.

Returns:
List of tool instances (Node objects).
"""
# Lazy import to avoid circular dependency
from dynamiq.sandbox.tools.shell import SandboxShellTool

shell_tool = SandboxShellTool(
name="shell",
sandbox=self,
)
return [shell_tool]


class SandboxConfig(BaseModel):
"""Configuration for sandbox and related features.

Attributes:
enabled: Whether sandbox is enabled.
backend: The sandbox backend to use.
agent_file_write_enabled: Whether the agent can write files.
todo_enabled: Whether to enable todo management tools (stored in ._agent/todos.json).
config: Additional configuration options.
"""

enabled: bool = False
backend: Sandbox = Field(..., description="Sandbox backend to use.")
todo_enabled: bool = Field(
default=False, description="Whether to enable todo management tools (todos stored in ._agent/todos.json)."
)
config: dict[str, Any] = Field(default_factory=dict)

model_config = ConfigDict(arbitrary_types_allowed=True)

def to_dict(self, **kwargs) -> dict[str, Any]:
"""Convert the SandboxConfig instance to a dictionary."""
for_tracing = kwargs.pop("for_tracing", False)
if for_tracing and not self.enabled:
return {"enabled": False}
kwargs.pop("include_secure_params", None)
config_data = self.model_dump(exclude={"backend"}, **kwargs)
config_data["backend"] = self.backend.to_dict()
return config_data
Loading
Loading