048c511b18
- shipyard_neo: browser property now returns None when not initialized instead of raising RuntimeError, matching ComputerBooter base contract - computer_tool_provider: remove dead os.environ writes for shipyard (SHIPYARD_ENDPOINT / SHIPYARD_ACCESS_TOKEN are never read anywhere) and remove unused os import
609 lines
20 KiB
Python
609 lines
20 KiB
Python
from __future__ import annotations
|
|
|
|
import functools
|
|
import os
|
|
import shlex
|
|
from typing import TYPE_CHECKING, Any, cast
|
|
|
|
from astrbot.api import logger
|
|
|
|
if TYPE_CHECKING:
|
|
from astrbot.core.agent.tool import FunctionTool
|
|
|
|
from ..olayer import (
|
|
BrowserComponent,
|
|
FileSystemComponent,
|
|
PythonComponent,
|
|
ShellComponent,
|
|
)
|
|
from .base import ComputerBooter
|
|
|
|
|
|
def _maybe_model_dump(value: Any) -> dict[str, Any]:
|
|
if isinstance(value, dict):
|
|
return value
|
|
if hasattr(value, "model_dump"):
|
|
dumped = value.model_dump()
|
|
if isinstance(dumped, dict):
|
|
return dumped
|
|
return {}
|
|
|
|
|
|
class NeoPythonComponent(PythonComponent):
|
|
def __init__(self, sandbox: Any) -> None:
|
|
self._sandbox = sandbox
|
|
|
|
async def exec(
|
|
self,
|
|
code: str,
|
|
kernel_id: str | None = None,
|
|
timeout: int = 30,
|
|
silent: bool = False,
|
|
) -> dict[str, Any]:
|
|
_ = kernel_id # Bay runtime does not expose kernel_id in current SDK.
|
|
result = await self._sandbox.python.exec(code, timeout=timeout)
|
|
payload = _maybe_model_dump(result)
|
|
|
|
output_text = payload.get("output", "") or ""
|
|
error_text = payload.get("error", "") or ""
|
|
data = payload.get("data") if isinstance(payload.get("data"), dict) else {}
|
|
rich_output = data.get("output") if isinstance(data.get("output"), dict) else {}
|
|
if not isinstance(rich_output.get("images"), list):
|
|
rich_output["images"] = []
|
|
if "text" not in rich_output:
|
|
rich_output["text"] = output_text
|
|
|
|
if silent:
|
|
rich_output["text"] = ""
|
|
|
|
return {
|
|
"success": bool(payload.get("success", error_text == "")),
|
|
"data": {
|
|
"output": rich_output,
|
|
"error": error_text,
|
|
},
|
|
"execution_id": payload.get("execution_id"),
|
|
"execution_time_ms": payload.get("execution_time_ms"),
|
|
"code": payload.get("code"),
|
|
"output": output_text,
|
|
"error": error_text,
|
|
}
|
|
|
|
|
|
class NeoShellComponent(ShellComponent):
|
|
def __init__(self, sandbox: Any) -> None:
|
|
self._sandbox = sandbox
|
|
|
|
async def exec(
|
|
self,
|
|
command: str,
|
|
cwd: str | None = None,
|
|
env: dict[str, str] | None = None,
|
|
timeout: int | None = 30,
|
|
shell: bool = True,
|
|
background: bool = False,
|
|
) -> dict[str, Any]:
|
|
if not shell:
|
|
return {
|
|
"stdout": "",
|
|
"stderr": "error: only shell mode is supported in shipyard_neo booter.",
|
|
"exit_code": 2,
|
|
"success": False,
|
|
}
|
|
|
|
run_command = command
|
|
if env:
|
|
env_prefix = " ".join(
|
|
f"{k}={shlex.quote(str(v))}" for k, v in sorted(env.items())
|
|
)
|
|
run_command = f"{env_prefix} {run_command}"
|
|
|
|
if background:
|
|
run_command = f"nohup sh -lc {shlex.quote(run_command)} >/tmp/astrbot_bg.log 2>&1 & echo $!"
|
|
|
|
result = await self._sandbox.shell.exec(
|
|
run_command,
|
|
timeout=timeout or 30,
|
|
cwd=cwd,
|
|
)
|
|
payload = _maybe_model_dump(result)
|
|
|
|
stdout = payload.get("output", "") or ""
|
|
stderr = payload.get("error", "") or ""
|
|
exit_code = payload.get("exit_code")
|
|
if background:
|
|
pid: int | None = None
|
|
try:
|
|
pid = int(stdout.strip().splitlines()[-1])
|
|
except Exception:
|
|
pid = None
|
|
return {
|
|
"pid": pid,
|
|
"stdout": stdout,
|
|
"stderr": stderr,
|
|
"exit_code": exit_code,
|
|
"success": bool(payload.get("success", not stderr)),
|
|
"execution_id": payload.get("execution_id"),
|
|
"execution_time_ms": payload.get("execution_time_ms"),
|
|
"command": payload.get("command"),
|
|
}
|
|
|
|
return {
|
|
"stdout": stdout,
|
|
"stderr": stderr,
|
|
"exit_code": exit_code,
|
|
"success": bool(payload.get("success", not stderr)),
|
|
"execution_id": payload.get("execution_id"),
|
|
"execution_time_ms": payload.get("execution_time_ms"),
|
|
"command": payload.get("command"),
|
|
}
|
|
|
|
|
|
class NeoFileSystemComponent(FileSystemComponent):
|
|
def __init__(self, sandbox: Any) -> None:
|
|
self._sandbox = sandbox
|
|
|
|
async def create_file(
|
|
self,
|
|
path: str,
|
|
content: str = "",
|
|
mode: int = 0o644,
|
|
) -> dict[str, Any]:
|
|
_ = mode
|
|
await self._sandbox.filesystem.write_file(path, content)
|
|
return {"success": True, "path": path}
|
|
|
|
async def read_file(self, path: str, encoding: str = "utf-8") -> dict[str, Any]:
|
|
_ = encoding
|
|
content = await self._sandbox.filesystem.read_file(path)
|
|
return {"success": True, "path": path, "content": content}
|
|
|
|
async def write_file(
|
|
self,
|
|
path: str,
|
|
content: str,
|
|
mode: str = "w",
|
|
encoding: str = "utf-8",
|
|
) -> dict[str, Any]:
|
|
_ = mode
|
|
_ = encoding
|
|
await self._sandbox.filesystem.write_file(path, content)
|
|
return {"success": True, "path": path}
|
|
|
|
async def delete_file(self, path: str) -> dict[str, Any]:
|
|
await self._sandbox.filesystem.delete(path)
|
|
return {"success": True, "path": path}
|
|
|
|
async def list_dir(
|
|
self,
|
|
path: str = ".",
|
|
show_hidden: bool = False,
|
|
) -> dict[str, Any]:
|
|
entries = await self._sandbox.filesystem.list_dir(path)
|
|
data = []
|
|
for entry in entries:
|
|
item = _maybe_model_dump(entry)
|
|
if not show_hidden and str(item.get("name", "")).startswith("."):
|
|
continue
|
|
data.append(item)
|
|
return {"success": True, "path": path, "entries": data}
|
|
|
|
|
|
class NeoBrowserComponent(BrowserComponent):
|
|
def __init__(self, sandbox: Any) -> None:
|
|
self._sandbox = sandbox
|
|
|
|
async def exec(
|
|
self,
|
|
cmd: str,
|
|
timeout: int = 30,
|
|
description: str | None = None,
|
|
tags: str | None = None,
|
|
learn: bool = False,
|
|
include_trace: bool = False,
|
|
) -> dict[str, Any]:
|
|
result = await self._sandbox.browser.exec(
|
|
cmd,
|
|
timeout=timeout,
|
|
description=description,
|
|
tags=tags,
|
|
learn=learn,
|
|
include_trace=include_trace,
|
|
)
|
|
return _maybe_model_dump(result)
|
|
|
|
async def exec_batch(
|
|
self,
|
|
commands: list[str],
|
|
timeout: int = 60,
|
|
stop_on_error: bool = True,
|
|
description: str | None = None,
|
|
tags: str | None = None,
|
|
learn: bool = False,
|
|
include_trace: bool = False,
|
|
) -> dict[str, Any]:
|
|
result = await self._sandbox.browser.exec_batch(
|
|
commands,
|
|
timeout=timeout,
|
|
stop_on_error=stop_on_error,
|
|
description=description,
|
|
tags=tags,
|
|
learn=learn,
|
|
include_trace=include_trace,
|
|
)
|
|
return _maybe_model_dump(result)
|
|
|
|
async def run_skill(
|
|
self,
|
|
skill_key: str,
|
|
timeout: int = 60,
|
|
stop_on_error: bool = True,
|
|
include_trace: bool = False,
|
|
description: str | None = None,
|
|
tags: str | None = None,
|
|
) -> dict[str, Any]:
|
|
result = await self._sandbox.browser.run_skill(
|
|
skill_key=skill_key,
|
|
timeout=timeout,
|
|
stop_on_error=stop_on_error,
|
|
include_trace=include_trace,
|
|
description=description,
|
|
tags=tags,
|
|
)
|
|
return _maybe_model_dump(result)
|
|
|
|
|
|
class ShipyardNeoBooter(ComputerBooter):
|
|
"""Booter backed by Shipyard Neo (Bay).
|
|
|
|
If *endpoint_url* is empty or set to ``"__auto__"``, Bay will be
|
|
started automatically as a Docker container (like Boxlite does for
|
|
Ship containers).
|
|
"""
|
|
|
|
AUTO_SENTINEL = "__auto__"
|
|
DEFAULT_PROFILE = "python-default"
|
|
|
|
def __init__(
|
|
self,
|
|
endpoint_url: str,
|
|
access_token: str,
|
|
profile: str = DEFAULT_PROFILE,
|
|
ttl: int = 3600,
|
|
) -> None:
|
|
self._endpoint_url = endpoint_url
|
|
self._access_token = access_token
|
|
self._profile = profile
|
|
self._ttl = ttl
|
|
self._client: Any = None
|
|
self._sandbox: Any = None
|
|
self._bay_manager: Any = None # BayContainerManager when auto-started
|
|
self._fs: FileSystemComponent | None = None
|
|
self._python: PythonComponent | None = None
|
|
self._shell: ShellComponent | None = None
|
|
self._browser: BrowserComponent | None = None
|
|
|
|
@property
|
|
def bay_client(self) -> Any:
|
|
return self._client
|
|
|
|
@property
|
|
def sandbox(self) -> Any:
|
|
return self._sandbox
|
|
|
|
@property
|
|
def capabilities(self) -> tuple[str, ...] | None:
|
|
"""Sandbox capabilities from the Bay profile.
|
|
|
|
Returns an immutable tuple after :meth:`boot`; ``None`` before boot.
|
|
"""
|
|
if self._sandbox is None:
|
|
return None
|
|
caps = getattr(self._sandbox, "capabilities", None)
|
|
return tuple(caps) if caps is not None else None
|
|
|
|
@property
|
|
def is_auto_mode(self) -> bool:
|
|
"""True when Bay should be auto-started."""
|
|
ep = (self._endpoint_url or "").strip()
|
|
return not ep or ep == self.AUTO_SENTINEL
|
|
|
|
async def boot(self, session_id: str) -> None:
|
|
_ = session_id
|
|
|
|
# --- Auto-start Bay if needed ---
|
|
if self.is_auto_mode:
|
|
from .bay_manager import BayContainerManager
|
|
|
|
# Clean up previous manager if re-booting
|
|
if self._bay_manager is not None:
|
|
await self._bay_manager.close_client()
|
|
|
|
logger.info("[Computer] bay_autostart status=starting")
|
|
self._bay_manager = BayContainerManager()
|
|
self._endpoint_url = await self._bay_manager.ensure_running()
|
|
await self._bay_manager.wait_healthy()
|
|
# Read auto-provisioned credentials
|
|
if not self._access_token:
|
|
self._access_token = await self._bay_manager.read_credentials()
|
|
logger.info(
|
|
"[Computer] bay_autostart status=ready endpoint=%s",
|
|
self._endpoint_url,
|
|
)
|
|
|
|
if not self._endpoint_url or not self._access_token:
|
|
if self._bay_manager is not None:
|
|
raise ValueError(
|
|
"Bay container started but credentials could not be read. "
|
|
"Ensure Bay generated credentials.json, or set access_token manually."
|
|
)
|
|
raise ValueError(
|
|
"Shipyard Neo sandbox configuration is incomplete. "
|
|
"Set endpoint (default http://127.0.0.1:8114) and access token, "
|
|
"or ensure Bay's credentials.json is accessible for auto-discovery."
|
|
)
|
|
|
|
from shipyard_neo import BayClient
|
|
|
|
self._client = BayClient(
|
|
endpoint_url=self._endpoint_url,
|
|
access_token=self._access_token,
|
|
)
|
|
await self._client.__aenter__()
|
|
|
|
# Resolve profile: user-specified > smart selection > default
|
|
resolved_profile = await self._resolve_profile(self._client)
|
|
|
|
self._sandbox = await self._client.create_sandbox(
|
|
profile=resolved_profile,
|
|
ttl=self._ttl,
|
|
)
|
|
|
|
self._fs = NeoFileSystemComponent(self._sandbox)
|
|
self._python = NeoPythonComponent(self._sandbox)
|
|
self._shell = NeoShellComponent(self._sandbox)
|
|
|
|
caps = self.capabilities or ()
|
|
self._browser = (
|
|
NeoBrowserComponent(self._sandbox) if "browser" in caps else None
|
|
)
|
|
|
|
logger.info(
|
|
"[Computer] sandbox_created booter=shipyard_neo sandbox_id=%s profile=%s capabilities=%s auto=%s",
|
|
self._sandbox.id,
|
|
resolved_profile,
|
|
list(caps),
|
|
bool(self._bay_manager),
|
|
)
|
|
|
|
async def _resolve_profile(self, client: Any) -> str:
|
|
"""Pick the best profile for this session.
|
|
|
|
Resolution order:
|
|
1. User-specified profile (non-empty, non-default) → use as-is.
|
|
2. Query ``GET /v1/profiles`` and pick the profile with the most
|
|
capabilities, preferring profiles that include ``"browser"``.
|
|
3. Fall back to :attr:`DEFAULT_PROFILE`.
|
|
|
|
Auth errors (401/403) are re-raised immediately — they indicate a
|
|
misconfigured token, and silently falling back would just delay the
|
|
real failure to ``create_sandbox``.
|
|
"""
|
|
# User explicitly set a profile → honour it
|
|
if self._profile and self._profile != self.DEFAULT_PROFILE:
|
|
logger.info(
|
|
"[Computer] profile_selected mode=user profile=%s",
|
|
self._profile,
|
|
)
|
|
return self._profile
|
|
|
|
# Query Bay for available profiles
|
|
from shipyard_neo.errors import ForbiddenError, UnauthorizedError
|
|
|
|
try:
|
|
profile_list = await client.list_profiles()
|
|
profiles = profile_list.items
|
|
except (UnauthorizedError, ForbiddenError):
|
|
raise # auth errors must not be silenced
|
|
except Exception as exc:
|
|
logger.warning(
|
|
"[Computer] profile_selection_fallback reason=query_failed fallback=%s error=%s",
|
|
self.DEFAULT_PROFILE,
|
|
exc,
|
|
)
|
|
return self.DEFAULT_PROFILE
|
|
|
|
if not profiles:
|
|
return self.DEFAULT_PROFILE
|
|
|
|
def _score(p: Any) -> tuple[int, int]:
|
|
"""(has_browser, capability_count) — higher is better."""
|
|
caps = getattr(p, "capabilities", []) or []
|
|
return (1 if "browser" in caps else 0, len(caps))
|
|
|
|
best = max(profiles, key=_score)
|
|
chosen = getattr(best, "id", self.DEFAULT_PROFILE)
|
|
|
|
if chosen != self.DEFAULT_PROFILE:
|
|
caps = getattr(best, "capabilities", [])
|
|
logger.info(
|
|
"[Computer] profile_selected mode=auto profile=%s capabilities=%s",
|
|
chosen,
|
|
caps,
|
|
)
|
|
|
|
return chosen
|
|
|
|
async def shutdown(self) -> None:
|
|
if self._client is not None:
|
|
sandbox_id = getattr(self._sandbox, "id", "unknown")
|
|
logger.info(
|
|
"[Computer] booter_shutdown booter=shipyard_neo sandbox_id=%s status=starting",
|
|
sandbox_id,
|
|
)
|
|
await self._client.__aexit__(None, None, None)
|
|
self._client = None
|
|
self._sandbox = None
|
|
logger.info(
|
|
"[Computer] booter_shutdown booter=shipyard_neo sandbox_id=%s status=done",
|
|
sandbox_id,
|
|
)
|
|
|
|
# NOTE: We intentionally do NOT stop the Bay container here.
|
|
# It stays running for reuse by future sessions. The user can
|
|
# stop it manually or via ``BayContainerManager.stop()``.
|
|
if self._bay_manager is not None:
|
|
await self._bay_manager.close_client()
|
|
|
|
@property
|
|
def fs(self) -> FileSystemComponent:
|
|
if self._fs is None:
|
|
raise RuntimeError("ShipyardNeoBooter is not initialized.")
|
|
return self._fs
|
|
|
|
@property
|
|
def python(self) -> PythonComponent:
|
|
if self._python is None:
|
|
raise RuntimeError("ShipyardNeoBooter is not initialized.")
|
|
return self._python
|
|
|
|
@property
|
|
def shell(self) -> ShellComponent:
|
|
if self._shell is None:
|
|
raise RuntimeError("ShipyardNeoBooter is not initialized.")
|
|
return self._shell
|
|
|
|
@property
|
|
def browser(self) -> BrowserComponent | None:
|
|
return self._browser
|
|
|
|
async def upload_file(self, path: str, file_name: str) -> dict:
|
|
if self._sandbox is None:
|
|
raise RuntimeError("ShipyardNeoBooter is not initialized.")
|
|
with open(path, "rb") as f:
|
|
content = f.read()
|
|
remote_path = file_name.lstrip("/")
|
|
await self._sandbox.filesystem.upload(remote_path, content)
|
|
logger.info(
|
|
"[Computer] file_upload booter=shipyard_neo remote_path=%s",
|
|
remote_path,
|
|
)
|
|
return {
|
|
"success": True,
|
|
"message": "File uploaded successfully",
|
|
"file_path": remote_path,
|
|
}
|
|
|
|
async def download_file(self, remote_path: str, local_path: str) -> None:
|
|
if self._sandbox is None:
|
|
raise RuntimeError("ShipyardNeoBooter is not initialized.")
|
|
content = await self._sandbox.filesystem.download(remote_path.lstrip("/"))
|
|
local_dir = os.path.dirname(local_path)
|
|
if local_dir:
|
|
os.makedirs(local_dir, exist_ok=True)
|
|
with open(local_path, "wb") as f:
|
|
f.write(cast(bytes, content))
|
|
logger.info(
|
|
"[Computer] file_download booter=shipyard_neo remote_path=%s local_path=%s",
|
|
remote_path,
|
|
local_path,
|
|
)
|
|
|
|
async def available(self) -> bool:
|
|
if self._sandbox is None:
|
|
return False
|
|
try:
|
|
await self._sandbox.refresh()
|
|
status = getattr(self._sandbox.status, "value", str(self._sandbox.status))
|
|
healthy = status not in {"failed", "expired"}
|
|
logger.debug(
|
|
"[Computer] health_check booter=shipyard_neo sandbox_id=%s status=%s healthy=%s",
|
|
getattr(self._sandbox, "id", "unknown"),
|
|
status,
|
|
healthy,
|
|
)
|
|
return healthy
|
|
except Exception:
|
|
logger.exception(
|
|
"[Computer] health_check_failed booter=shipyard_neo sandbox_id=%s",
|
|
getattr(self._sandbox, "id", "unknown"),
|
|
)
|
|
return False
|
|
|
|
# ── Tool / prompt self-description ────────────────────────────
|
|
|
|
@classmethod
|
|
@functools.cache
|
|
def _base_tools(cls) -> tuple[FunctionTool, ...]:
|
|
"""4 base + 11 Neo lifecycle = 15 tools (all Neo profiles)."""
|
|
from astrbot.core.computer.tools import (
|
|
AnnotateExecutionTool,
|
|
CreateSkillCandidateTool,
|
|
CreateSkillPayloadTool,
|
|
EvaluateSkillCandidateTool,
|
|
ExecuteShellTool,
|
|
FileDownloadTool,
|
|
FileUploadTool,
|
|
GetExecutionHistoryTool,
|
|
GetSkillPayloadTool,
|
|
ListSkillCandidatesTool,
|
|
ListSkillReleasesTool,
|
|
PromoteSkillCandidateTool,
|
|
PythonTool,
|
|
RollbackSkillReleaseTool,
|
|
SyncSkillReleaseTool,
|
|
)
|
|
|
|
return (
|
|
ExecuteShellTool(),
|
|
PythonTool(),
|
|
FileUploadTool(),
|
|
FileDownloadTool(),
|
|
GetExecutionHistoryTool(),
|
|
AnnotateExecutionTool(),
|
|
CreateSkillPayloadTool(),
|
|
GetSkillPayloadTool(),
|
|
CreateSkillCandidateTool(),
|
|
ListSkillCandidatesTool(),
|
|
EvaluateSkillCandidateTool(),
|
|
PromoteSkillCandidateTool(),
|
|
ListSkillReleasesTool(),
|
|
RollbackSkillReleaseTool(),
|
|
SyncSkillReleaseTool(),
|
|
)
|
|
|
|
@classmethod
|
|
@functools.cache
|
|
def _browser_tools(cls) -> tuple[FunctionTool, ...]:
|
|
from astrbot.core.computer.tools import (
|
|
BrowserBatchExecTool,
|
|
BrowserExecTool,
|
|
RunBrowserSkillTool,
|
|
)
|
|
|
|
return (BrowserExecTool(), BrowserBatchExecTool(), RunBrowserSkillTool())
|
|
|
|
@classmethod
|
|
def get_default_tools(cls) -> list[FunctionTool]:
|
|
"""Pre-boot: conservative full list (including browser)."""
|
|
return list(cls._base_tools()) + list(cls._browser_tools())
|
|
|
|
def get_tools(self) -> list[FunctionTool]:
|
|
"""Post-boot: capability-filtered list."""
|
|
caps = self.capabilities
|
|
if caps is None:
|
|
return self.__class__.get_default_tools()
|
|
tools = list(self._base_tools())
|
|
if "browser" in caps:
|
|
tools.extend(self._browser_tools())
|
|
return tools
|
|
|
|
@classmethod
|
|
def get_system_prompt_parts(cls) -> list[str]:
|
|
from astrbot.core.computer.prompts import (
|
|
NEO_FILE_PATH_PROMPT,
|
|
NEO_SKILL_LIFECYCLE_PROMPT,
|
|
)
|
|
|
|
return [NEO_FILE_PATH_PROMPT, NEO_SKILL_LIFECYCLE_PROMPT]
|