-
Notifications
You must be signed in to change notification settings - Fork 121
feat: add basic sandbox abstraction #541
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
2a1348a
c0445b4
54f96b8
8873d02
fd37b25
ba5f620
43c3d13
16c0c59
532ddb8
ea72e94
34ea5b8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -160,6 +160,7 @@ cython_debug/ | |
| .DS_Store | ||
| .local | ||
| sandbox | ||
| !dynamiq/sandbox/ | ||
|
|
||
| # Chainlit | ||
| .chainlit | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
|
@@ -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.", | ||
|
|
@@ -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), | ||
| ] | ||
| ) | ||
maksymbuleshnyi marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| 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] | ||
|
|
@@ -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 | ||
| } | ||
|
|
||
|
|
@@ -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 | ||
|
|
||
|
|
@@ -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) | ||
|
|
||
|
|
@@ -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: | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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), | ||
| ] | ||
| ) | ||
maksymbuleshnyi marked this conversation as resolved.
Show resolved
Hide resolved
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Files silently ignored when sandbox is enabledHigh Severity When sandbox is enabled and users provide files in their input, the files are completely ignored. The condition |
||
|
|
||
| new_tool_description = self.tool_description | ||
| self.system_prompt_manager.set_initial_variable("tool_description", new_tool_description) | ||
|
|
@@ -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]}...") | ||
|
|
@@ -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): | ||
|
|
@@ -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): | ||
|
|
@@ -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: | ||
|
|
@@ -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.""" | ||
|
|
@@ -1234,6 +1258,22 @@ def _build_child_agent_context(self, child_agent: "Agent") -> dict[str, Any]: | |
|
|
||
| return child_context | ||
|
|
||
| def cleanup(self) -> None: | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| """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
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
|
|
||
| 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", | ||
| ] |
| 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( | ||
maksymbuleshnyi marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| 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) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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: |
||
|
|
||
| model_config = ConfigDict(arbitrary_types_allowed=True) | ||
maksymbuleshnyi marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| 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 | ||
maksymbuleshnyi marked this conversation as resolved.
Show resolved
Hide resolved
|
||


Uh oh!
There was an error while loading. Please reload this page.