feat: 完善backends实现,对接agent-sandbox api
This commit is contained in:
parent
8b6229d230
commit
ebec385d4f
@ -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:
|
||||
|
||||
Loading…
Reference in New Issue
Block a user