feat: 完善backends实现,对接agent-sandbox api

This commit is contained in:
supreme0597 2026-03-22 23:37:25 +08:00
parent 8b6229d230
commit ebec385d4f

View File

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