Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
66 changes: 53 additions & 13 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.sandboxes.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 | None = 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,23 @@ def __init__(self, **kwargs):

self.tools = expanded_tools

if self.file_store_backend:
if self.sandbox_backend:
# Add sandbox tools when sandbox is enabled
tools = self.sandbox_backend.get_tools()
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 @@ -335,6 +346,7 @@ def to_dict_exclude_params(self):
"files": True,
"images": True,
"file_store": True,
"sandbox": True,
"system_prompt_manager": True, # Runtime state container, not serializable
}

Expand All @@ -353,6 +365,7 @@ def to_dict(self, **kwargs) -> dict:
data["images"] = [{"name": getattr(f, "name", f"image_{i}")} for i, f in enumerate(self.images)]

data["file_store"] = self.file_store.to_dict(**kwargs) if self.file_store else None
data["sandbox"] = self.sandbox.to_dict(**kwargs) if self.sandbox else None

return data

Expand Down Expand Up @@ -444,6 +457,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 @@ -487,12 +501,17 @@ def execute(

files = input_data.files
uploaded_file_names: set[str] = set()
if files:
if files and not self.sandbox_backend:
if not self.file_store_backend:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What if sandbox enabled? Shoudl we still add such tools? Because we partially handle this in init

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For now, I think that we need to separate logic of FileStore and Sandbox. We discussed it that we want to deprecate FileStore soon. Current trajectory - keep FileStore as it is, without any changes and shadow logic and introduce sandbox. Little by little transition there.

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),
]
)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Files silently ignored when sandbox is enabled

High Severity

When sandbox is enabled and users provide files in their input, the files are completely ignored. The condition if files and not self.sandbox_backend: causes the entire file handling block to be skipped when sandbox is active. This means files are never stored, never injected into the agent's message context via _inject_attached_files_into_message, and the agent has no knowledge of uploaded files. Users uploading files to a sandbox-enabled agent will experience silent failure with no error or warning.

Fix in Cursor Fix in Web


new_tool_description = self.tool_description
self.system_prompt_manager.set_initial_variable("tool_description", new_tool_description)
Expand Down Expand Up @@ -546,7 +565,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 +981,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 storage backend."""
named = []
for i, f in enumerate(files):
if isinstance(f, bytes):
Expand All @@ -980,7 +999,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 +1021,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 @@ -1190,6 +1209,11 @@ def file_store_backend(self) -> FileStore | None:
"""Get the file store backend from the configuration if enabled."""
return self.file_store.backend if self.file_store.enabled else None

@property
def sandbox_backend(self):
"""Get the sandbox backend from the configuration if enabled."""
return self.sandbox.backend if self.sandbox and self.sandbox.enabled else None

@property
def tool_description(self) -> str:
"""Returns a description of the tools available to the agent."""
Expand Down Expand Up @@ -1234,6 +1258,22 @@ def _build_child_agent_context(self, child_agent: "Agent") -> dict[str, Any]:

return child_context

def cleanup(self) -> None:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What if we wanna reuse sandbox on next request? We are going to connect to it?

Also let's rename to def close(self) -> None: as this is more clear name for this purpose and we used if few more nodes it

"""Cleanup agent resources (sandbox, etc.)."""
if self.sandbox_backend 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

Comment on lines 1262 to 1276
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In this case, we can try adding a new parameter on init – something like auto_delete_sandbox, set it to False by default, and when it's True, we can perform automatic session deletion (like it is implemented here)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not sure if we need that. E2B can handle auto-deletion. probably agent doesn't really care about the delete/cleanup part

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree, can be done on their side via timeout

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/sandboxes/__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",
]
133 changes: 133 additions & 0 deletions dynamiq/sandboxes/base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
"""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.connections.connections import BaseConnection
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.
"""

connection: BaseConnection | None = Field(default=None, description="Connection to the sandbox backend.")

@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.

Args:
for_tracing: If True, exclude sensitive fields like connection credentials.

Returns:
dict: Dictionary representation of the Sandbox instance.
"""
for_tracing = kwargs.pop("for_tracing", False)
kwargs.pop("include_secure_params", None)

has_connection = getattr(self, "connection", None) is not None
exclude_fields = {"connection"} if has_connection else set()
data = self.model_dump(exclude=exclude_fields, **kwargs)
data["type"] = self.type

if has_connection:
data["connection"] = self.connection.to_dict(for_tracing=for_tracing, **kwargs)

return data

def run_command_shell(
self,
command: str,
timeout: int = 60,
run_in_background_enabled: 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).
run_in_background_enabled: 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) -> list[Node]:
"""Return tools this sandbox provides for agent use.

Base implementation returns shell tool. Subclasses can override
to add or replace tools specific to their sandbox type.

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

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

def close(self) -> None:
"""Close the sandbox."""
raise NotImplementedError(f"Implementation of close() is not implemented for {self.__class__.__name__}")


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

Attributes:
enabled: Whether sandbox is enabled.
backend: The sandbox backend to use.
config: Additional configuration options.
"""

enabled: bool = False
backend: Sandbox = Field(..., description="Sandbox backend to use.")
config: dict[str, Any] = Field(default_factory=dict)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think here also need to add tools with ability to configure each of it. Or maybe in backend. Not sure for now. Idea like:

nodes:
  coding-agent:
    type: dynamiq.nodes.agents.Agent
    sandbox:
      enabled: true
      backend:
        type: dynamiq.sandbox.E2BSandbox
        connection: e2b-conn
      tools:
        shell:
          enabled: true
          allowed_commands: ["python", "pip", "ls", "cat"]
          blocked_commands: ["rm -rf", "sudo"]


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)
kwargs.pop("include_secure_params", None)
if for_tracing and not self.enabled:
return {"enabled": False}
config_data = self.model_dump(exclude={"backend"}, **kwargs)
config_data["backend"] = self.backend.to_dict(for_tracing=for_tracing, **kwargs)
return config_data
Loading
Loading