From ebec385d4f806c448811b8ce65ccd7ebbb6226b3 Mon Sep 17 00:00:00 2001 From: supreme0597 Date: Sun, 22 Mar 2026 23:37:25 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E5=96=84backends=E5=AE=9E?= =?UTF-8?q?=E7=8E=B0=EF=BC=8C=E5=AF=B9=E6=8E=A5agent-sandbox=20api?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/sandbox/backend.py | 274 +++++++++++++++++++++++++++++++---------- 1 file changed, 206 insertions(+), 68 deletions(-) diff --git a/src/sandbox/backend.py b/src/sandbox/backend.py index 97fda765..b1d881ce 100644 --- a/src/sandbox/backend.py +++ b/src/sandbox/backend.py @@ -1,10 +1,19 @@ from __future__ import annotations import base64 +from datetime import datetime from pathlib import PurePosixPath from typing import Any -from deepagents.backends.protocol import ExecuteResponse, FileDownloadResponse, FileUploadResponse +from deepagents.backends.protocol import ( + EditResult, + ExecuteResponse, + FileDownloadResponse, + FileInfo, + FileUploadResponse, + GrepMatch, + WriteResult, +) from deepagents.backends.sandbox import BaseSandbox from src import config as conf @@ -51,7 +60,7 @@ class ProvisionerSandboxBackend(BaseSandbox): return AgentSandboxClient(base_url=sandbox_url, timeout=self._command_timeout_seconds) - def _get_client(self): + def _get_client(self) -> Any: connection = self._provider.get(self._thread_id, create_if_missing=True) if connection is None: raise RuntimeError(f"sandbox is unavailable for thread {self._thread_id}") @@ -62,62 +71,16 @@ class ProvisionerSandboxBackend(BaseSandbox): return self._client - @staticmethod - def _extract_data(payload: Any) -> Any: - return getattr(payload, "data", payload) - - def _shell_exec(self, command: str): - client = self._get_client() - shell = getattr(client, "shell", None) - if shell is not None: - if hasattr(shell, "exec_command"): - return shell.exec_command(command=command) - if hasattr(shell, "exec"): - return shell.exec(command=command) - if hasattr(client, "exec_command"): - return client.exec_command(command=command) - raise RuntimeError("sandbox client does not provide shell execution API") - - def _write_binary(self, path: str, content: bytes) -> None: - client = self._get_client() - file_api = getattr(client, "file", None) - if file_api is None: - raise RuntimeError("sandbox client does not provide file API") - - encoded = base64.b64encode(content).decode("ascii") - if hasattr(file_api, "write_file"): - try: - file_api.write_file(file=path, content=encoded, encoding="base64") - return - except TypeError: - file_api.write_file(file=path, content=content.decode("utf-8", errors="replace")) - return - if hasattr(file_api, "write"): - file_api.write(path=path, content=encoded, encoding="base64") - return - raise RuntimeError("sandbox file API does not provide write method") - def _read_binary(self, path: str) -> bytes: - client = self._get_client() - file_api = getattr(client, "file", None) - if file_api is None: - raise RuntimeError("sandbox client does not provide file API") + """Read file content from the sandbox file API and normalize it to bytes. - result: Any - if hasattr(file_api, "read_file"): - try: - result = file_api.read_file(file=path, encoding="base64") - except TypeError: - result = file_api.read_file(file=path) - elif hasattr(file_api, "read"): - result = file_api.read(path=path, encoding="base64") - else: - raise RuntimeError("sandbox file API does not provide read method") + The underlying API may return base64 text, raw bytes, or plain strings. + This helper is the single normalization point used by read(), edit(), and + download_files() so all read paths share the same transport semantics. + """ + result = self._get_client().file.read_file(file=path, encoding="base64") - data = self._extract_data(result) - content = getattr(data, "content", None) - if content is None and isinstance(data, dict): - content = data.get("content") + content = result.content if content is None: return b"" if isinstance(content, bytes): @@ -136,7 +99,11 @@ class ProvisionerSandboxBackend(BaseSandbox): offset: int = 0, limit: int = 2000, ) -> str: - """Read file content directly via file API to avoid shell-output false positives.""" + """Read file content via the sandbox file API and render a text view. + + This stays on top of _read_binary() so the backend has one consistent + read path for base64 transport, raw bytes, and text-like responses. + """ normalized_path = _normalize_path(file_path) start = max(0, int(offset)) size = max(0, int(limit)) @@ -158,25 +125,33 @@ class ProvisionerSandboxBackend(BaseSandbox): return "\n".join(f"{start + idx + 1:6d}\t{line}" for idx, line in enumerate(selected_lines)) - def execute(self, command: str) -> ExecuteResponse: + def execute(self, command: str, *, timeout: int | None = None) -> ExecuteResponse: + """Execute a shell command in the sandbox. + + Output is normalized to text and truncated to the configured maximum + payload size before being returned. + """ try: - result = self._shell_exec(command) - data = self._extract_data(result) - output = getattr(data, "output", None) - if output is None and isinstance(data, dict): - output = data.get("output") + kwargs: dict[str, Any] = {"command": command} + if timeout is not None: + kwargs["timeout"] = timeout + result = self._get_client().shell.exec_command(**kwargs) + + output = getattr(result, "output", None) + if output is None and isinstance(result, dict): + output = result.get("output") if output is None: - output = str(data) if data is not None else "" + output = str(result) if result is not None else "" if not isinstance(output, str): output = str(output) - exit_code = getattr(data, "exit_code", None) - if exit_code is None and isinstance(data, dict): - exit_code = data.get("exit_code") + exit_code = getattr(result, "exit_code", None) + if exit_code is None and isinstance(result, dict): + exit_code = result.get("exit_code") if isinstance(exit_code, str) and exit_code.isdigit(): exit_code = int(exit_code) - truncated = bool(getattr(data, "truncated", False)) + truncated = False encoded = output.encode("utf-8", errors="ignore") if len(encoded) > self._max_output_bytes: output = encoded[: self._max_output_bytes].decode("utf-8", errors="ignore") @@ -191,12 +166,170 @@ class ProvisionerSandboxBackend(BaseSandbox): logger.error(f"Sandbox execute failed for thread {self._thread_id}: {exc}") return ExecuteResponse(output=f"Error: {exc}", exit_code=1, truncated=False) + def ls_info(self, path: str) -> list[FileInfo]: + """List direct children of a sandbox path with lightweight metadata.""" + normalized_path = _normalize_path(path) + try: + result = self._get_client().file.list_path(path=normalized_path, recursive=False, include_size=True) + except Exception: # noqa: BLE001 + return [] + + entries = result.files or [] + infos: list[FileInfo] = [] + for entry in entries: + info: FileInfo = {"path": entry.path, "is_dir": entry.is_directory} + size = entry.size + if isinstance(size, int): + info["size"] = size + modified_time = entry.modified_time + if isinstance(modified_time, str) and modified_time: + try: + info["modified_at"] = datetime.fromisoformat(modified_time).isoformat() + except ValueError: + info["modified_at"] = modified_time + infos.append(info) + return infos + + def write(self, file_path: str, content: str) -> WriteResult: + """Write a new text file. + + This method is intentionally text-only. Binary payloads should go through + upload_files(), which uses base64 encoding for the sandbox file API. + """ + normalized_path = _normalize_path(file_path) + if not isinstance(content, str): + return WriteResult(error="Error: write() only supports text content; use upload_files() for binary data") + try: + self._read_binary(normalized_path) + except Exception: # noqa: BLE001 + pass + else: + return WriteResult(error=f"Error: File '{file_path}' already exists") + + try: + self._get_client().file.write_file(file=normalized_path, content=content) + except Exception as exc: # noqa: BLE001 + return WriteResult(error=str(exc) or f"Failed to write file '{file_path}'") + + return WriteResult(path=normalized_path, files_update=None) + + def edit( + self, + file_path: str, + old_string: str, + new_string: str, + replace_all: bool = False, # noqa: FBT001, FBT002 + ) -> EditResult: + """Edit an existing text file by replacing string content. + + This method operates on UTF-8-decoded text content only. Binary files + are not supported here and should be handled via download/upload flows. + """ + normalized_path = _normalize_path(file_path) + try: + text = self._read_binary(normalized_path).decode("utf-8", errors="replace") + except Exception: # noqa: BLE001 + return EditResult(error=f"Error: File '{file_path}' not found") + + count = text.count(old_string) + if count == 0: + return EditResult(error=f"Error: String not found in file: '{old_string}'") + if count > 1 and not replace_all: + return EditResult( + error=( + f"Error: String '{old_string}' appears multiple times. " + "Use replace_all=True to replace all occurrences." + ) + ) + + updated = text.replace(old_string, new_string) if replace_all else text.replace(old_string, new_string, 1) + + try: + self._get_client().file.write_file(file=normalized_path, content=updated) + except Exception as exc: # noqa: BLE001 + return EditResult(error=f"Error editing file (exit code 1): {exc or 'Unknown error'}") + + return EditResult(path=normalized_path, files_update=None, occurrences=count if replace_all else 1) + + def grep_raw( + self, + pattern: str, + path: str | None = None, + glob: str | None = None, + ) -> list[GrepMatch] | str: + """Search file contents under a path and return raw line matches. + + The sandbox file API is used directly with fixed-string matching and an + optional include glob. + """ + search_path = _normalize_path(path or "/") + include = [glob] if glob else None + + try: + result = self._get_client().file.grep_files( + path=search_path, + pattern=pattern, + include=include, + fixed_strings=True, + recursive=True, + ) + except Exception as exc: # noqa: BLE001 + return str(exc) + + matches_out: list[GrepMatch] = [] + for match in result.matches or []: + matches_out.append( + { + "path": match.file, + "line": match.line_number, + "text": match.line_content, + } + ) + return matches_out + + def glob_info(self, pattern: str, path: str = "/") -> list[FileInfo]: + """Return files matching a glob pattern with optional metadata.""" + normalized_path = _normalize_path(path) + + try: + result = self._get_client().file.glob_files( + path=normalized_path, + pattern=pattern, + include_metadata=True, + ) + except Exception: # noqa: BLE001 + return [] + + infos: list[FileInfo] = [] + for entry in result.files or []: + info: FileInfo = {"path": entry.path} + if isinstance(entry.is_directory, bool): + info["is_dir"] = entry.is_directory + if isinstance(entry.size, int): + info["size"] = entry.size + if isinstance(entry.modified_time, str) and entry.modified_time: + try: + info["modified_at"] = datetime.fromisoformat(entry.modified_time).isoformat() + except ValueError: + info["modified_at"] = entry.modified_time + infos.append(info) + return infos + def upload_files(self, files: list[tuple[str, bytes]]) -> list[FileUploadResponse]: + """Upload binary or text file payloads via the sandbox file API. + + Contents are base64-encoded before calling the remote write_file API so + arbitrary bytes can be transferred safely. + """ responses: list[FileUploadResponse] = [] for path, content in files: try: normalized_path = _normalize_path(path) - self._write_binary(normalized_path, content) + self._get_client().file.write_file( + file=normalized_path, + content=base64.b64encode(content).decode("ascii"), + encoding="base64", + ) responses.append(FileUploadResponse(path=normalized_path, error=None)) except PermissionError: normalized_path = str(path) @@ -214,6 +347,11 @@ class ProvisionerSandboxBackend(BaseSandbox): return responses def download_files(self, paths: list[str]) -> list[FileDownloadResponse]: + """Download file payloads as raw bytes from the sandbox file API. + + The underlying API is read with base64 encoding and decoded back into + bytes by _read_binary(). + """ responses: list[FileDownloadResponse] = [] for path in paths: try: