diff --git a/src/cli/App.tsx b/src/cli/App.tsx index 8884ff88c..d5521ab87 100644 --- a/src/cli/App.tsx +++ b/src/cli/App.tsx @@ -48,6 +48,7 @@ import { type RalphState, ralphMode, } from "../ralph/mode"; +import { getSandboxStatus, type SandboxStatus } from "../sandbox"; import { updateProjectSettings } from "../settings"; import { settingsManager } from "../settings-manager"; import { telemetry } from "../telemetry"; @@ -104,6 +105,7 @@ import { PinDialog, validateAgentName } from "./components/PinDialog"; // QuestionDialog removed - now using InlineQuestionApproval import { ReasoningMessage } from "./components/ReasoningMessageRich"; import { ResumeSelector } from "./components/ResumeSelector"; +import { SandboxSelector } from "./components/SandboxSelector"; import { formatUsageStats } from "./components/SessionStats"; // InlinePlanApproval kept for easy rollback if needed // import { InlinePlanApproval } from "./components/InlinePlanApproval"; @@ -791,6 +793,7 @@ export default function App({ | "pin" | "new" | "mcp" + | "sandbox" | "help" | "oauth" | null; @@ -804,6 +807,11 @@ export default function App({ // Pin dialog state const [pinDialogLocal, setPinDialogLocal] = useState(false); + // Sandbox state + const [sandboxStatus, setSandboxStatus] = useState( + null, + ); + // Derived: check if any selector/overlay is open (blocks queue processing and hides input) const anySelectorOpen = activeOverlay !== null; @@ -1381,6 +1389,14 @@ export default function App({ : "default"; setCurrentToolset(derivedToolset); } + + // Fetch sandbox status + try { + const status = await getSandboxStatus(agentId); + setSandboxStatus(status); + } catch { + setSandboxStatus(null); + } } catch (error) { console.error("Error fetching agent config:", error); } @@ -3510,6 +3526,69 @@ export default function App({ return { submitted: true }; } + // Special handling for /sandbox command - manage sandbox providers + if (trimmed === "/sandbox" || trimmed.startsWith("/sandbox ")) { + const afterSandbox = trimmed.slice(8).trim(); + const firstWord = afterSandbox.split(/\s+/)[0]?.toLowerCase(); + + // /sandbox or /sandbox enable - open sandbox selector + if (!firstWord || firstWord === "enable") { + setActiveOverlay("sandbox"); + return { submitted: true }; + } + + // /sandbox disable - disable sandbox directly + if (firstWord === "disable") { + setCommandRunning(true); + + try { + const { disableSandbox } = await import("../sandbox"); + const removed = await disableSandbox(agentId); + setSandboxStatus({ enabled: false, provider: null }); + const cmdId = uid("cmd"); + buffersRef.current.byId.set(cmdId, { + kind: "command", + id: cmdId, + input: trimmed, + output: `Sandbox disabled (${removed} tools removed)`, + phase: "finished", + success: true, + }); + buffersRef.current.order.push(cmdId); + } catch (err) { + const cmdId = uid("cmd"); + buffersRef.current.byId.set(cmdId, { + kind: "command", + id: cmdId, + input: trimmed, + output: `Failed to disable sandbox: ${err}`, + phase: "finished", + success: false, + }); + buffersRef.current.order.push(cmdId); + } + + setCommandRunning(false); + refreshDerived(); + return { submitted: true }; + } + + // Unknown subcommand - show usage + const cmdId = uid("cmd"); + buffersRef.current.byId.set(cmdId, { + kind: "command", + id: cmdId, + input: trimmed, + output: + "Usage: /sandbox [enable|disable]\n /sandbox - Open sandbox provider selector\n /sandbox enable - Same as /sandbox\n /sandbox disable - Remove sandbox tools", + phase: "finished", + success: true, + }); + buffersRef.current.order.push(cmdId); + refreshDerived(); + return { submitted: true }; + } + // Special handling for /connect command - OAuth connection if (msg.trim().startsWith("/connect")) { const parts = msg.trim().split(/\s+/); @@ -7179,6 +7258,7 @@ Plan file path: ${planFilePath}`; agentName={agentName} currentModel={currentModelDisplay} currentModelProvider={currentModelProvider} + sandboxProvider={sandboxStatus?.provider ?? null} messageQueue={messageQueue} onEnterQueueEditMode={handleEnterQueueEditMode} onEscapeCancel={ @@ -7292,6 +7372,34 @@ Plan file path: ${planFilePath}`; /> )} + {/* Sandbox Selector - conditionally mounted as overlay */} + {activeOverlay === "sandbox" && ( + { + closeOverlay(); + // Update sandbox status + getSandboxStatus(agentId) + .then(setSandboxStatus) + .catch(() => setSandboxStatus(null)); + // Show result + const cmdId = uid("cmd"); + buffersRef.current.byId.set(cmdId, { + kind: "command", + id: cmdId, + input: "/sandbox", + output: message, + phase: "finished", + success: true, + }); + buffersRef.current.order.push(cmdId); + refreshDerived(); + }} + onCancel={closeOverlay} + /> + )} + {/* Help Dialog - conditionally mounted as overlay */} {activeOverlay === "help" && } diff --git a/src/cli/commands/registry.ts b/src/cli/commands/registry.ts index 3b3973e31..92a5171b1 100644 --- a/src/cli/commands/registry.ts +++ b/src/cli/commands/registry.ts @@ -244,6 +244,14 @@ export const commands: Record = { return `Installed Shift+Enter keybinding for ${terminalName}\nLocation: ${keybindingsPath}`; }, }, + "/sandbox": { + desc: "Connect remote sandbox (E2B/Daytona)", + order: 37, + handler: () => { + // Handled specially in App.tsx to open SandboxSelector component + return "Opening sandbox selector..."; + }, + }, // === Session management (order 40-49) === "/connect": { diff --git a/src/cli/components/InputRich.tsx b/src/cli/components/InputRich.tsx index ccc0a206a..b81c5b112 100644 --- a/src/cli/components/InputRich.tsx +++ b/src/cli/components/InputRich.tsx @@ -51,6 +51,7 @@ const InputFooter = memo(function InputFooter({ agentName, currentModel, isAnthropicProvider, + sandboxProvider, }: { ctrlCPressed: boolean; escapePressed: boolean; @@ -61,6 +62,7 @@ const InputFooter = memo(function InputFooter({ agentName: string | null | undefined; currentModel: string | null | undefined; isAnthropicProvider: boolean; + sandboxProvider: string | null | undefined; }) { return ( @@ -95,6 +97,9 @@ const InputFooter = memo(function InputFooter({ > {` [${currentModel ?? "unknown"}]`} + {sandboxProvider && ( + {` [${sandboxProvider.toUpperCase()}]`} + )} ); @@ -124,6 +129,7 @@ export function Input({ agentName, currentModel, currentModelProvider, + sandboxProvider, messageQueue, onEnterQueueEditMode, onEscapeCancel, @@ -147,6 +153,7 @@ export function Input({ agentName?: string | null; currentModel?: string | null; currentModelProvider?: string | null; + sandboxProvider?: string | null; messageQueue?: string[]; onEnterQueueEditMode?: () => void; onEscapeCancel?: () => void; @@ -836,6 +843,7 @@ export function Input({ agentName={agentName} currentModel={currentModel} isAnthropicProvider={currentModelProvider === ANTHROPIC_PROVIDER_NAME} + sandboxProvider={sandboxProvider} /> diff --git a/src/cli/components/SandboxSelector.tsx b/src/cli/components/SandboxSelector.tsx new file mode 100644 index 000000000..b01b23f24 --- /dev/null +++ b/src/cli/components/SandboxSelector.tsx @@ -0,0 +1,249 @@ +import { Box, Text, useInput } from "ink"; +import { useState } from "react"; +import { + disableSandbox, + enableSandbox, + getApiKeyFromEnv, + hasApiKeyInEnv, + type SandboxProvider, + type SandboxStatus, +} from "../../sandbox"; +import { colors } from "./colors"; +import { PasteAwareTextInput } from "./PasteAwareTextInput"; + +type SandboxOption = "e2b" | "daytona" | "disable"; + +interface SandboxSelectorProps { + agentId: string; + initialStatus?: SandboxStatus; + onComplete: (message: string) => void; + onCancel: () => void; +} + +export function SandboxSelector({ + agentId, + initialStatus, + onComplete, + onCancel, +}: SandboxSelectorProps) { + const [selectedIndex, setSelectedIndex] = useState(0); + const [phase, setPhase] = useState<"select" | "input" | "loading">("select"); + const [selectedProvider, setSelectedProvider] = + useState(null); + const [apiKey, setApiKey] = useState(""); + const [error, setError] = useState(null); + const [loadingMessage, setLoadingMessage] = useState(""); + + // Check which providers have API keys in env + const e2bHasKey = hasApiKeyInEnv("e2b"); + const daytonaHasKey = hasApiKeyInEnv("daytona"); + + const options: Array<{ + id: SandboxOption; + label: string; + description: string; + hasKey?: boolean; + }> = [ + { + id: "e2b", + label: "E2B", + description: "Cloud sandbox (https://e2b.dev)", + hasKey: e2bHasKey, + }, + { + id: "daytona", + label: "Daytona", + description: "Cloud sandbox (https://daytona.io)", + hasKey: daytonaHasKey, + }, + ]; + + // Add disable option if sandbox is currently enabled + if (initialStatus?.enabled) { + options.push({ + id: "disable", + label: "Disable", + description: `Remove ${initialStatus.provider?.toUpperCase() || "sandbox"} tools`, + }); + } + + const handleSelect = async (option: SandboxOption) => { + if (option === "disable") { + setPhase("loading"); + setLoadingMessage("Disabling sandbox..."); + try { + const removed = await disableSandbox(agentId); + onComplete(`Sandbox disabled (${removed} tools removed)`); + } catch (err) { + setError(`Failed to disable sandbox: ${err}`); + setPhase("select"); + } + return; + } + + const provider = option as SandboxProvider; + + // Check if API key is in env + const envKey = getApiKeyFromEnv(provider); + if (envKey) { + // Use env key directly + await enableWithKey(provider, envKey); + } else { + // Need to prompt for API key + setSelectedProvider(provider); + setPhase("input"); + } + }; + + const enableWithKey = async (provider: SandboxProvider, key: string) => { + setPhase("loading"); + setLoadingMessage(`Enabling ${provider.toUpperCase()} sandbox...`); + try { + const toolCount = await enableSandbox(agentId, provider, key); + onComplete( + `Sandbox enabled: ${provider.toUpperCase()} (${toolCount} tools attached)`, + ); + } catch (err) { + setError(`Failed to enable sandbox: ${err}`); + setPhase("select"); + } + }; + + const handleApiKeySubmit = async () => { + if (!apiKey.trim()) { + setError("API key cannot be empty"); + return; + } + if (!selectedProvider) return; + await enableWithKey(selectedProvider, apiKey.trim()); + }; + + useInput( + (input, key) => { + // CTRL-C: immediately cancel + if (key.ctrl && input === "c") { + onCancel(); + return; + } + + if (phase === "select") { + if (key.upArrow) { + setSelectedIndex((prev) => Math.max(0, prev - 1)); + } else if (key.downArrow) { + setSelectedIndex((prev) => Math.min(options.length - 1, prev + 1)); + } else if (key.return) { + const selected = options[selectedIndex]; + if (selected) { + handleSelect(selected.id); + } + } else if (key.escape) { + onCancel(); + } + } else if (phase === "input") { + if (key.escape) { + setPhase("select"); + setApiKey(""); + setError(null); + } else if (key.return) { + handleApiKeySubmit(); + } + } + }, + { isActive: phase !== "loading" }, + ); + + // Loading state + if (phase === "loading") { + return ( + + {loadingMessage} + + ); + } + + // API key input phase + if (phase === "input" && selectedProvider) { + const providerName = selectedProvider.toUpperCase(); + const getKeyUrl = + selectedProvider === "e2b" ? "https://e2b.dev" : "https://daytona.io"; + + return ( + + + {providerName} API Key + + + {error && {error}} + + + API Key: + + + + Get an API key at: {getKeyUrl} + + Enter to submit, ESC to go back + + ); + } + + // Provider selection phase + return ( + + + + Select Sandbox Provider (↑↓ to navigate, Enter to select, ESC to + cancel) + + + + {error && {error}} + + + {options.map((option, index) => { + const isSelected = index === selectedIndex; + const isCurrent = + initialStatus?.enabled && initialStatus.provider === option.id; + + return ( + + + {isSelected ? ">" : " "} + + + + + {option.label} + + {isCurrent && ( + (current) + )} + {option.hasKey !== undefined && ( + + {option.hasKey ? "(env key found)" : "(no env key)"} + + )} + + {option.description} + + + ); + })} + + + ); +} diff --git a/src/sandbox/daytona.ts b/src/sandbox/daytona.ts new file mode 100644 index 000000000..adb10949a --- /dev/null +++ b/src/sandbox/daytona.ts @@ -0,0 +1,632 @@ +/** + * Daytona Sandbox Tool Definitions + * + * Python source code extracted from letta-ai/letta-tools/daytona_sandbox_tools/tools.py + * These tools execute inside Daytona cloud sandboxes. + */ + +import type { SandboxToolDefinition } from "./e2b"; + +export const DAYTONA_PIP_PACKAGE = "daytona"; + +// Helper to get or create Daytona sandbox (shared by all tools) +const GET_SANDBOX_HELPER = ` +from letta_client import Letta +client = Letta() + +def _get_or_create_daytona_sandbox(): + """Get existing sandbox or create new one for the current agent.""" + import os + from daytona import Daytona, DaytonaConfig, CreateSandboxFromSnapshotParams + + agent_id = os.environ.get("LETTA_AGENT_ID") + if not agent_id: + raise ValueError("LETTA_AGENT_ID environment variable not set") + + daytona_api_key = os.environ.get("DAYTONA_API_KEY") + if not daytona_api_key: + raise ValueError("DAYTONA_API_KEY environment variable not set") + + daytona_api_url = os.environ.get("DAYTONA_API_URL", "https://app.daytona.io/api") + + config = DaytonaConfig(api_key=daytona_api_key, api_url=daytona_api_url) + daytona = Daytona(config) + + agent = client.agents.retrieve(agent_id=agent_id) + sandbox_id = None + if agent.metadata and isinstance(agent.metadata, dict): + sandbox_id = agent.metadata.get("daytona_sandbox_id") + + sandbox = None + if sandbox_id: + try: + sandbox = daytona.get(sandbox_id) + if sandbox.state == "stopped": + sandbox.start(timeout=60) + elif sandbox.state == "archived": + sandbox = None + except Exception: + sandbox = None + + if sandbox is None: + params = CreateSandboxFromSnapshotParams( + auto_stop_interval=60, + auto_archive_interval=0, + labels={"letta_agent_id": agent_id}, + ) + sandbox = daytona.create(params, timeout=120) + + current_metadata = agent.metadata or {} + if not isinstance(current_metadata, dict): + current_metadata = {} + current_metadata["daytona_sandbox_id"] = sandbox.id + client.agents.modify(agent_id=agent_id, metadata=current_metadata) + + return sandbox, daytona +`; + +export const DAYTONA_TOOLS: SandboxToolDefinition[] = [ + { + name: "sandbox_read", + description: "Read a file from the Daytona sandbox filesystem.", + source_code: `${GET_SANDBOX_HELPER} + +def sandbox_read(file_path: str, offset: int = 0, limit: int = None) -> str: + """ + Reads a file from the Daytona sandbox. + + Args: + file_path: The absolute path to the file to read + offset: The line number to start reading from (0-indexed) + limit: The number of lines to read. If not provided, reads all lines. + + Returns: + str: The file contents with line numbers + """ + sandbox, _ = _get_or_create_daytona_sandbox() + + try: + content_bytes = sandbox.fs.download_file(file_path) + content = content_bytes.decode('utf-8') + + lines = content.split('\\n') + total_lines = len(lines) + + if offset > 0: + lines = lines[offset:] + if limit is not None: + lines = lines[:limit] + + start_line = offset + 1 + numbered_lines = [] + for i, line in enumerate(lines): + line_num = start_line + i + numbered_lines.append(f"{line_num:6d}\\t{line}") + + result = '\\n'.join(numbered_lines) + + if limit and offset + limit < total_lines: + result += f"\\n[Showing lines {offset + 1}-{offset + limit} of {total_lines}]" + + return result + + except Exception as e: + error_msg = str(e) + if "not found" in error_msg.lower() or "no such file" in error_msg.lower(): + raise ValueError(f"File does not exist: {file_path}") + raise ValueError(f"Failed to read file: {error_msg}") +`, + }, + { + name: "sandbox_write", + description: "Write content to a file in the Daytona sandbox.", + source_code: `${GET_SANDBOX_HELPER} + +def sandbox_write(file_path: str, content: str) -> str: + """ + Writes content to a file in the Daytona sandbox. + + Args: + file_path: The absolute path to the file to write + content: The content to write to the file + + Returns: + str: Success message + """ + sandbox, _ = _get_or_create_daytona_sandbox() + + try: + parent_dir = "/".join(file_path.rsplit("/", 1)[:-1]) + if parent_dir: + sandbox.process.exec(f"mkdir -p {parent_dir}") + + sandbox.fs.upload_file(content.encode('utf-8'), file_path) + + return f"Successfully wrote {len(content)} characters to {file_path}" + + except Exception as e: + raise ValueError(f"Failed to write file: {str(e)}") +`, + }, + { + name: "sandbox_edit", + description: + "Performs exact string replacement in a file in the Daytona sandbox.", + source_code: `${GET_SANDBOX_HELPER} + +def sandbox_edit(file_path: str, old_string: str, new_string: str, replace_all: bool = False) -> str: + """ + Performs exact string replacement in a file in the Daytona sandbox. + + Args: + file_path: The absolute path to the file to modify + old_string: The text to replace + new_string: The text to replace it with + replace_all: Replace all occurrences of old_string (default false) + + Returns: + str: A message indicating the edit was successful + """ + if old_string == new_string: + raise ValueError("old_string and new_string are the same - no edit needed") + + sandbox, _ = _get_or_create_daytona_sandbox() + + try: + content_bytes = sandbox.fs.download_file(file_path) + content = content_bytes.decode('utf-8') + + occurrences = content.count(old_string) + if occurrences == 0: + raise ValueError(f"String not found in file.\\nString: {old_string}") + + if occurrences > 1 and not replace_all: + raise ValueError( + f"Found {occurrences} matches but replace_all is False. " + f"Set replace_all=True or provide more context.\\nString: {old_string}" + ) + + if replace_all: + new_content = content.replace(old_string, new_string) + replaced_count = occurrences + else: + new_content = content.replace(old_string, new_string, 1) + replaced_count = 1 + + sandbox.fs.upload_file(new_content.encode('utf-8'), file_path) + + return f"Successfully replaced {replaced_count} occurrence{'s' if replaced_count != 1 else ''} in {file_path}" + + except ValueError: + raise + except Exception as e: + error_msg = str(e) + if "not found" in error_msg.lower(): + raise ValueError(f"File does not exist: {file_path}") + raise ValueError(f"Failed to edit file: {error_msg}") +`, + }, + { + name: "sandbox_bash", + description: "Execute a bash command in the Daytona sandbox.", + source_code: `${GET_SANDBOX_HELPER} + +def sandbox_bash(command: str, timeout: int = 120, run_in_background: bool = False, shell_id: str = None) -> str: + """ + Execute a bash command in the Daytona sandbox. + + Args: + command: The bash command to execute + timeout: Timeout in seconds (default: 120, max: 600) + run_in_background: Run the command in the background + shell_id: Optional custom ID for background shell + + Returns: + str: Command output or shell_id for background commands + """ + import uuid + + sandbox, _ = _get_or_create_daytona_sandbox() + + MAX_OUTPUT_CHARS = 30000 + timeout_seconds = max(1, min(timeout, 600)) + + if run_in_background: + from daytona import SessionExecuteRequest + session_id = shell_id if shell_id else f"bg-{uuid.uuid4().hex[:8]}" + + try: + sandbox.process.create_session(session_id) + req = SessionExecuteRequest(command=command, run_async=True) + result = sandbox.process.execute_session_command(session_id, req, timeout=timeout_seconds) + + return f"Background shell started with ID: {session_id}\\nCommand ID: {result.cmd_id}\\nUse sandbox_bash_output('{session_id}') to check output." + + except Exception as e: + raise ValueError(f"Failed to start background command: {str(e)}") + + else: + try: + result = sandbox.process.exec(command, timeout=timeout_seconds) + + output = result.result if hasattr(result, 'result') else "" + exit_code = result.exit_code if hasattr(result, 'exit_code') else 0 + + if len(output) > MAX_OUTPUT_CHARS: + output = output[:MAX_OUTPUT_CHARS] + "\\n[Output truncated]" + + if exit_code != 0: + return f"Exit code: {exit_code}\\n{output}" + + return output + + except Exception as e: + error_msg = str(e) + if "timeout" in error_msg.lower(): + raise ValueError(f"Command timed out after {timeout_seconds} seconds") + raise ValueError(f"Command failed: {error_msg}") +`, + }, + { + name: "sandbox_bash_output", + description: + "Retrieves output from a running or completed background bash shell.", + source_code: `${GET_SANDBOX_HELPER} + +def sandbox_bash_output(shell_id: str, filter: str = None) -> str: + """ + Retrieves output from a running or completed background bash shell. + + Args: + shell_id: The ID of the background shell to retrieve output from + filter: Optional regular expression to filter the output lines + + Returns: + str: The stdout and stderr from the shell + """ + import re + + sandbox, _ = _get_or_create_daytona_sandbox() + + try: + session = sandbox.process.get_session(shell_id) + + if not session.commands: + return f"No commands found in session '{shell_id}'" + + cmd = session.commands[-1] + cmd_id = cmd.id if hasattr(cmd, 'id') else cmd.command_id + + logs = sandbox.process.get_session_command_logs(shell_id, cmd_id) + + text = logs.stdout if hasattr(logs, 'stdout') else "" + stderr = logs.stderr if hasattr(logs, 'stderr') else "" + + if stderr: + text = f"{text}\\n{stderr}" if text else stderr + + if filter and text: + try: + pattern = re.compile(filter) + text = "\\n".join(line for line in text.split("\\n") if pattern.search(line)) + except re.error: + pass + + if not text: + text = "(no output yet)" + + if len(text) > 30000: + text = text[:30000] + "\\n[Output truncated]" + + exit_code = cmd.exit_code if hasattr(cmd, 'exit_code') else None + status = "running" if exit_code is None else f"finished (exit code: {exit_code})" + + return f"Status: {status}\\n\\n{text}" + + except Exception as e: + return f"Failed to get output for session '{shell_id}': {str(e)}" +`, + }, + { + name: "sandbox_bash_kill", + description: "Kills a running background bash shell by its ID.", + source_code: `${GET_SANDBOX_HELPER} + +def sandbox_bash_kill(shell_id: str) -> str: + """ + Kills a running background bash shell by its ID. + + Args: + shell_id: The ID of the shell to terminate + + Returns: + str: Confirmation message + """ + sandbox, _ = _get_or_create_daytona_sandbox() + + try: + sandbox.process.delete_session(shell_id) + return f"Killed shell '{shell_id}'" + + except Exception as e: + return f"Failed to kill shell '{shell_id}': {str(e)}" +`, + }, + { + name: "sandbox_grep", + description: + "Search for a pattern in files in the Daytona sandbox using grep.", + source_code: `${GET_SANDBOX_HELPER} + +def sandbox_grep(pattern: str, path: str = None, include: str = None, context_lines: int = 0, max_results: int = 100) -> str: + """ + Search for a pattern in files in the Daytona sandbox using grep. + + Args: + pattern: The regular expression pattern to search for + path: File or directory to search in. Defaults to /home/daytona. + include: File pattern to include (e.g., '*.py') + context_lines: Number of context lines to show before and after + max_results: Maximum number of results to return + + Returns: + str: Search results with file paths, line numbers, and matching content + """ + search_path = path if path else "/home/daytona" + sandbox, _ = _get_or_create_daytona_sandbox() + + MAX_OUTPUT_CHARS = 30000 + + try: + cmd_parts = ["grep", "-rn"] + + if context_lines > 0: + cmd_parts.append(f"-C{context_lines}") + + if include: + cmd_parts.append(f"--include='{include}'") + + escaped_pattern = pattern.replace("'", "'\\\\''") + cmd_parts.append(f"'{escaped_pattern}'") + cmd_parts.append(search_path) + + cmd = " ".join(cmd_parts) + f" 2>/dev/null | head -n {max_results}" + + result = sandbox.process.exec(cmd, timeout=60) + output = result.result if hasattr(result, 'result') else "" + + if not output or output.strip() == "": + return f"No matches found for pattern: {pattern}" + + if len(output) > MAX_OUTPUT_CHARS: + output = output[:MAX_OUTPUT_CHARS] + "\\n[Output truncated]" + + return output + + except Exception as e: + raise ValueError(f"Grep failed: {str(e)}") +`, + }, + { + name: "sandbox_glob", + description: "Find files matching a glob pattern in the sandbox.", + source_code: `${GET_SANDBOX_HELPER} + +def sandbox_glob(pattern: str, path: str = None) -> str: + """ + Find files matching a glob pattern in the sandbox. + + Args: + pattern: The glob pattern to match files against + path: The directory to search in. Defaults to /home/daytona. + + Returns: + str: List of matching files + """ + search_path = path if path else "/home/daytona" + sandbox, _ = _get_or_create_daytona_sandbox() + + try: + if "**" in pattern: + name_pattern = pattern.replace("**/", "").replace("**", "*") + find_cmd = f"find {search_path} -type f -name '{name_pattern}' 2>/dev/null | sort" + else: + find_cmd = f"find {search_path} -type f -name '{pattern}' 2>/dev/null | sort" + + result = sandbox.process.exec(find_cmd, timeout=30) + output = result.result if hasattr(result, 'result') else "" + + if not output or output.strip() == "": + return "No files found" + + files = [f for f in output.strip().split("\\n") if f] + total_files = len(files) + + max_files = 2000 + if total_files > max_files: + files = files[:max_files] + files.append(f"\\n[Output truncated: showing {max_files:,} of {total_files:,} files.]") + + return f"Found {total_files} file{'s' if total_files != 1 else ''}\\n" + "\\n".join(files) + + except Exception as e: + raise ValueError(f"Glob failed: {str(e)}") +`, + }, + { + name: "sandbox_ls", + description: "List directory contents in the Daytona sandbox.", + source_code: `${GET_SANDBOX_HELPER} + +def sandbox_ls(path: str = "/home/daytona", ignore: list = None) -> str: + """ + List directory contents in the Daytona sandbox. + + Args: + path: The absolute path to the directory to list + ignore: List of glob patterns to ignore + + Returns: + str: Formatted directory listing + """ + sandbox, _ = _get_or_create_daytona_sandbox() + + try: + try: + files = sandbox.fs.list_files(path) + lines = [] + for f in files: + name = f.name if hasattr(f, 'name') else str(f) + is_dir = f.is_dir if hasattr(f, 'is_dir') else False + + if ignore: + import fnmatch + should_ignore = any(fnmatch.fnmatch(name, pat) for pat in ignore) + if should_ignore: + continue + + if is_dir: + lines.append(f"📁 {name}/") + else: + size = f.size if hasattr(f, 'size') else 0 + lines.append(f"📄 {name} ({size} bytes)") + + if not lines: + return f"{path}/ (empty)" + + return f"{path}/\\n" + "\\n".join(sorted(lines)) + + except Exception: + cmd = f"ls -la {path}" + result = sandbox.process.exec(cmd, timeout=30) + output = result.result if hasattr(result, 'result') else "" + + if ignore and output: + lines = output.split("\\n") + filtered = [] + import fnmatch + for line in lines: + should_ignore = False + for pat in ignore: + if fnmatch.fnmatch(line, f"*{pat}*"): + should_ignore = True + break + if not should_ignore: + filtered.append(line) + output = "\\n".join(filtered) + + return output if output else f"{path}/ (empty or inaccessible)" + + except Exception as e: + raise ValueError(f"Failed to list directory: {str(e)}") +`, + }, + { + name: "sandbox_status", + description: "Get the status of the current agent's sandbox.", + source_code: `from letta_client import Letta +client = Letta() + +def sandbox_status() -> str: + """ + Get the status of the current agent's sandbox. + + Returns: + str: Sandbox status information + """ + import os + from daytona import Daytona, DaytonaConfig + + agent_id = os.environ.get("LETTA_AGENT_ID") + if not agent_id: + raise ValueError("LETTA_AGENT_ID environment variable not set") + + agent = client.agents.retrieve(agent_id=agent_id) + + sandbox_id = None + if agent.metadata and isinstance(agent.metadata, dict): + sandbox_id = agent.metadata.get("daytona_sandbox_id") + + if not sandbox_id: + return "No sandbox associated with this agent. A new one will be created on the next sandbox operation." + + try: + daytona_api_key = os.environ.get("DAYTONA_API_KEY") + daytona_api_url = os.environ.get("DAYTONA_API_URL", "https://app.daytona.io/api") + + config = DaytonaConfig(api_key=daytona_api_key, api_url=daytona_api_url) + daytona = Daytona(config) + + sandbox = daytona.get(sandbox_id) + sandbox.refresh_data() + + info = [ + f"Sandbox ID: {sandbox.id}", + f"Name: {sandbox.name}", + f"State: {sandbox.state}", + f"Resources: {sandbox.cpu} CPU, {sandbox.memory} GiB RAM, {sandbox.disk} GiB disk", + f"Auto-stop interval: {sandbox.auto_stop_interval} minutes", + ] + + return "\\n".join(info) + + except Exception as e: + return f"Sandbox ID: {sandbox_id}\\nStatus: Unavailable\\nError: {str(e)}" +`, + }, + { + name: "sandbox_kill", + description: "Kill the current agent's sandbox permanently.", + source_code: `from letta_client import Letta +client = Letta() + +def sandbox_kill() -> str: + """ + Kill the current agent's sandbox permanently. + + This will permanently delete the sandbox. A new one will be created + on the next tool call that needs a sandbox. + + Returns: + str: A confirmation message + """ + import os + from daytona import Daytona, DaytonaConfig + + agent_id = os.environ.get("LETTA_AGENT_ID") + if not agent_id: + raise ValueError("LETTA_AGENT_ID environment variable not set") + + agent = client.agents.retrieve(agent_id=agent_id) + + sandbox_id = None + if agent.metadata and isinstance(agent.metadata, dict): + sandbox_id = agent.metadata.get("daytona_sandbox_id") + + if not sandbox_id: + return "No sandbox found for this agent" + + try: + daytona_api_key = os.environ.get("DAYTONA_API_KEY") + daytona_api_url = os.environ.get("DAYTONA_API_URL", "https://app.daytona.io/api") + + config = DaytonaConfig(api_key=daytona_api_key, api_url=daytona_api_url) + daytona = Daytona(config) + + sandbox = daytona.get(sandbox_id) + daytona.delete(sandbox) + + except Exception: + pass + + current_metadata = agent.metadata or {} + if isinstance(current_metadata, dict) and "daytona_sandbox_id" in current_metadata: + del current_metadata["daytona_sandbox_id"] + client.agents.modify(agent_id=agent_id, metadata=current_metadata) + + return f"Sandbox {sandbox_id} has been deleted" +`, + }, +]; + +export const DAYTONA_TOOL_NAMES = DAYTONA_TOOLS.map((t) => t.name); diff --git a/src/sandbox/e2b.ts b/src/sandbox/e2b.ts new file mode 100644 index 000000000..bcb4b4371 --- /dev/null +++ b/src/sandbox/e2b.ts @@ -0,0 +1,668 @@ +/** + * E2B Sandbox Tool Definitions + * + * Python source code extracted from letta-ai/letta-tools/e2b_sandbox_tools/tools.py + * These tools execute inside E2B persistent sandboxes. + */ + +export interface SandboxToolDefinition { + name: string; + description: string; + source_code: string; +} + +export const E2B_PIP_PACKAGE = "e2b-code-interpreter"; + +// Helper to get sandbox (shared by all tools) +const GET_SANDBOX_HELPER = ` +def _get_or_create_e2b_sandbox(): + """Get existing sandbox or create new one for the current agent.""" + import os + from e2b_code_interpreter import Sandbox + + agent_id = os.environ.get("LETTA_AGENT_ID") + if not agent_id: + raise ValueError("LETTA_AGENT_ID environment variable not set") + + agent = client.agents.retrieve(agent_id=agent_id) + sandbox_id = None + if agent.metadata and isinstance(agent.metadata, dict): + sandbox_id = agent.metadata.get("sandbox_id") + + if sandbox_id: + try: + sandbox = Sandbox.connect(sandbox_id, timeout=600) + return sandbox + except Exception: + pass + + sandbox = Sandbox.beta_create(auto_pause=True) + current_metadata = agent.metadata or {} + if not isinstance(current_metadata, dict): + current_metadata = {} + current_metadata["sandbox_id"] = sandbox.sandbox_id + client.agents.update(agent_id=agent_id, metadata=current_metadata) + return sandbox +`; + +export const E2B_TOOLS: SandboxToolDefinition[] = [ + { + name: "sandbox_read", + description: "Read a file from the sandbox filesystem with line numbers.", + source_code: `${GET_SANDBOX_HELPER} + +def sandbox_read(file_path: str, offset: int = 0, limit: int = None) -> str: + """ + Read a file from the sandbox filesystem with line numbers. + + Args: + file_path: The absolute path to the file to read in the sandbox + offset: Line number to start reading from (0-indexed) + limit: Maximum number of lines to read (default: 2000) + + Returns: + str: The file content with line numbers + """ + sandbox = _get_or_create_e2b_sandbox() + + MAX_READ_LINES = 2000 + MAX_CHARS_PER_LINE = 2000 + + try: + content = sandbox.files.read(file_path) + + if not content or content.strip() == "": + return f"\\nThe file {file_path} exists but has empty contents.\\n" + + lines = content.split("\\n") + original_count = len(lines) + effective_limit = limit if limit is not None else MAX_READ_LINES + start_line = offset + end_line = min(start_line + effective_limit, len(lines)) + selected_lines = lines[start_line:end_line] + + max_line_num = start_line + len(selected_lines) + padding = max(1, len(str(max_line_num))) + + formatted_lines = [] + lines_truncated = False + + for i, line in enumerate(selected_lines): + line_num = start_line + i + 1 + if len(line) > MAX_CHARS_PER_LINE: + lines_truncated = True + line = line[:MAX_CHARS_PER_LINE] + "... [line truncated]" + formatted_lines.append(f"{str(line_num).rjust(padding)}→{line}") + + result = "\\n".join(formatted_lines) + + if end_line < original_count and limit is None: + result += f"\\n\\n[File truncated: showing lines {start_line + 1}-{end_line} of {original_count} total lines.]" + if lines_truncated: + result += f"\\n\\n[Some lines exceeded {MAX_CHARS_PER_LINE:,} characters and were truncated.]" + + return result + + except Exception as e: + error_msg = str(e) + if "not found" in error_msg.lower() or "no such file" in error_msg.lower(): + raise ValueError(f"File does not exist: {file_path}") + raise ValueError(f"Failed to read file: {error_msg}") +`, + }, + { + name: "sandbox_write", + description: "Write content to a file in the sandbox filesystem.", + source_code: `${GET_SANDBOX_HELPER} + +def sandbox_write(file_path: str, content: str) -> str: + """ + Write content to a file in the sandbox filesystem. + + Args: + file_path: The absolute path to the file to write in the sandbox + content: The content to write to the file + + Returns: + str: A success message + """ + sandbox = _get_or_create_e2b_sandbox() + + try: + parent_dir = "/".join(file_path.rsplit("/", 1)[:-1]) + if parent_dir: + sandbox.commands.run(f"mkdir -p {parent_dir}") + + sandbox.files.write(file_path, content) + return f"Successfully wrote {len(content)} characters to {file_path}" + except Exception as e: + raise ValueError(f"Failed to write file: {str(e)}") +`, + }, + { + name: "sandbox_edit", + description: "Edit a file in the sandbox by replacing text.", + source_code: `${GET_SANDBOX_HELPER} + +def sandbox_edit(file_path: str, old_string: str, new_string: str, replace_all: bool = False) -> str: + """ + Edit a file in the sandbox by replacing text. + + Args: + file_path: The absolute path to the file to edit + old_string: The text to find and replace + new_string: The text to replace with + replace_all: If True, replace all occurrences; otherwise replace only the first + + Returns: + str: A message indicating how many replacements were made + """ + if old_string == new_string: + raise ValueError("No changes to make: old_string and new_string are exactly the same.") + + sandbox = _get_or_create_e2b_sandbox() + + try: + content = sandbox.files.read(file_path) + occurrences = content.count(old_string) + + if occurrences == 0: + raise ValueError(f"String to replace not found in file.\\nString: {old_string}") + + if replace_all: + new_content = content.replace(old_string, new_string) + replacements = occurrences + else: + new_content = content.replace(old_string, new_string, 1) + replacements = 1 + + sandbox.files.write(file_path, new_content) + return f"Successfully replaced {replacements} occurrence{'s' if replacements != 1 else ''} in {file_path}" + + except ValueError: + raise + except Exception as e: + error_msg = str(e) + if "not found" in error_msg.lower(): + raise ValueError(f"File does not exist: {file_path}") + raise ValueError(f"Failed to edit file: {error_msg}") +`, + }, + { + name: "sandbox_bash", + description: "Execute a bash command in the sandbox.", + source_code: `${GET_SANDBOX_HELPER} + +def sandbox_bash(command: str, timeout: int = None, description: str = None, run_in_background: bool = False) -> str: + """ + Execute a bash command in the sandbox. + + Args: + command: The command to execute + timeout: Optional timeout in milliseconds (max 600000) + description: Clear, concise description of what this command does + run_in_background: Set to true to run in background. Use sandbox_bash_output to read output. + + Returns: + str: Command output, or shell_id if run_in_background is True + """ + import uuid + + sandbox = _get_or_create_e2b_sandbox() + MAX_OUTPUT_CHARS = 30000 + + if timeout is not None: + timeout_seconds = max(1, min(timeout // 1000, 600)) + else: + timeout_seconds = 120 + + if run_in_background: + shell_id = str(uuid.uuid4())[:8] + jobs_dir = "/home/user/.sandbox_jobs" + sandbox.commands.run(f"mkdir -p {jobs_dir}") + + stdout_file = f"{jobs_dir}/{shell_id}.stdout" + stderr_file = f"{jobs_dir}/{shell_id}.stderr" + pid_file = f"{jobs_dir}/{shell_id}.pid" + + bg_command = f"nohup bash -c '{command}' > {stdout_file} 2> {stderr_file} & echo $! > {pid_file}" + sandbox.commands.run(bg_command) + + return f"Started background shell '{shell_id}'. Use sandbox_bash_output to check output." + + try: + result = sandbox.commands.run(command, timeout=timeout_seconds) + + stdout = result.stdout if hasattr(result, 'stdout') else "" + stderr = result.stderr if hasattr(result, 'stderr') else "" + exit_code = result.exit_code if hasattr(result, 'exit_code') else 0 + + output = stdout + if stderr: + output = f"{output}\\n{stderr}" if output else stderr + + if not output: + output = "(Command completed with no output)" + + if len(output) > MAX_OUTPUT_CHARS: + output = output[:MAX_OUTPUT_CHARS] + "\\n[Output truncated]" + + if exit_code != 0: + return f"Exit code: {exit_code}\\n{output}" + + return output + + except Exception as e: + error_msg = str(e) + if "timeout" in error_msg.lower(): + raise ValueError(f"Command timed out after {timeout_seconds} seconds") + raise ValueError(f"Command failed: {error_msg}") +`, + }, + { + name: "sandbox_bash_output", + description: + "Retrieves output from a running or completed background bash shell.", + source_code: `${GET_SANDBOX_HELPER} + +def sandbox_bash_output(shell_id: str, filter: str = None) -> str: + """ + Retrieves output from a running or completed background bash shell. + + Args: + shell_id: The ID of the background shell to retrieve output from + filter: Optional regular expression to filter the output lines + + Returns: + str: The stdout and stderr from the shell + """ + import re + + sandbox = _get_or_create_e2b_sandbox() + + jobs_dir = "/home/user/.sandbox_jobs" + stdout_file = f"{jobs_dir}/{shell_id}.stdout" + stderr_file = f"{jobs_dir}/{shell_id}.stderr" + pid_file = f"{jobs_dir}/{shell_id}.pid" + + result = sandbox.commands.run(f"test -f {pid_file} && echo exists || echo missing") + if "missing" in result.stdout: + return f"No background process found with ID: {shell_id}" + + result = sandbox.commands.run(f"cat {pid_file}") + pid = result.stdout.strip() + result = sandbox.commands.run(f"ps -p {pid} > /dev/null 2>&1 && echo running || echo finished") + status = "running" if "running" in result.stdout else "finished" + + try: + stdout = sandbox.files.read(stdout_file) + except Exception: + stdout = "" + try: + stderr = sandbox.files.read(stderr_file) + except Exception: + stderr = "" + + text = stdout + if stderr: + text = f"{text}\\n{stderr}" if text else stderr + + if filter and text: + try: + pattern = re.compile(filter) + text = "\\n".join(line for line in text.split("\\n") if pattern.search(line)) + except re.error: + pass + + if not text: + text = "(no output yet)" + + if len(text) > 30000: + text = text[:30000] + "\\n[Output truncated]" + + return f"Status: {status}\\n\\n{text}" +`, + }, + { + name: "sandbox_bash_kill", + description: "Kills a running background bash shell by its ID.", + source_code: `${GET_SANDBOX_HELPER} + +def sandbox_bash_kill(shell_id: str) -> str: + """ + Kills a running background bash shell by its ID. + + Args: + shell_id: The ID of the shell to terminate + + Returns: + str: Confirmation message + """ + sandbox = _get_or_create_e2b_sandbox() + + jobs_dir = "/home/user/.sandbox_jobs" + pid_file = f"{jobs_dir}/{shell_id}.pid" + + result = sandbox.commands.run(f"test -f {pid_file} && echo exists || echo missing") + if "missing" in result.stdout: + return f"No background process found with ID: {shell_id}" + + result = sandbox.commands.run(f"cat {pid_file}") + pid = result.stdout.strip() + sandbox.commands.run(f"kill {pid} 2>/dev/null || true") + sandbox.commands.run(f"rm -f {jobs_dir}/{shell_id}.*") + + return f"Killed shell '{shell_id}' (PID {pid})" +`, + }, + { + name: "sandbox_grep", + description: "Search for a pattern in files within the sandbox using grep.", + source_code: `${GET_SANDBOX_HELPER} + +def sandbox_grep(pattern: str, path: str = None, glob: str = None, output_mode: str = "files_with_matches", context_before: int = None, context_after: int = None, context: int = None, line_numbers: bool = True, case_insensitive: bool = False, file_type: str = None, head_limit: int = 100, offset: int = 0, multiline: bool = False) -> str: + """ + Search for a pattern in files within the sandbox using grep. + + Args: + pattern: The regular expression pattern to search for + path: File or directory to search in. Defaults to /home/user. + glob: Glob pattern to filter files (e.g. "*.js") + output_mode: "content", "files_with_matches", or "count" + context_before: Lines to show before match (-B) + context_after: Lines to show after match (-A) + context: Lines before and after (-C) + line_numbers: Show line numbers (-n) + case_insensitive: Case insensitive (-i) + file_type: File type to search (e.g. py, js) + head_limit: Limit results (default 100) + offset: Skip first N results + multiline: Enable multiline mode + + Returns: + str: Search results + """ + sandbox = _get_or_create_e2b_sandbox() + + MAX_OUTPUT_CHARS = 30000 + search_path = path if path else "/home/user" + + grep_cmd = "grep -r" + if case_insensitive: + grep_cmd += " -i" + if output_mode == "files_with_matches": + grep_cmd += " -l" + elif output_mode == "count": + grep_cmd += " -c" + else: + if context is not None and context > 0: + grep_cmd += f" -C {context}" + else: + if context_before is not None and context_before > 0: + grep_cmd += f" -B {context_before}" + if context_after is not None and context_after > 0: + grep_cmd += f" -A {context_after}" + if line_numbers: + grep_cmd += " -n" + + if multiline: + grep_cmd += " -z" + + escaped_pattern = pattern.replace("'", "'\\\\''") + grep_cmd += f" -E '{escaped_pattern}'" + + if glob: + grep_cmd += f" --include='{glob}'" + if file_type: + type_map = {"py": "*.py", "js": "*.js", "ts": "*.ts", "tsx": "*.tsx", "java": "*.java", "go": "*.go", "rs": "*.rs", "rust": "*.rs"} + ext = type_map.get(file_type, f"*.{file_type}") + grep_cmd += f" --include='{ext}'" + + grep_cmd += f" {search_path}" + + try: + result = sandbox.commands.run(grep_cmd) + output = result.stdout if hasattr(result, 'stdout') else str(result) + + if not output or output.strip() == "": + if output_mode == "files_with_matches": + return "No files found" + elif output_mode == "count": + return "0\\n\\nFound 0 total occurrences across 0 files." + return "No matches found" + + lines = output.strip().split("\\n") + + if offset > 0: + lines = lines[offset:] + if head_limit > 0 and len(lines) > head_limit: + lines = lines[:head_limit] + lines.append(f"\\n[Output truncated: showing {head_limit} results]") + + result_text = "\\n".join(lines) + if len(result_text) > MAX_OUTPUT_CHARS: + result_text = result_text[:MAX_OUTPUT_CHARS] + "\\n[Output truncated]" + + if output_mode == "files_with_matches": + file_count = len([l for l in lines if l and not l.startswith("[")]) + return f"Found {file_count} file{'s' if file_count != 1 else ''}\\n{result_text}" + + return result_text + + except Exception as e: + if "exit code 1" in str(e).lower() or "returned 1" in str(e).lower(): + if output_mode == "files_with_matches": + return "No files found" + elif output_mode == "count": + return "0\\n\\nFound 0 total occurrences across 0 files." + return "No matches found" + raise ValueError(f"Grep failed: {str(e)}") +`, + }, + { + name: "sandbox_glob", + description: "Find files matching a glob pattern in the sandbox.", + source_code: `${GET_SANDBOX_HELPER} + +def sandbox_glob(pattern: str, path: str = None) -> str: + """ + Find files matching a glob pattern in the sandbox. + + Args: + pattern: The glob pattern to match files against + path: The directory to search in. Defaults to /home/user. + + Returns: + str: List of matching files + """ + search_path = path if path else "/home/user" + sandbox = _get_or_create_e2b_sandbox() + + try: + if "**" in pattern: + name_pattern = pattern.replace("**/", "").replace("**", "*") + find_cmd = f"find {search_path} -type f -name '{name_pattern}' 2>/dev/null | sort" + else: + find_cmd = f"find {search_path} -type f -name '{pattern}' 2>/dev/null | sort" + + result = sandbox.commands.run(find_cmd) + output = result.stdout if hasattr(result, 'stdout') else str(result) + + if not output or output.strip() == "": + return "No files found" + + files = [f for f in output.strip().split("\\n") if f] + total_files = len(files) + + max_files = 2000 + if total_files > max_files: + files = files[:max_files] + files.append(f"\\n[Output truncated: showing {max_files:,} of {total_files:,} files.]") + + return f"Found {total_files} file{'s' if total_files != 1 else ''}\\n" + "\\n".join(files) + + except Exception as e: + raise ValueError(f"Glob failed: {str(e)}") +`, + }, + { + name: "sandbox_ls", + description: "List contents of a directory in the sandbox.", + source_code: `${GET_SANDBOX_HELPER} + +def sandbox_ls(path: str, ignore: list = None) -> str: + """ + List contents of a directory in the sandbox. + + Args: + path: The directory to list + ignore: Optional list of glob patterns to ignore + + Returns: + str: A tree-like listing of the directory contents + """ + import fnmatch + + sandbox = _get_or_create_e2b_sandbox() + MAX_ENTRIES = 1000 + ignore = ignore or [] + + try: + result = sandbox.commands.run(f"ls -la {path}") + output = result.stdout if hasattr(result, 'stdout') else str(result) + + if not output or output.strip() == "": + return f"{path}/ (empty directory)" + + lines = output.strip().split("\\n") + entries = [] + + for line in lines[1:]: + parts = line.split() + if len(parts) < 9: + continue + + name = " ".join(parts[8:]) + if name in (".", ".."): + continue + + if any(fnmatch.fnmatch(name, p) for p in ignore): + continue + + is_dir = parts[0].startswith("d") + entries.append({"name": name, "type": "directory" if is_dir else "file"}) + + entries.sort(key=lambda x: (0 if x["type"] == "directory" else 1, x["name"].lower())) + + total_entries = len(entries) + truncated = False + if total_entries > MAX_ENTRIES: + entries = entries[:MAX_ENTRIES] + truncated = True + + if not entries: + return f"{path}/ (empty directory)" + + path_parts = path.rstrip("/").split("/") + last_part = path_parts[-1] if path_parts else "/" + parent_path = "/".join(path_parts[:-1]) if len(path_parts) > 1 else "/" + + output_lines = [f"- {parent_path}/", f" - {last_part}/"] + + for entry in entries: + suffix = "/" if entry["type"] == "directory" else "" + output_lines.append(f" - {entry['name']}{suffix}") + + if truncated: + output_lines.append("") + output_lines.append(f"[Output truncated: showing {MAX_ENTRIES:,} of {total_entries:,} entries.]") + + return "\\n".join(output_lines) + + except Exception as e: + error_msg = str(e) + if "not found" in error_msg.lower() or "no such" in error_msg.lower(): + raise ValueError(f"Directory not found: {path}") + if "not a directory" in error_msg.lower(): + raise ValueError(f"Not a directory: {path}") + raise ValueError(f"Failed to list directory: {error_msg}") +`, + }, + { + name: "sandbox_status", + description: "Get the status of the current agent's sandbox.", + source_code: `def sandbox_status() -> str: + """ + Get the status of the current agent's sandbox. + + Returns: + str: Sandbox status information + """ + import os + from e2b_code_interpreter import Sandbox + + agent_id = os.environ.get("LETTA_AGENT_ID") + if not agent_id: + raise ValueError("LETTA_AGENT_ID environment variable not set") + + agent = client.agents.retrieve(agent_id=agent_id) + + sandbox_id = None + if agent.metadata and isinstance(agent.metadata, dict): + sandbox_id = agent.metadata.get("sandbox_id") + + if not sandbox_id: + return "No sandbox associated with this agent. A new one will be created on the next sandbox operation." + + try: + sandbox = Sandbox.connect(sandbox_id, timeout=600) + return f"Sandbox ID: {sandbox_id}\\nStatus: Running" + except Exception as e: + return f"Sandbox ID: {sandbox_id}\\nStatus: Unavailable (may be paused or expired)\\nError: {str(e)}" +`, + }, + { + name: "sandbox_kill", + description: "Kill the current agent's sandbox permanently.", + source_code: `def sandbox_kill() -> str: + """ + Kill the current agent's sandbox permanently. + + This will permanently delete the sandbox. A new one will be created + on the next tool call that needs a sandbox. + + Returns: + str: A confirmation message + """ + import os + from e2b_code_interpreter import Sandbox + + agent_id = os.environ.get("LETTA_AGENT_ID") + if not agent_id: + raise ValueError("LETTA_AGENT_ID environment variable not set") + + agent = client.agents.retrieve(agent_id=agent_id) + + sandbox_id = None + if agent.metadata and isinstance(agent.metadata, dict): + sandbox_id = agent.metadata.get("sandbox_id") + + if not sandbox_id: + return "No sandbox found for this agent" + + try: + Sandbox.kill(sandbox_id) + except Exception: + pass + + current_metadata = agent.metadata or {} + if isinstance(current_metadata, dict) and "sandbox_id" in current_metadata: + del current_metadata["sandbox_id"] + client.agents.update(agent_id=agent_id, metadata=current_metadata) + + return f"Sandbox {sandbox_id} has been killed" +`, + }, +]; + +export const E2B_TOOL_NAMES = E2B_TOOLS.map((t) => t.name); diff --git a/src/sandbox/index.ts b/src/sandbox/index.ts new file mode 100644 index 000000000..3495627a9 --- /dev/null +++ b/src/sandbox/index.ts @@ -0,0 +1,214 @@ +/** + * Sandbox Management Module + * + * Provides functions to enable/disable remote sandbox providers (E2B, Daytona) + * for Letta Code agents. + */ + +import { getClient } from "../agent/client"; +import { + DAYTONA_PIP_PACKAGE, + DAYTONA_TOOL_NAMES, + DAYTONA_TOOLS, +} from "./daytona"; +import { E2B_PIP_PACKAGE, E2B_TOOL_NAMES, E2B_TOOLS } from "./e2b"; + +export type SandboxProvider = "e2b" | "daytona"; + +export const ALL_SANDBOX_TOOL_NAMES = new Set([ + ...E2B_TOOL_NAMES, + ...DAYTONA_TOOL_NAMES, +]); + +export interface SandboxStatus { + enabled: boolean; + provider: SandboxProvider | null; +} + +/** + * Check the current sandbox status for an agent. + * Detects if sandbox tools are attached based on tool names and metadata. + */ +export async function getSandboxStatus( + agentId: string, +): Promise { + const client = await getClient(); + const agent = await client.agents.retrieve(agentId, { + include: ["agent.tools"], + }); + + const toolNames = new Set((agent.tools || []).map((t) => t.name)); + + // Check if any sandbox tools are attached + const hasSandboxTools = + E2B_TOOL_NAMES.some((name) => toolNames.has(name)) || + DAYTONA_TOOL_NAMES.some((name) => toolNames.has(name)); + + if (!hasSandboxTools) { + return { enabled: false, provider: null }; + } + + // Determine provider from metadata + const metadata = agent.metadata as Record | null; + if (metadata?.sandbox_id) { + return { enabled: true, provider: "e2b" }; + } + if (metadata?.daytona_sandbox_id) { + return { enabled: true, provider: "daytona" }; + } + + // Has tools but no metadata - assume based on which tools are present + if (E2B_TOOL_NAMES.some((name) => toolNames.has(name))) { + return { enabled: true, provider: "e2b" }; + } + if (DAYTONA_TOOL_NAMES.some((name) => toolNames.has(name))) { + return { enabled: true, provider: "daytona" }; + } + + return { enabled: false, provider: null }; +} + +/** + * Enable a sandbox provider for an agent. + * Upserts the sandbox tools to the server and attaches them to the agent. + * + * @param agentId - The agent ID + * @param provider - The sandbox provider to enable + * @param apiKey - The API key for the provider + * @returns Number of tools attached + */ +export async function enableSandbox( + agentId: string, + provider: SandboxProvider, + apiKey: string, +): Promise { + const client = await getClient(); + + // Get tool definitions for provider + const { tools, pipPackage } = + provider === "e2b" + ? { tools: E2B_TOOLS, pipPackage: E2B_PIP_PACKAGE } + : { tools: DAYTONA_TOOLS, pipPackage: DAYTONA_PIP_PACKAGE }; + + // Upsert each tool to server (idempotent - safe to call multiple times) + for (const tool of tools) { + await client.tools.upsert({ + source_code: tool.source_code, + description: tool.description, + pip_requirements: [{ name: pipPackage }], + default_requires_approval: false, + tags: ["sandbox", provider], + }); + } + + // Get tool IDs after upsert + const toolIds = ( + await Promise.all( + tools.map(async (t) => { + const resp = await client.tools.list({ name: t.name }); + return resp.items[0]?.id; + }), + ) + ).filter((id): id is string => !!id); + + // Get current agent state with tools and secrets + const agent = await client.agents.retrieve(agentId, { + include: ["agent.tools", "agent.secrets"], + }); + const currentIds = (agent.tools || []) + .map((t) => t.id) + .filter((id): id is string => !!id); + + // Prepare secrets based on provider + // agent.secrets from retrieve is AgentEnvironmentVariable[] - convert to Record + const existingSecrets: Record = {}; + if (Array.isArray(agent.secrets)) { + for (const secret of agent.secrets) { + if (secret.key && secret.value) { + existingSecrets[secret.key] = secret.value; + } + } + } + const secretKey = provider === "e2b" ? "E2B_API_KEY" : "DAYTONA_API_KEY"; + const secrets: Record = { + ...existingSecrets, + [secretKey]: apiKey, + }; + + // For Daytona, also set API URL + if (provider === "daytona") { + secrets.DAYTONA_API_URL = "https://app.daytona.io/api"; + } + + // Update agent with tools and secrets + await client.agents.update(agentId, { + tool_ids: [...new Set([...currentIds, ...toolIds])], // Dedupe + secrets, + }); + + return toolIds.length; +} + +/** + * Disable sandbox for an agent. + * Removes all sandbox tools and clears related secrets. + */ +export async function disableSandbox(agentId: string): Promise { + const client = await getClient(); + const agent = await client.agents.retrieve(agentId, { + include: ["agent.tools", "agent.secrets"], + }); + + // Remove sandbox tools (both providers to be safe) + const remainingTools = (agent.tools || []) + .filter((t) => t.name && !ALL_SANDBOX_TOOL_NAMES.has(t.name)) + .map((t) => t.id) + .filter((id): id is string => !!id); + + const removedCount = (agent.tools?.length || 0) - remainingTools.length; + + // Clear sandbox secrets from the existing secrets array + // agent.secrets from retrieve is AgentEnvironmentVariable[] - convert to Record + const secrets: Record = {}; + if (Array.isArray(agent.secrets)) { + for (const secret of agent.secrets) { + // Skip sandbox-related secrets + if ( + secret.key && + secret.value && + !["E2B_API_KEY", "DAYTONA_API_KEY", "DAYTONA_API_URL"].includes( + secret.key, + ) + ) { + secrets[secret.key] = secret.value; + } + } + } + + await client.agents.update(agentId, { + tool_ids: remainingTools, + secrets, + }); + + return removedCount; +} + +/** + * Check if API key is available in environment for a provider. + */ +export function hasApiKeyInEnv(provider: SandboxProvider): boolean { + const envKey = provider === "e2b" ? "E2B_API_KEY" : "DAYTONA_API_KEY"; + return !!process.env[envKey]; +} + +/** + * Get API key from environment for a provider. + */ +export function getApiKeyFromEnv( + provider: SandboxProvider, +): string | undefined { + const envKey = provider === "e2b" ? "E2B_API_KEY" : "DAYTONA_API_KEY"; + return process.env[envKey]; +} + +export { E2B_TOOL_NAMES, DAYTONA_TOOL_NAMES };