diff --git a/.gitignore b/.gitignore index ef33bfa57e..e3ffbd4739 100644 --- a/.gitignore +++ b/.gitignore @@ -54,7 +54,3 @@ IFLOW.md # genie_tts data CharacterModels/ GenieData/ -.agent/ -.codex/ -.opencode/ -.kilocode/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bfdf904e03..47404d5631 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -46,32 +46,6 @@ ruff check . 如果您使用 VSCode,可以安装 `Ruff` 插件。 -##### PR 功能完整性验证(推荐) - -如果您希望在本地做一套接近 CI 的完整验证,可使用: - -```bash -make pr-test-neo -``` - -该命令会执行: -- `uv sync --group dev` -- `ruff format --check .` 与 `ruff check .` -- Neo 相关关键测试 -- `main.py` 启动 smoke test(检测 `http://localhost:6185`) - -需要全量验证时可使用: - -```bash -make pr-test-full -``` - -如果只想快速重复执行(跳过依赖同步和 dashboard 构建): - -```bash -make pr-test-full-fast -``` - ## Contributing Guide @@ -114,29 +88,3 @@ We use Ruff as our code formatter and static analysis tool. Before submitting yo ruff format . ruff check . ``` - -##### PR completeness checks (recommended) - -To run a local validation flow close to CI, use: - -```bash -make pr-test-neo -``` - -This command runs: -- `uv sync --group dev` -- `ruff format --check .` and `ruff check .` -- Neo-related critical tests -- a startup smoke test against `http://localhost:6185` - -For full validation, use: - -```bash -make pr-test-full -``` - -For faster repeated runs (skip dependency sync and dashboard build), use: - -```bash -make pr-test-full-fast -``` diff --git a/Makefile b/Makefile index 1a981e537e..d8fdb04baf 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: worktree worktree-add worktree-rm pr-test-neo pr-test-full pr-test-full-fast +.PHONY: worktree worktree-add worktree-rm WORKTREE_DIR ?= ../astrbot_worktree BRANCH ?= $(word 2,$(MAKECMDGOALS)) @@ -27,15 +27,6 @@ endif echo "Worktree $(WORKTREE_DIR)/$(BRANCH) not found."; \ fi -pr-test-neo: - ./scripts/pr_test_env.sh --profile neo - -pr-test-full: - ./scripts/pr_test_env.sh --profile full - -pr-test-full-fast: - ./scripts/pr_test_env.sh --profile full --skip-sync --no-dashboard - # Swallow extra args (branch/base) so make doesn't treat them as targets %: @true diff --git a/astrbot/core/astr_main_agent.py b/astrbot/core/astr_main_agent.py index 0f51a29c05..8934fd104d 100644 --- a/astrbot/core/astr_main_agent.py +++ b/astrbot/core/astr_main_agent.py @@ -20,32 +20,18 @@ from astrbot.core.astr_agent_run_util import AgentRunner from astrbot.core.astr_agent_tool_exec import FunctionToolExecutor from astrbot.core.astr_main_agent_resources import ( - ANNOTATE_EXECUTION_TOOL, - BROWSER_BATCH_EXEC_TOOL, - BROWSER_EXEC_TOOL, CHATUI_SPECIAL_DEFAULT_PERSONA_PROMPT, - CREATE_SKILL_CANDIDATE_TOOL, - CREATE_SKILL_PAYLOAD_TOOL, - EVALUATE_SKILL_CANDIDATE_TOOL, EXECUTE_SHELL_TOOL, FILE_DOWNLOAD_TOOL, FILE_UPLOAD_TOOL, - GET_EXECUTION_HISTORY_TOOL, - GET_SKILL_PAYLOAD_TOOL, KNOWLEDGE_BASE_QUERY_TOOL, - LIST_SKILL_CANDIDATES_TOOL, - LIST_SKILL_RELEASES_TOOL, LIVE_MODE_SYSTEM_PROMPT, LLM_SAFETY_MODE_SYSTEM_PROMPT, LOCAL_EXECUTE_SHELL_TOOL, LOCAL_PYTHON_TOOL, - PROMOTE_SKILL_CANDIDATE_TOOL, PYTHON_TOOL, - ROLLBACK_SKILL_RELEASE_TOOL, - RUN_BROWSER_SKILL_TOOL, SANDBOX_MODE_PROMPT, SEND_MESSAGE_TO_USER_TOOL, - SYNC_SKILL_RELEASE_TOOL, TOOL_CALL_PROMPT, TOOL_CALL_PROMPT_SKILLS_LIKE_MODE, retrieve_knowledge_base, @@ -846,8 +832,7 @@ def _apply_sandbox_tools( ) -> None: if req.func_tool is None: req.func_tool = ToolSet() - booter = config.sandbox_cfg.get("booter", "shipyard_neo") - if booter == "shipyard": + if config.sandbox_cfg.get("booter") == "shipyard": ep = config.sandbox_cfg.get("shipyard_endpoint", "") at = config.sandbox_cfg.get("shipyard_access_token", "") if not ep or not at: @@ -855,64 +840,11 @@ def _apply_sandbox_tools( return os.environ["SHIPYARD_ENDPOINT"] = ep os.environ["SHIPYARD_ACCESS_TOKEN"] = at - req.func_tool.add_tool(EXECUTE_SHELL_TOOL) req.func_tool.add_tool(PYTHON_TOOL) req.func_tool.add_tool(FILE_UPLOAD_TOOL) req.func_tool.add_tool(FILE_DOWNLOAD_TOOL) - if booter == "shipyard_neo": - # Neo-specific path rule: filesystem tools operate relative to sandbox - # workspace root. Do not prepend "/workspace". - req.system_prompt += ( - "\n[Shipyard Neo File Path Rule]\n" - "When using sandbox filesystem tools (upload/download/read/write/list/delete), " - "always pass paths relative to the sandbox workspace root. " - "Example: use `baidu_homepage.png` instead of `/workspace/baidu_homepage.png`.\n" - ) - - req.system_prompt += ( - "\n[Neo Skill Lifecycle Workflow]\n" - "When user asks to create/update a reusable skill in Neo mode, use lifecycle tools instead of directly writing local skill folders.\n" - "Preferred sequence:\n" - "1) Use `astrbot_create_skill_payload` to store canonical payload content and get `payload_ref`.\n" - "2) Use `astrbot_create_skill_candidate` with `skill_key` + `source_execution_ids` (and optional `payload_ref`) to create a candidate.\n" - "3) Use `astrbot_promote_skill_candidate` to release: `stage=canary` for trial; `stage=stable` for production.\n" - "For stable release, set `sync_to_local=true` to sync `payload.skill_markdown` into local `SKILL.md`.\n" - "Do not treat ad-hoc generated files as reusable Neo skills unless they are captured via payload/candidate/release.\n" - "To update an existing skill, create a new payload/candidate and promote a new release version; avoid patching old local folders directly.\n" - ) - - # Determine sandbox capabilities from an already-booted session. - # If no session exists yet (first request), capabilities is None - # and we register all tools conservatively. - from astrbot.core.computer.computer_client import session_booter - - sandbox_capabilities: list[str] | None = None - existing_booter = session_booter.get(session_id) - if existing_booter is not None: - sandbox_capabilities = getattr(existing_booter, "capabilities", None) - - # Browser tools: only register if profile supports browser - # (or if capabilities are unknown because sandbox hasn't booted yet) - if sandbox_capabilities is None or "browser" in sandbox_capabilities: - req.func_tool.add_tool(BROWSER_EXEC_TOOL) - req.func_tool.add_tool(BROWSER_BATCH_EXEC_TOOL) - req.func_tool.add_tool(RUN_BROWSER_SKILL_TOOL) - - # Neo-specific tools (always available for shipyard_neo) - req.func_tool.add_tool(GET_EXECUTION_HISTORY_TOOL) - req.func_tool.add_tool(ANNOTATE_EXECUTION_TOOL) - req.func_tool.add_tool(CREATE_SKILL_PAYLOAD_TOOL) - req.func_tool.add_tool(GET_SKILL_PAYLOAD_TOOL) - req.func_tool.add_tool(CREATE_SKILL_CANDIDATE_TOOL) - req.func_tool.add_tool(LIST_SKILL_CANDIDATES_TOOL) - req.func_tool.add_tool(EVALUATE_SKILL_CANDIDATE_TOOL) - req.func_tool.add_tool(PROMOTE_SKILL_CANDIDATE_TOOL) - req.func_tool.add_tool(LIST_SKILL_RELEASES_TOOL) - req.func_tool.add_tool(ROLLBACK_SKILL_RELEASE_TOOL) - req.func_tool.add_tool(SYNC_SKILL_RELEASE_TOOL) - - req.system_prompt = f"{req.system_prompt or ''}\n{SANDBOX_MODE_PROMPT}\n" + req.system_prompt = f"{req.system_prompt}\n{SANDBOX_MODE_PROMPT}\n" def _proactive_cron_job_tools(req: ProviderRequest) -> None: diff --git a/astrbot/core/astr_main_agent_resources.py b/astrbot/core/astr_main_agent_resources.py index 2e0d8b0aa7..634647e7a7 100644 --- a/astrbot/core/astr_main_agent_resources.py +++ b/astrbot/core/astr_main_agent_resources.py @@ -13,25 +13,11 @@ from astrbot.core.astr_agent_context import AstrAgentContext from astrbot.core.computer.computer_client import get_booter from astrbot.core.computer.tools import ( - AnnotateExecutionTool, - BrowserBatchExecTool, - BrowserExecTool, - CreateSkillCandidateTool, - CreateSkillPayloadTool, - EvaluateSkillCandidateTool, ExecuteShellTool, FileDownloadTool, FileUploadTool, - GetExecutionHistoryTool, - GetSkillPayloadTool, - ListSkillCandidatesTool, - ListSkillReleasesTool, LocalPythonTool, - PromoteSkillCandidateTool, PythonTool, - RollbackSkillReleaseTool, - RunBrowserSkillTool, - SyncSkillReleaseTool, ) from astrbot.core.message.message_event_result import MessageChain from astrbot.core.platform.message_session import MessageSession @@ -463,20 +449,6 @@ async def retrieve_knowledge_base( LOCAL_PYTHON_TOOL = LocalPythonTool() FILE_UPLOAD_TOOL = FileUploadTool() FILE_DOWNLOAD_TOOL = FileDownloadTool() -BROWSER_EXEC_TOOL = BrowserExecTool() -BROWSER_BATCH_EXEC_TOOL = BrowserBatchExecTool() -RUN_BROWSER_SKILL_TOOL = RunBrowserSkillTool() -GET_EXECUTION_HISTORY_TOOL = GetExecutionHistoryTool() -ANNOTATE_EXECUTION_TOOL = AnnotateExecutionTool() -CREATE_SKILL_PAYLOAD_TOOL = CreateSkillPayloadTool() -GET_SKILL_PAYLOAD_TOOL = GetSkillPayloadTool() -CREATE_SKILL_CANDIDATE_TOOL = CreateSkillCandidateTool() -LIST_SKILL_CANDIDATES_TOOL = ListSkillCandidatesTool() -EVALUATE_SKILL_CANDIDATE_TOOL = EvaluateSkillCandidateTool() -PROMOTE_SKILL_CANDIDATE_TOOL = PromoteSkillCandidateTool() -LIST_SKILL_RELEASES_TOOL = ListSkillReleasesTool() -ROLLBACK_SKILL_RELEASE_TOOL = RollbackSkillReleaseTool() -SYNC_SKILL_RELEASE_TOOL = SyncSkillReleaseTool() # we prevent astrbot from connecting to known malicious hosts # these hosts are base64 encoded diff --git a/astrbot/core/computer/booters/base.py b/astrbot/core/computer/booters/base.py index 4c74e5edd6..ea93a3d6d9 100644 --- a/astrbot/core/computer/booters/base.py +++ b/astrbot/core/computer/booters/base.py @@ -1,9 +1,4 @@ -from ..olayer import ( - BrowserComponent, - FileSystemComponent, - PythonComponent, - ShellComponent, -) +from ..olayer import FileSystemComponent, PythonComponent, ShellComponent class ComputerBooter: @@ -16,19 +11,6 @@ def python(self) -> PythonComponent: ... @property def shell(self) -> ShellComponent: ... - @property - def capabilities(self) -> tuple[str, ...] | None: - """Sandbox capabilities (e.g. ('python', 'shell', 'filesystem', 'browser')). - - Returns None if the booter doesn't support capability introspection - (backward-compatible default). Subclasses override after boot. - """ - return None - - @property - def browser(self) -> BrowserComponent | None: - return None - async def boot(self, session_id: str) -> None: ... async def shutdown(self) -> None: ... diff --git a/astrbot/core/computer/booters/bay_manager.py b/astrbot/core/computer/booters/bay_manager.py deleted file mode 100644 index 24fa379e82..0000000000 --- a/astrbot/core/computer/booters/bay_manager.py +++ /dev/null @@ -1,258 +0,0 @@ -"""Manage Bay container lifecycle for zero-config Shipyard Neo integration. - -When no Bay endpoint is configured, AstrBot can automatically start a Bay -container using the Docker socket (like BoxliteBooter does for Ship -containers). -""" - -from __future__ import annotations - -import asyncio -import io -import json -import tarfile -from typing import Any - -import aiodocker -import aiohttp - -from astrbot.api import logger - -# --------------------------------------------------------------------------- -# Constants -# --------------------------------------------------------------------------- - -BAY_IMAGE = "ghcr.io/astrbotdevs/shipyard-neo-bay:latest" -BAY_CONTAINER_NAME = "astrbot-bay" -BAY_LABEL = "astrbot.bay.managed" -BAY_PORT = 8114 -HEALTH_TIMEOUT_S = 60 -HEALTH_POLL_INTERVAL_S = 2 - - -class BayContainerManager: - """Start / reuse / stop a Bay container via Docker Engine API.""" - - def __init__( - self, - image: str = BAY_IMAGE, - host_port: int = BAY_PORT, - ) -> None: - self._image = image - self._host_port = host_port - self._docker: aiodocker.Docker | None = None - self._container: Any = None - - # ------------------------------------------------------------------ - # Public API - # ------------------------------------------------------------------ - - async def ensure_running(self) -> str: - """Make sure a Bay container is running. Returns the endpoint URL. - - If a container labelled ``astrbot.bay.managed`` already exists - and is running, it will be reused. Otherwise a new container is - created from *self._image*. - """ - try: - self._docker = aiodocker.Docker() - except Exception as exc: - raise RuntimeError( - "Failed to connect to Docker daemon. " - "Ensure Docker is installed and running, or configure " - "an explicit Bay endpoint instead of auto-start mode." - ) from exc - - # 1. Look for an existing managed container - existing = await self._find_managed_container() - if existing is not None: - state = existing["State"] - if state.get("Running"): - cid = existing["Id"][:12] - logger.info("[BayManager] Reusing existing Bay container: %s", cid) - self._container = await self._docker.containers.get(existing["Id"]) - return f"http://127.0.0.1:{self._host_port}" - else: - # Container exists but stopped — restart it - logger.info("[BayManager] Restarting stopped Bay container") - container = await self._docker.containers.get(existing["Id"]) - await container.start() - self._container = container - return f"http://127.0.0.1:{self._host_port}" - - # 2. Pull image if needed - await self._pull_image_if_needed() - - # 3. Create and start container - logger.info( - "[BayManager] Starting Bay container: image=%s, port=%d", - self._image, - self._host_port, - ) - config = { - "Image": self._image, - "Labels": {BAY_LABEL: "true"}, - "Env": [ - "BAY_SERVER__HOST=0.0.0.0", - f"BAY_SERVER__PORT={BAY_PORT}", - "BAY_DATA_DIR=/app/data", - # allow_anonymous=false → auto-provisions API key - "BAY_SECURITY__ALLOW_ANONYMOUS=false", - ], - "HostConfig": { - "PortBindings": { - f"{BAY_PORT}/tcp": [{"HostPort": str(self._host_port)}], - }, - "Binds": [ - # Bay needs Docker socket to create sandbox containers - "/var/run/docker.sock:/var/run/docker.sock", - ], - "RestartPolicy": {"Name": "unless-stopped"}, - }, - } - self._container = await self._docker.containers.create_or_replace( - BAY_CONTAINER_NAME, config - ) - await self._container.start() - logger.info("[BayManager] Bay container started: %s", BAY_CONTAINER_NAME) - - return f"http://127.0.0.1:{self._host_port}" - - async def wait_healthy(self, timeout: int = HEALTH_TIMEOUT_S) -> None: - """Block until Bay's ``/health`` endpoint returns 200.""" - url = f"http://127.0.0.1:{self._host_port}/health" - deadline = asyncio.get_event_loop().time() + timeout - last_error: str = "" - - async with aiohttp.ClientSession() as session: - while asyncio.get_event_loop().time() < deadline: - try: - async with session.get( - url, timeout=aiohttp.ClientTimeout(total=3) - ) as resp: - if resp.status == 200: - logger.info("[BayManager] Bay is healthy") - return - last_error = f"HTTP {resp.status}" - except Exception as exc: - last_error = str(exc) - - await asyncio.sleep(HEALTH_POLL_INTERVAL_S) - - raise TimeoutError( - f"Bay did not become healthy within {timeout}s (last error: {last_error})" - ) - - async def read_credentials(self) -> str: - """Read auto-provisioned API key from Bay container. - - Bay writes ``credentials.json`` to its data directory when - ``allow_anonymous=false`` and no explicit API key is set. - """ - if self._container is None: - return "" - - try: - # Read credentials.json from container filesystem - tar_stream = await self._container.get_archive("/app/data/credentials.json") - # get_archive returns (tar_data, stat) - tar_data = tar_stream - - if isinstance(tar_data, dict): - raw = tar_data.get("data", b"") - elif isinstance(tar_data, tuple): - # (stream, stat_info) - raw = b"" - stream = tar_data[0] - if hasattr(stream, "read"): - raw = await stream.read() - elif isinstance(stream, bytes): - raw = stream - else: - # It might be a chunked response - chunks = [] - async for chunk in stream: - chunks.append(chunk) - raw = b"".join(chunks) - else: - raw = tar_data if isinstance(tar_data, bytes) else b"" - - if not raw: - logger.debug("[BayManager] Empty tar response from container") - return "" - - tario = io.BytesIO(raw) - with tarfile.open(fileobj=tario) as tar: - for member in tar.getmembers(): - f = tar.extractfile(member) - if f: - creds = json.loads(f.read().decode("utf-8")) - api_key = creds.get("api_key", "") - if api_key: - masked = ( - f"{api_key[:8]}..." - if len(api_key) >= 10 - else "redacted" - ) - logger.info( - "[BayManager] Auto-discovered Bay API key: %s", - masked, - ) - return api_key - except Exception as exc: - logger.debug( - "[BayManager] Failed to read credentials from container: %s", exc - ) - - return "" - - async def close_client(self) -> None: - """Close the Docker client without stopping the container. - - The Bay container stays running for reuse by future sessions. - """ - if self._docker is not None: - await self._docker.close() - self._docker = None - - async def stop(self) -> None: - """Stop and remove the managed Bay container.""" - if self._container is not None: - try: - await self._container.stop() - await self._container.delete(force=True) - logger.info("[BayManager] Bay container stopped and removed") - except Exception as exc: - logger.debug("[BayManager] Error stopping Bay container: %s", exc) - finally: - self._container = None - - await self.close_client() - - # ------------------------------------------------------------------ - # Private helpers - # ------------------------------------------------------------------ - - async def _find_managed_container(self) -> dict | None: - """Find an existing container with our management label.""" - assert self._docker is not None - containers = await self._docker.containers.list( - all=True, - filters=json.dumps({"label": [f"{BAY_LABEL}=true"]}), - ) - if containers: - # Inspect first match to get full state - return await containers[0].show() - return None - - async def _pull_image_if_needed(self) -> None: - """Pull the Bay image if it doesn't exist locally.""" - assert self._docker is not None - try: - await self._docker.images.inspect(self._image) - logger.debug("[BayManager] Image %s already exists", self._image) - except aiodocker.exceptions.DockerError: - logger.info("[BayManager] Pulling image %s ...", self._image) - # Pull with progress logging - await self._docker.images.pull(self._image) - logger.info("[BayManager] Image %s pulled successfully", self._image) diff --git a/astrbot/core/computer/booters/boxlite.py b/astrbot/core/computer/booters/boxlite.py index 70064fdd48..373f6cee04 100644 --- a/astrbot/core/computer/booters/boxlite.py +++ b/astrbot/core/computer/booters/boxlite.py @@ -64,10 +64,6 @@ async def upload_file(self, path: str, remote_path: str) -> dict: async with aiohttp.ClientSession(timeout=timeout) as session: async with session.post(url, data=data) as response: if response.status == 200: - logger.info( - "[Computer] File uploaded to Boxlite sandbox: %s", - remote_path, - ) return { "success": True, "message": "File uploaded successfully", diff --git a/astrbot/core/computer/booters/shipyard.py b/astrbot/core/computer/booters/shipyard.py index 6379d1e48b..b13503431c 100644 --- a/astrbot/core/computer/booters/shipyard.py +++ b/astrbot/core/computer/booters/shipyard.py @@ -31,7 +31,7 @@ async def boot(self, session_id: str) -> None: self._ship = ship async def shutdown(self) -> None: - logger.info("[Computer] Shipyard booter shutdown.") + pass @property def fs(self) -> FileSystemComponent: @@ -47,19 +47,11 @@ def shell(self) -> ShellComponent: async def upload_file(self, path: str, file_name: str) -> dict: """Upload file to sandbox""" - result = await self._ship.upload_file(path, file_name) - logger.info("[Computer] File uploaded to Shipyard sandbox: %s", file_name) - return result + return await self._ship.upload_file(path, file_name) async def download_file(self, remote_path: str, local_path: str): """Download file from sandbox.""" - result = await self._ship.download_file(remote_path, local_path) - logger.info( - "[Computer] File downloaded from Shipyard sandbox: %s -> %s", - remote_path, - local_path, - ) - return result + return await self._ship.download_file(remote_path, local_path) async def available(self) -> bool: """Check if the sandbox is available.""" @@ -67,17 +59,8 @@ async def available(self) -> bool: ship_id = self._ship.id data = await self._sandbox_client.get_ship(ship_id) if not data: - logger.info( - "[Computer] Shipyard sandbox health check: id=%s, healthy=False (no data)", - ship_id, - ) return False health = bool(data.get("status", 0) == 1) - logger.info( - "[Computer] Shipyard sandbox health check: id=%s, healthy=%s", - ship_id, - health, - ) return health except Exception as e: logger.error(f"Error checking Shipyard sandbox availability: {e}") diff --git a/astrbot/core/computer/booters/shipyard_neo.py b/astrbot/core/computer/booters/shipyard_neo.py deleted file mode 100644 index 6304696ad2..0000000000 --- a/astrbot/core/computer/booters/shipyard_neo.py +++ /dev/null @@ -1,513 +0,0 @@ -from __future__ import annotations - -import os -import shlex -from typing import Any, cast - -from astrbot.api import logger - -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] Neo auto-start mode: launching Bay container") - 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 auto-started at %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( - "Got Shipyard Neo sandbox: %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] Using user-specified 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] Failed to query Bay profiles, falling back to %s: %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] Auto-selected 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] Shutting down Shipyard Neo sandbox: id=%s", sandbox_id - ) - await self._client.__aexit__(None, None, None) - self._client = None - self._sandbox = None - logger.info("[Computer] Shipyard Neo sandbox shut down: id=%s", 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: - if self._browser is None: - raise RuntimeError("ShipyardNeoBooter is not initialized.") - 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 uploaded to Neo sandbox: %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 downloaded from Neo sandbox: %s -> %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.info( - "[Computer] Neo sandbox health check: id=%s, status=%s, healthy=%s", - getattr(self._sandbox, "id", "unknown"), - status, - healthy, - ) - return healthy - except Exception as e: - logger.error(f"Error checking Shipyard Neo sandbox availability: {e}") - return False diff --git a/astrbot/core/computer/computer_client.py b/astrbot/core/computer/computer_client.py index 1853abf75c..9750e7b642 100644 --- a/astrbot/core/computer/computer_client.py +++ b/astrbot/core/computer/computer_client.py @@ -1,10 +1,10 @@ -import json +import os import shutil import uuid from pathlib import Path from astrbot.api import logger -from astrbot.core.skills.skill_manager import SANDBOX_SKILLS_ROOT, SkillManager +from astrbot.core.skills.skill_manager import SANDBOX_SKILLS_ROOT from astrbot.core.star.context import Context from astrbot.core.utils.astrbot_path import ( get_astrbot_skills_path, @@ -16,403 +16,45 @@ session_booter: dict[str, ComputerBooter] = {} local_booter: ComputerBooter | None = None -_MANAGED_SKILLS_FILE = ".astrbot_managed_skills.json" - - -def _list_local_skill_dirs(skills_root: Path) -> list[Path]: - skills: list[Path] = [] - for entry in sorted(skills_root.iterdir()): - if not entry.is_dir(): - continue - skill_md = entry / "SKILL.md" - if skill_md.exists(): - skills.append(entry) - return skills - - -def _discover_bay_credentials(endpoint: str) -> str: - """Try to auto-discover Bay API key from credentials.json. - - Search order: - 1. BAY_DATA_DIR env var - 2. Mono-repo relative path: ../pkgs/bay/ (dev layout) - 3. Current working directory - - Returns: - API key string, or empty string if not found. - """ - import os - - candidates: list[Path] = [] - - # 1. BAY_DATA_DIR env var - bay_data_dir = os.environ.get("BAY_DATA_DIR") - if bay_data_dir: - candidates.append(Path(bay_data_dir) / "credentials.json") - - # 2. Mono-repo layout: AstrBot/../pkgs/bay/credentials.json - astrbot_root = Path(__file__).resolve().parents[3] # astrbot/core/computer/ → root - candidates.append(astrbot_root.parent / "pkgs" / "bay" / "credentials.json") - - # 3. Current working directory - candidates.append(Path.cwd() / "credentials.json") - - for cred_path in candidates: - if not cred_path.is_file(): - continue - try: - data = json.loads(cred_path.read_text()) - api_key = data.get("api_key", "") - if api_key: - # Optionally verify endpoint matches - cred_endpoint = data.get("endpoint", "") - if ( - cred_endpoint - and endpoint - and cred_endpoint.rstrip("/") != endpoint.rstrip("/") - ): - logger.warning( - "[Computer] credentials.json endpoint mismatch: " - "file=%s, configured=%s — using key anyway", - cred_endpoint, - endpoint, - ) - masked_key = f"{api_key[:4]}..." if len(api_key) >= 6 else "redacted" - logger.info( - "[Computer] Auto-discovered Bay API key from %s (prefix=%s)", - cred_path, - masked_key, - ) - return api_key - except (json.JSONDecodeError, OSError) as exc: - logger.debug("[Computer] Failed to read %s: %s", cred_path, exc) - - logger.debug("[Computer] No Bay credentials.json found in search paths") - return "" - - -def _build_python_exec_command(script: str) -> str: - return ( - "if command -v python3 >/dev/null 2>&1; then PYBIN=python3; " - "elif command -v python >/dev/null 2>&1; then PYBIN=python; " - "else echo 'python not found in sandbox' >&2; exit 127; fi; " - "$PYBIN - <<'PY'\n" - f"{script}\n" - "PY" - ) - - -def _build_apply_sync_command() -> str: - """Build shell command for sync stage only. - - This stage mutates sandbox files (managed skill replacement) but does not scan - metadata. Keeping it separate allows callers to preserve old behavior while - reusing the apply step independently. - """ - script = f""" -import json -import shutil -import zipfile -from pathlib import Path - -root = Path({SANDBOX_SKILLS_ROOT!r}) -zip_path = root / "skills.zip" -tmp_extract = Path(f"{{root}}_tmp_extract") -managed_file = root / {_MANAGED_SKILLS_FILE!r} - - -def remove_tree(path: Path) -> None: - if not path.exists(): - return - if path.is_dir(): - shutil.rmtree(path, ignore_errors=True) - else: - path.unlink(missing_ok=True) - - -def load_managed_skills() -> list[str]: - if not managed_file.exists(): - return [] - try: - payload = json.loads(managed_file.read_text(encoding="utf-8")) - except Exception: - return [] - if not isinstance(payload, dict): - return [] - items = payload.get("managed_skills", []) - if not isinstance(items, list): - return [] - result: list[str] = [] - for item in items: - if isinstance(item, str) and item.strip(): - result.append(item.strip()) - return result - - -root.mkdir(parents=True, exist_ok=True) -for managed_name in load_managed_skills(): - remove_tree(root / managed_name) - -current_managed: list[str] = [] -if zip_path.exists(): - remove_tree(tmp_extract) - tmp_extract.mkdir(parents=True, exist_ok=True) - with zipfile.ZipFile(zip_path) as zf: - zf.extractall(tmp_extract) - for entry in sorted(tmp_extract.iterdir()): - if not entry.is_dir(): - continue - target = root / entry.name - remove_tree(target) - shutil.copytree(entry, target) - current_managed.append(entry.name) - -remove_tree(tmp_extract) -remove_tree(zip_path) -managed_file.write_text( - json.dumps({{"managed_skills": current_managed}}, ensure_ascii=False, indent=2), - encoding="utf-8", -) -print(json.dumps({{"managed_skills": current_managed}}, ensure_ascii=False)) -""".strip() - return _build_python_exec_command(script) - - -def _build_scan_command() -> str: - """Build shell command for scan stage only. - - This stage is read-oriented: it scans SKILL.md metadata and returns the - historical payload shape consumed by cache update logic. - - The scan resolves the absolute path of the skills root at runtime so - that the LLM can reliably ``cat`` skill files regardless of cwd. - Only the ``description`` field is extracted from frontmatter. - """ - script = f""" -import json -from pathlib import Path - -root = Path({SANDBOX_SKILLS_ROOT!r}) -managed_file = root / {_MANAGED_SKILLS_FILE!r} - -# Resolve absolute path at runtime so prompts always have a reliable path -root_abs = str(root.resolve()) - - -# NOTE: This parser mirrors skill_manager._parse_frontmatter_description. -# Keep the two implementations in sync when changing parsing logic. -def parse_description(text: str) -> str: - if not text.startswith("---"): - return "" - lines = text.splitlines() - if not lines or lines[0].strip() != "---": - return "" - end_idx = None - for i in range(1, len(lines)): - if lines[i].strip() == "---": - end_idx = i - break - if end_idx is None: - return "" - for line in lines[1:end_idx]: - if ":" not in line: - continue - key, value = line.split(":", 1) - if key.strip().lower() == "description": - return value.strip().strip('"').strip("'") - return "" - - -def load_managed_skills() -> list[str]: - if not managed_file.exists(): - return [] - try: - payload = json.loads(managed_file.read_text(encoding="utf-8")) - except Exception: - return [] - if not isinstance(payload, dict): - return [] - items = payload.get("managed_skills", []) - if not isinstance(items, list): - return [] - result: list[str] = [] - for item in items: - if isinstance(item, str) and item.strip(): - result.append(item.strip()) - return result - - -def collect_skills() -> list[dict[str, str]]: - skills: list[dict[str, str]] = [] - if not root.exists(): - return skills - for skill_dir in sorted(root.iterdir()): - if not skill_dir.is_dir(): - continue - skill_md = skill_dir / "SKILL.md" - if not skill_md.is_file(): - continue - description = "" - try: - text = skill_md.read_text(encoding="utf-8") - description = parse_description(text) - except Exception: - description = "" - skills.append( - {{ - "name": skill_dir.name, - "description": description, - "path": f"{{root_abs}}/{{skill_dir.name}}/SKILL.md", - }} - ) - return skills - - -print( - json.dumps( - {{ - "managed_skills": load_managed_skills(), - "skills": collect_skills(), - }}, - ensure_ascii=False, - ) -) -""".strip() - return _build_python_exec_command(script) - - -def _build_sync_and_scan_command() -> str: - """Legacy combined command kept for backward compatibility. - - New code paths should prefer apply + scan split helpers. - """ - return f"{_build_apply_sync_command()}\n{_build_scan_command()}" - - -def _shell_exec_succeeded(result: dict) -> bool: - if "success" in result: - return bool(result.get("success")) - exit_code = result.get("exit_code") - return exit_code in (0, None) - - -def _format_exec_error_detail(result: dict) -> str: - """Format shell execution details for better observability. - - Keep the message compact while still surfacing exit code and stderr/stdout. - """ - exit_code = result.get("exit_code") - stderr = str(result.get("stderr", "") or "").strip() - stdout = str(result.get("stdout", "") or "").strip() - stderr_text = stderr[:500] - stdout_text = stdout[:300] - return f"exit_code={exit_code}, stderr={stderr_text!r}, stdout_tail={stdout_text!r}" - - -def _decode_sync_payload(stdout: str) -> dict | None: - text = stdout.strip() - if not text: - return None - candidates = [text] - candidates.extend([line.strip() for line in text.splitlines() if line.strip()]) - for candidate in reversed(candidates): - try: - payload = json.loads(candidate) - except Exception: - continue - if isinstance(payload, dict): - return payload - return None - - -def _update_sandbox_skills_cache(payload: dict | None) -> None: - if not isinstance(payload, dict): - return - skills = payload.get("skills", []) - if not isinstance(skills, list): - return - SkillManager().set_sandbox_skills_cache(skills) - - -async def _apply_skills_to_sandbox(booter: ComputerBooter) -> None: - """Apply local skill bundle to sandbox filesystem only. - - This function is intentionally limited to file mutation. Metadata scanning is - executed in a separate phase to keep failure domains clear. - """ - logger.info("[Computer] Skill sync phase=apply start") - apply_result = await booter.shell.exec(_build_apply_sync_command()) - if not _shell_exec_succeeded(apply_result): - detail = _format_exec_error_detail(apply_result) - logger.error("[Computer] Skill sync phase=apply failed: %s", detail) - raise RuntimeError(f"Failed to apply sandbox skill sync strategy: {detail}") - logger.info("[Computer] Skill sync phase=apply done") - - -async def _scan_sandbox_skills(booter: ComputerBooter) -> dict | None: - """Scan sandbox skills and return normalized payload for cache update.""" - logger.info("[Computer] Skill sync phase=scan start") - scan_result = await booter.shell.exec(_build_scan_command()) - if not _shell_exec_succeeded(scan_result): - detail = _format_exec_error_detail(scan_result) - logger.error("[Computer] Skill sync phase=scan failed: %s", detail) - raise RuntimeError(f"Failed to scan sandbox skills after sync: {detail}") - - payload = _decode_sync_payload(str(scan_result.get("stdout", "") or "")) - if payload is None: - logger.warning("[Computer] Skill sync phase=scan returned empty payload") - else: - logger.info("[Computer] Skill sync phase=scan done") - return payload async def _sync_skills_to_sandbox(booter: ComputerBooter) -> None: - """Sync local skills to sandbox and refresh cache. - - Backward-compatible orchestrator: keep historical behavior while internally - splitting into `apply` and `scan` phases. - """ - skills_root = Path(get_astrbot_skills_path()) - if not skills_root.is_dir(): + skills_root = get_astrbot_skills_path() + if not os.path.isdir(skills_root): + return + if not any(Path(skills_root).iterdir()): return - local_skill_dirs = _list_local_skill_dirs(skills_root) - temp_dir = Path(get_astrbot_temp_path()) - temp_dir.mkdir(parents=True, exist_ok=True) - zip_base = temp_dir / "skills_bundle" - zip_path = zip_base.with_suffix(".zip") + temp_dir = get_astrbot_temp_path() + os.makedirs(temp_dir, exist_ok=True) + zip_base = os.path.join(temp_dir, "skills_bundle") + zip_path = f"{zip_base}.zip" try: - if local_skill_dirs: - if zip_path.exists(): - zip_path.unlink() - shutil.make_archive(str(zip_base), "zip", str(skills_root)) - remote_zip = Path(SANDBOX_SKILLS_ROOT) / "skills.zip" - logger.info("Uploading skills bundle to sandbox...") - await booter.shell.exec(f"mkdir -p {SANDBOX_SKILLS_ROOT}") - upload_result = await booter.upload_file(str(zip_path), str(remote_zip)) - if not upload_result.get("success", False): - raise RuntimeError("Failed to upload skills bundle to sandbox.") - else: - logger.info( - "No local skills found. Keeping sandbox built-ins and refreshing metadata." - ) - await booter.shell.exec(f"rm -f {SANDBOX_SKILLS_ROOT}/skills.zip") - - # Keep backward-compatible behavior while splitting lifecycle into two - # observable phases: apply (filesystem mutation) + scan (metadata read). - await _apply_skills_to_sandbox(booter) - payload = await _scan_sandbox_skills(booter) - _update_sandbox_skills_cache(payload) - managed = payload.get("managed_skills", []) if isinstance(payload, dict) else [] - logger.info( - "[Computer] Sandbox skill sync complete: managed=%d", - len(managed), + if os.path.exists(zip_path): + os.remove(zip_path) + shutil.make_archive(zip_base, "zip", skills_root) + remote_zip = Path(SANDBOX_SKILLS_ROOT) / "skills.zip" + logger.info("Uploading skills bundle to sandbox...") + await booter.shell.exec(f"mkdir -p {SANDBOX_SKILLS_ROOT}") + upload_result = await booter.upload_file(zip_path, str(remote_zip)) + if not upload_result.get("success", False): + raise RuntimeError("Failed to upload skills bundle to sandbox.") + # Use -n flag to never overwrite existing files, fallback to Python if unzip unavailable + await booter.shell.exec( + f"unzip -n {remote_zip} -d {SANDBOX_SKILLS_ROOT} || " + f"python3 -c \"import zipfile, os, pathlib; z=zipfile.ZipFile('{remote_zip}'); " + f"[z.extract(m, '{SANDBOX_SKILLS_ROOT}') for m in z.namelist() " + f"if not os.path.exists(os.path.join('{SANDBOX_SKILLS_ROOT}', m))]\" || " + f"python -c \"import zipfile, os, pathlib; z=zipfile.ZipFile('{remote_zip}'); " + f"[z.extract(m, '{SANDBOX_SKILLS_ROOT}') for m in z.namelist() " + f"if not os.path.exists(os.path.join('{SANDBOX_SKILLS_ROOT}', m))]\"; " + f"rm -f {remote_zip}" ) finally: - if zip_path.exists(): + if os.path.exists(zip_path): try: - zip_path.unlink() + os.remove(zip_path) except Exception: logger.warning(f"Failed to remove temp skills zip: {zip_path}") @@ -424,7 +66,7 @@ async def get_booter( config = context.get_config(umo=session_id) sandbox_cfg = config.get("provider_settings", {}).get("sandbox", {}) - booter_type = sandbox_cfg.get("booter", "shipyard_neo") + booter_type = sandbox_cfg.get("booter", "shipyard") if session_id in session_booter: booter = session_booter[session_id] @@ -433,9 +75,6 @@ async def get_booter( session_booter.pop(session_id, None) if session_id not in session_booter: uuid_str = uuid.uuid5(uuid.NAMESPACE_DNS, session_id).hex - logger.info( - f"[Computer] Initializing booter: type={booter_type}, session={session_id}" - ) if booter_type == "shipyard": from .booters.shipyard import ShipyardBooter @@ -447,27 +86,6 @@ async def get_booter( client = ShipyardBooter( endpoint_url=ep, access_token=token, ttl=ttl, session_num=max_sessions ) - elif booter_type == "shipyard_neo": - from .booters.shipyard_neo import ShipyardNeoBooter - - ep = sandbox_cfg.get("shipyard_neo_endpoint", "") - token = sandbox_cfg.get("shipyard_neo_access_token", "") - ttl = sandbox_cfg.get("shipyard_neo_ttl", 3600) - profile = sandbox_cfg.get("shipyard_neo_profile", "python-default") - - # Auto-discover token from Bay's credentials.json if not configured - if not token: - token = _discover_bay_credentials(ep) - - logger.info( - f"[Computer] Shipyard Neo config: endpoint={ep}, profile={profile}, ttl={ttl}" - ) - client = ShipyardNeoBooter( - endpoint_url=ep, - access_token=token, - profile=profile, - ttl=ttl, - ) elif booter_type == "boxlite": from .booters.boxlite import BoxliteBooter @@ -477,9 +95,6 @@ async def get_booter( try: await client.boot(uuid_str) - logger.info( - f"[Computer] Sandbox booted successfully: type={booter_type}, session={session_id}" - ) await _sync_skills_to_sandbox(client) except Exception as e: logger.error(f"Error booting sandbox for session {session_id}: {e}") @@ -489,24 +104,6 @@ async def get_booter( return session_booter[session_id] -async def sync_skills_to_active_sandboxes() -> None: - """Best-effort skills synchronization for all active sandbox sessions.""" - logger.info( - "[Computer] Syncing skills to %d active sandbox(es)", len(session_booter) - ) - for session_id, booter in list(session_booter.items()): - try: - if not await booter.available(): - continue - await _sync_skills_to_sandbox(booter) - except Exception as e: - logger.warning( - "Failed to sync skills to sandbox for session %s: %s", - session_id, - e, - ) - - def get_local_booter() -> ComputerBooter: global local_booter if local_booter is None: diff --git a/astrbot/core/computer/olayer/__init__.py b/astrbot/core/computer/olayer/__init__.py index e2348671eb..f099c079a3 100644 --- a/astrbot/core/computer/olayer/__init__.py +++ b/astrbot/core/computer/olayer/__init__.py @@ -1,11 +1,5 @@ -from .browser import BrowserComponent from .filesystem import FileSystemComponent from .python import PythonComponent from .shell import ShellComponent -__all__ = [ - "PythonComponent", - "ShellComponent", - "FileSystemComponent", - "BrowserComponent", -] +__all__ = ["PythonComponent", "ShellComponent", "FileSystemComponent"] diff --git a/astrbot/core/computer/olayer/browser.py b/astrbot/core/computer/olayer/browser.py deleted file mode 100644 index aa69f4501d..0000000000 --- a/astrbot/core/computer/olayer/browser.py +++ /dev/null @@ -1,46 +0,0 @@ -""" -Browser automation component -""" - -from typing import Any, Protocol - - -class BrowserComponent(Protocol): - """Browser operations component""" - - 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]: - """Execute a browser automation command""" - ... - - 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]: - """Execute a browser automation command batch""" - ... - - 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]: - """Run a browser skill by skill key""" - ... diff --git a/astrbot/core/computer/tools/__init__.py b/astrbot/core/computer/tools/__init__.py index 598abbb6ea..79994fb9b4 100644 --- a/astrbot/core/computer/tools/__init__.py +++ b/astrbot/core/computer/tools/__init__.py @@ -1,36 +1,8 @@ -from .browser import BrowserBatchExecTool, BrowserExecTool, RunBrowserSkillTool from .fs import FileDownloadTool, FileUploadTool -from .neo_skills import ( - AnnotateExecutionTool, - CreateSkillCandidateTool, - CreateSkillPayloadTool, - EvaluateSkillCandidateTool, - GetExecutionHistoryTool, - GetSkillPayloadTool, - ListSkillCandidatesTool, - ListSkillReleasesTool, - PromoteSkillCandidateTool, - RollbackSkillReleaseTool, - SyncSkillReleaseTool, -) from .python import LocalPythonTool, PythonTool from .shell import ExecuteShellTool __all__ = [ - "BrowserExecTool", - "BrowserBatchExecTool", - "RunBrowserSkillTool", - "GetExecutionHistoryTool", - "AnnotateExecutionTool", - "CreateSkillPayloadTool", - "GetSkillPayloadTool", - "CreateSkillCandidateTool", - "ListSkillCandidatesTool", - "EvaluateSkillCandidateTool", - "PromoteSkillCandidateTool", - "ListSkillReleasesTool", - "RollbackSkillReleaseTool", - "SyncSkillReleaseTool", "FileUploadTool", "PythonTool", "LocalPythonTool", diff --git a/astrbot/core/computer/tools/browser.py b/astrbot/core/computer/tools/browser.py deleted file mode 100644 index 70061ac313..0000000000 --- a/astrbot/core/computer/tools/browser.py +++ /dev/null @@ -1,204 +0,0 @@ -import json -from dataclasses import dataclass, field -from typing import Any - -from astrbot.api import FunctionTool -from astrbot.core.agent.run_context import ContextWrapper -from astrbot.core.agent.tool import ToolExecResult -from astrbot.core.astr_agent_context import AstrAgentContext - -from ..computer_client import get_booter - - -def _to_json(data: Any) -> str: - return json.dumps(data, ensure_ascii=False, default=str) - - -def _ensure_admin(context: ContextWrapper[AstrAgentContext]) -> str | None: - if context.context.event.role != "admin": - return ( - "error: Permission denied. Browser and skill lifecycle tools are only allowed " - "for admin users." - ) - return None - - -async def _get_browser_component(context: ContextWrapper[AstrAgentContext]) -> Any: - booter = await get_booter( - context.context.context, - context.context.event.unified_msg_origin, - ) - browser = getattr(booter, "browser", None) - if browser is None: - raise RuntimeError( - "Current sandbox booter does not support browser capability. " - "Please switch to shipyard_neo." - ) - return browser - - -@dataclass -class BrowserExecTool(FunctionTool): - name: str = "astrbot_execute_browser" - description: str = "Execute one browser automation command in the sandbox." - parameters: dict = field( - default_factory=lambda: { - "type": "object", - "properties": { - "cmd": {"type": "string", "description": "Browser command to execute."}, - "timeout": {"type": "integer", "default": 30}, - "description": { - "type": "string", - "description": "Optional execution description.", - }, - "tags": {"type": "string", "description": "Optional tags."}, - "learn": { - "type": "boolean", - "description": "Whether to mark execution as learn evidence.", - "default": False, - }, - "include_trace": { - "type": "boolean", - "description": "Whether to include trace_ref in response.", - "default": False, - }, - }, - "required": ["cmd"], - } - ) - - async def call( - self, - context: ContextWrapper[AstrAgentContext], - cmd: str, - timeout: int = 30, - description: str | None = None, - tags: str | None = None, - learn: bool = False, - include_trace: bool = False, - ) -> ToolExecResult: - if err := _ensure_admin(context): - return err - try: - browser = await _get_browser_component(context) - result = await browser.exec( - cmd=cmd, - timeout=timeout, - description=description, - tags=tags, - learn=learn, - include_trace=include_trace, - ) - return _to_json(result) - except Exception as e: - return f"Error executing browser command: {str(e)}" - - -@dataclass -class BrowserBatchExecTool(FunctionTool): - name: str = "astrbot_execute_browser_batch" - description: str = "Execute a browser command batch in the sandbox." - parameters: dict = field( - default_factory=lambda: { - "type": "object", - "properties": { - "commands": { - "type": "array", - "items": {"type": "string"}, - "description": "Ordered browser commands.", - }, - "timeout": {"type": "integer", "default": 60}, - "stop_on_error": {"type": "boolean", "default": True}, - "description": { - "type": "string", - "description": "Optional execution description.", - }, - "tags": {"type": "string", "description": "Optional tags."}, - "learn": { - "type": "boolean", - "description": "Whether to mark execution as learn evidence.", - "default": False, - }, - "include_trace": { - "type": "boolean", - "description": "Whether to include trace_ref in response.", - "default": False, - }, - }, - "required": ["commands"], - } - ) - - async def call( - self, - context: ContextWrapper[AstrAgentContext], - 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, - ) -> ToolExecResult: - if err := _ensure_admin(context): - return err - try: - browser = await _get_browser_component(context) - result = await browser.exec_batch( - commands=commands, - timeout=timeout, - stop_on_error=stop_on_error, - description=description, - tags=tags, - learn=learn, - include_trace=include_trace, - ) - return _to_json(result) - except Exception as e: - return f"Error executing browser batch command: {str(e)}" - - -@dataclass -class RunBrowserSkillTool(FunctionTool): - name: str = "astrbot_run_browser_skill" - description: str = "Run a released browser skill in the sandbox by skill_key." - parameters: dict = field( - default_factory=lambda: { - "type": "object", - "properties": { - "skill_key": {"type": "string"}, - "timeout": {"type": "integer", "default": 60}, - "stop_on_error": {"type": "boolean", "default": True}, - "include_trace": {"type": "boolean", "default": False}, - "description": {"type": "string"}, - "tags": {"type": "string"}, - }, - "required": ["skill_key"], - } - ) - - async def call( - self, - context: ContextWrapper[AstrAgentContext], - skill_key: str, - timeout: int = 60, - stop_on_error: bool = True, - include_trace: bool = False, - description: str | None = None, - tags: str | None = None, - ) -> ToolExecResult: - if err := _ensure_admin(context): - return err - try: - browser = await _get_browser_component(context) - result = await browser.run_skill( - skill_key=skill_key, - timeout=timeout, - stop_on_error=stop_on_error, - include_trace=include_trace, - description=description, - tags=tags, - ) - return _to_json(result) - except Exception as e: - return f"Error running browser skill: {str(e)}" diff --git a/astrbot/core/computer/tools/neo_skills.py b/astrbot/core/computer/tools/neo_skills.py deleted file mode 100644 index 492b6e45ed..0000000000 --- a/astrbot/core/computer/tools/neo_skills.py +++ /dev/null @@ -1,542 +0,0 @@ -import json -from collections.abc import Awaitable, Callable -from dataclasses import dataclass, field -from typing import Any - -from astrbot.api import FunctionTool -from astrbot.core.agent.run_context import ContextWrapper -from astrbot.core.agent.tool import ToolExecResult -from astrbot.core.astr_agent_context import AstrAgentContext -from astrbot.core.skills.neo_skill_sync import NeoSkillSyncManager - -from ..computer_client import get_booter - - -def _to_jsonable(model_like: Any) -> Any: - if isinstance(model_like, dict): - return model_like - if isinstance(model_like, list): - return [_to_jsonable(i) for i in model_like] - if hasattr(model_like, "model_dump"): - return _to_jsonable(model_like.model_dump()) - return model_like - - -def _to_json_text(data: Any) -> str: - return json.dumps(_to_jsonable(data), ensure_ascii=False, default=str) - - -def _ensure_admin(context: ContextWrapper[AstrAgentContext]) -> str | None: - if context.context.event.role != "admin": - return "error: Permission denied. Skill lifecycle tools are only allowed for admin users." - return None - - -async def _get_neo_context( - context: ContextWrapper[AstrAgentContext], -) -> tuple[Any, Any]: - booter = await get_booter( - context.context.context, - context.context.event.unified_msg_origin, - ) - client = getattr(booter, "bay_client", None) - sandbox = getattr(booter, "sandbox", None) - if client is None or sandbox is None: - raise RuntimeError( - "Current sandbox booter does not support Neo skill lifecycle APIs. " - "Please switch to shipyard_neo." - ) - return client, sandbox - - -@dataclass -class NeoSkillToolBase(FunctionTool): - error_prefix: str = "Error" - - async def _run( - self, - context: ContextWrapper[AstrAgentContext], - neo_call: Callable[[Any, Any], Awaitable[Any]], - error_action: str, - ) -> ToolExecResult: - if err := _ensure_admin(context): - return err - try: - client, sandbox = await _get_neo_context(context) - result = await neo_call(client, sandbox) - return _to_json_text(result) - except Exception as e: - return f"{self.error_prefix} {error_action}: {str(e)}" - - -@dataclass -class GetExecutionHistoryTool(NeoSkillToolBase): - name: str = "astrbot_get_execution_history" - description: str = "Get execution history from current sandbox." - parameters: dict = field( - default_factory=lambda: { - "type": "object", - "properties": { - "exec_type": {"type": "string"}, - "success_only": {"type": "boolean", "default": False}, - "limit": {"type": "integer", "default": 100}, - "offset": {"type": "integer", "default": 0}, - "tags": {"type": "string"}, - "has_notes": {"type": "boolean", "default": False}, - "has_description": {"type": "boolean", "default": False}, - }, - "required": [], - } - ) - - async def call( - self, - context: ContextWrapper[AstrAgentContext], - exec_type: str | None = None, - success_only: bool = False, - limit: int = 100, - offset: int = 0, - tags: str | None = None, - has_notes: bool = False, - has_description: bool = False, - ) -> ToolExecResult: - return await self._run( - context, - lambda _client, sandbox: sandbox.get_execution_history( - exec_type=exec_type, - success_only=success_only, - limit=limit, - offset=offset, - tags=tags, - has_notes=has_notes, - has_description=has_description, - ), - error_action="getting execution history", - ) - - -@dataclass -class AnnotateExecutionTool(NeoSkillToolBase): - name: str = "astrbot_annotate_execution" - description: str = "Annotate one execution history record." - parameters: dict = field( - default_factory=lambda: { - "type": "object", - "properties": { - "execution_id": {"type": "string"}, - "description": {"type": "string"}, - "tags": {"type": "string"}, - "notes": {"type": "string"}, - }, - "required": ["execution_id"], - } - ) - - async def call( - self, - context: ContextWrapper[AstrAgentContext], - execution_id: str, - description: str | None = None, - tags: str | None = None, - notes: str | None = None, - ) -> ToolExecResult: - return await self._run( - context, - lambda _client, sandbox: sandbox.annotate_execution( - execution_id=execution_id, - description=description, - tags=tags, - notes=notes, - ), - error_action="annotating execution", - ) - - -@dataclass -class CreateSkillPayloadTool(NeoSkillToolBase): - name: str = "astrbot_create_skill_payload" - description: str = ( - "Step 1/3 for Neo skill authoring: create immutable payload content and return payload_ref. " - "Use this to store skill_markdown and structured metadata; do NOT write local skill folders directly." - ) - parameters: dict = field( - default_factory=lambda: { - "type": "object", - "properties": { - "payload": { - "anyOf": [{"type": "object"}, {"type": "array"}], - "description": ( - "Skill payload JSON. Typical schema: {skill_markdown, inputs, outputs, meta}. " - "This only stores content and returns payload_ref; it does not create a candidate or release." - ), - }, - "kind": { - "type": "string", - "description": "Payload kind.", - "default": "astrbot_skill_v1", - }, - }, - "required": ["payload"], - } - ) - - async def call( - self, - context: ContextWrapper[AstrAgentContext], - payload: dict[str, Any] | list[Any], - kind: str = "astrbot_skill_v1", - ) -> ToolExecResult: - return await self._run( - context, - lambda client, _sandbox: client.skills.create_payload( - payload=payload, - kind=kind, - ), - error_action="creating skill payload", - ) - - -@dataclass -class GetSkillPayloadTool(NeoSkillToolBase): - name: str = "astrbot_get_skill_payload" - description: str = "Get one skill payload by payload_ref." - parameters: dict = field( - default_factory=lambda: { - "type": "object", - "properties": { - "payload_ref": {"type": "string"}, - }, - "required": ["payload_ref"], - } - ) - - async def call( - self, - context: ContextWrapper[AstrAgentContext], - payload_ref: str, - ) -> ToolExecResult: - return await self._run( - context, - lambda client, _sandbox: client.skills.get_payload(payload_ref), - error_action="getting skill payload", - ) - - -@dataclass -class CreateSkillCandidateTool(NeoSkillToolBase): - name: str = "astrbot_create_skill_candidate" - description: str = ( - "Step 2/3 for Neo skill authoring: create a candidate by binding execution evidence " - "(source_execution_ids) with skill identity (skill_key) and optional payload_ref." - ) - parameters: dict = field( - default_factory=lambda: { - "type": "object", - "properties": { - "skill_key": { - "type": "string", - "description": "Stable logical identifier, e.g. image-collage-9grid.", - }, - "source_execution_ids": { - "type": "array", - "items": {"type": "string"}, - "description": "Execution evidence IDs captured from sandbox history.", - }, - "scenario_key": { - "type": "string", - "description": "Optional scenario namespace for grouping candidates.", - }, - "payload_ref": { - "type": "string", - "description": "Optional payload reference created by astrbot_create_skill_payload.", - }, - }, - "required": ["skill_key", "source_execution_ids"], - } - ) - - async def call( - self, - context: ContextWrapper[AstrAgentContext], - skill_key: str, - source_execution_ids: list[str], - scenario_key: str | None = None, - payload_ref: str | None = None, - ) -> ToolExecResult: - return await self._run( - context, - lambda client, _sandbox: client.skills.create_candidate( - skill_key=skill_key, - source_execution_ids=source_execution_ids, - scenario_key=scenario_key, - payload_ref=payload_ref, - ), - error_action="creating skill candidate", - ) - - -@dataclass -class ListSkillCandidatesTool(NeoSkillToolBase): - name: str = "astrbot_list_skill_candidates" - description: str = "List skill candidates." - parameters: dict = field( - default_factory=lambda: { - "type": "object", - "properties": { - "status": {"type": "string"}, - "skill_key": {"type": "string"}, - "limit": {"type": "integer", "default": 100}, - "offset": {"type": "integer", "default": 0}, - }, - "required": [], - } - ) - - async def call( - self, - context: ContextWrapper[AstrAgentContext], - status: str | None = None, - skill_key: str | None = None, - limit: int = 100, - offset: int = 0, - ) -> ToolExecResult: - return await self._run( - context, - lambda client, _sandbox: client.skills.list_candidates( - status=status, - skill_key=skill_key, - limit=limit, - offset=offset, - ), - error_action="listing skill candidates", - ) - - -@dataclass -class EvaluateSkillCandidateTool(NeoSkillToolBase): - name: str = "astrbot_evaluate_skill_candidate" - description: str = "Evaluate a skill candidate." - parameters: dict = field( - default_factory=lambda: { - "type": "object", - "properties": { - "candidate_id": {"type": "string"}, - "passed": {"type": "boolean"}, - "score": {"type": "number"}, - "benchmark_id": {"type": "string"}, - "report": {"type": "string"}, - }, - "required": ["candidate_id", "passed"], - } - ) - - async def call( - self, - context: ContextWrapper[AstrAgentContext], - candidate_id: str, - passed: bool, - score: float | None = None, - benchmark_id: str | None = None, - report: str | None = None, - ) -> ToolExecResult: - return await self._run( - context, - lambda client, _sandbox: client.skills.evaluate_candidate( - candidate_id, - passed=passed, - score=score, - benchmark_id=benchmark_id, - report=report, - ), - error_action="evaluating skill candidate", - ) - - -@dataclass -class PromoteSkillCandidateTool(NeoSkillToolBase): - name: str = "astrbot_promote_skill_candidate" - description: str = ( - "Step 3/3 for Neo skill authoring: promote candidate to canary/stable release. " - "If stage=stable and sync_to_local=true, payload.skill_markdown is synced to local SKILL.md automatically." - ) - parameters: dict = field( - default_factory=lambda: { - "type": "object", - "properties": { - "candidate_id": {"type": "string"}, - "stage": { - "type": "string", - "description": "Release stage: canary/stable", - "default": "canary", - }, - "sync_to_local": { - "type": "boolean", - "description": ( - "Only used with stage=stable. true means sync payload.skill_markdown to local SKILL.md; " - "false means release remains Neo-side only." - ), - "default": True, - }, - }, - "required": ["candidate_id"], - } - ) - - async def call( - self, - context: ContextWrapper[AstrAgentContext], - candidate_id: str, - stage: str = "canary", - sync_to_local: bool = True, - ) -> ToolExecResult: - if err := _ensure_admin(context): - return err - if stage not in {"canary", "stable"}: - return "Error promoting skill candidate: stage must be canary or stable." - - try: - client, _sandbox = await _get_neo_context(context) - sync_mgr = NeoSkillSyncManager() - result = await sync_mgr.promote_with_optional_sync( - client, - candidate_id=candidate_id, - stage=stage, - sync_to_local=sync_to_local, - ) - if result.get("sync_error"): - rollback_json = result.get("rollback") - if rollback_json: - return ( - "Error promoting skill candidate: stable release synced failed; " - f"auto rollback succeeded. sync_error={result['sync_error']}; " - f"rollback={_to_json_text(rollback_json)}" - ) - return _to_json_text( - { - "release": result.get("release"), - "sync": result.get("sync"), - "rollback": result.get("rollback"), - } - ) - except Exception as e: - return f"Error promoting skill candidate: {str(e)}" - - -@dataclass -class ListSkillReleasesTool(NeoSkillToolBase): - name: str = "astrbot_list_skill_releases" - description: str = "List skill releases." - parameters: dict = field( - default_factory=lambda: { - "type": "object", - "properties": { - "skill_key": {"type": "string"}, - "active_only": {"type": "boolean", "default": False}, - "stage": {"type": "string"}, - "limit": {"type": "integer", "default": 100}, - "offset": {"type": "integer", "default": 0}, - }, - "required": [], - } - ) - - async def call( - self, - context: ContextWrapper[AstrAgentContext], - skill_key: str | None = None, - active_only: bool = False, - stage: str | None = None, - limit: int = 100, - offset: int = 0, - ) -> ToolExecResult: - return await self._run( - context, - lambda client, _sandbox: client.skills.list_releases( - skill_key=skill_key, - active_only=active_only, - stage=stage, - limit=limit, - offset=offset, - ), - error_action="listing skill releases", - ) - - -@dataclass -class RollbackSkillReleaseTool(NeoSkillToolBase): - name: str = "astrbot_rollback_skill_release" - description: str = "Rollback one skill release." - parameters: dict = field( - default_factory=lambda: { - "type": "object", - "properties": { - "release_id": {"type": "string"}, - }, - "required": ["release_id"], - } - ) - - async def call( - self, - context: ContextWrapper[AstrAgentContext], - release_id: str, - ) -> ToolExecResult: - return await self._run( - context, - lambda client, _sandbox: client.skills.rollback_release(release_id), - error_action="rolling back skill release", - ) - - -@dataclass -class SyncSkillReleaseTool(NeoSkillToolBase): - name: str = "astrbot_sync_skill_release" - description: str = ( - "Sync stable Neo release payload to local SKILL.md and update mapping metadata." - ) - parameters: dict = field( - default_factory=lambda: { - "type": "object", - "properties": { - "release_id": {"type": "string"}, - "skill_key": {"type": "string"}, - "require_stable": {"type": "boolean", "default": True}, - }, - "required": [], - } - ) - - async def call( - self, - context: ContextWrapper[AstrAgentContext], - release_id: str | None = None, - skill_key: str | None = None, - require_stable: bool = True, - ) -> ToolExecResult: - return await self._run( - context, - lambda client, _sandbox: _sync_release_to_dict( - client, - release_id=release_id, - skill_key=skill_key, - require_stable=require_stable, - ), - error_action="syncing skill release", - ) - - -async def _sync_release_to_dict( - client: Any, - *, - release_id: str | None, - skill_key: str | None, - require_stable: bool, -) -> dict[str, str]: - sync_mgr = NeoSkillSyncManager() - result = await sync_mgr.sync_release( - client, - release_id=release_id, - skill_key=skill_key, - require_stable=require_stable, - ) - return sync_mgr.sync_result_to_dict(result) diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index 37edeb312d..01d0be2af6 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -132,15 +132,11 @@ "computer_use_runtime": "none", "computer_use_require_admin": True, "sandbox": { - "booter": "shipyard_neo", + "booter": "shipyard", "shipyard_endpoint": "", "shipyard_access_token": "", "shipyard_ttl": 3600, "shipyard_max_sessions": 10, - "shipyard_neo_endpoint": "", - "shipyard_neo_access_token": "", - "shipyard_neo_profile": "python-default", - "shipyard_neo_ttl": 3600, }, }, # SubAgent orchestrator mode: @@ -2875,48 +2871,12 @@ class ChatProviderTemplate(TypedDict): "provider_settings.sandbox.booter": { "description": "沙箱环境驱动器", "type": "string", - "options": ["shipyard_neo", "shipyard"], - "labels": ["Shipyard Neo", "Shipyard"], + "options": ["shipyard"], + "labels": ["Shipyard"], "condition": { "provider_settings.computer_use_runtime": "sandbox", }, }, - "provider_settings.sandbox.shipyard_neo_endpoint": { - "description": "Shipyard Neo API Endpoint", - "type": "string", - "hint": "Shipyard Neo(Bay) 服务的 API 地址,默认 http://127.0.0.1:8114。", - "condition": { - "provider_settings.computer_use_runtime": "sandbox", - "provider_settings.sandbox.booter": "shipyard_neo", - }, - }, - "provider_settings.sandbox.shipyard_neo_access_token": { - "description": "Shipyard Neo Access Token", - "type": "string", - "hint": "Bay 的 API Key(sk-bay-...)。留空时自动从 credentials.json 发现。", - "condition": { - "provider_settings.computer_use_runtime": "sandbox", - "provider_settings.sandbox.booter": "shipyard_neo", - }, - }, - "provider_settings.sandbox.shipyard_neo_profile": { - "description": "Shipyard Neo Profile", - "type": "string", - "hint": "Shipyard Neo 沙箱 profile,如 python-default。", - "condition": { - "provider_settings.computer_use_runtime": "sandbox", - "provider_settings.sandbox.booter": "shipyard_neo", - }, - }, - "provider_settings.sandbox.shipyard_neo_ttl": { - "description": "Shipyard Neo Sandbox TTL", - "type": "int", - "hint": "Shipyard Neo 沙箱生存时间(秒)。", - "condition": { - "provider_settings.computer_use_runtime": "sandbox", - "provider_settings.sandbox.booter": "shipyard_neo", - }, - }, "provider_settings.sandbox.shipyard_endpoint": { "description": "Shipyard API Endpoint", "type": "string", diff --git a/astrbot/core/skills/neo_skill_sync.py b/astrbot/core/skills/neo_skill_sync.py deleted file mode 100644 index 5fe2b7832d..0000000000 --- a/astrbot/core/skills/neo_skill_sync.py +++ /dev/null @@ -1,372 +0,0 @@ -from __future__ import annotations - -import hashlib -import json -import os -import re -from dataclasses import dataclass -from datetime import datetime, timezone -from pathlib import Path -from typing import Any - -from astrbot.core.computer.computer_client import sync_skills_to_active_sandboxes -from astrbot.core.skills.skill_manager import SkillManager -from astrbot.core.utils.astrbot_path import get_astrbot_skills_path - -_MAP_VERSION = 1 -_MAP_FILE_NAME = "neo_skill_map.json" -_SKILL_NAME_RE = re.compile(r"[^a-zA-Z0-9._-]+") - - -def _now_iso() -> str: - return datetime.now(timezone.utc).isoformat() - - -def _to_jsonable(model_like: Any) -> dict[str, Any]: - if isinstance(model_like, dict): - return model_like - if hasattr(model_like, "model_dump"): - dumped = model_like.model_dump() - if isinstance(dumped, dict): - return dumped - return {} - - -def _parse_frontmatter(text: str) -> tuple[dict[str, str], str]: - if not text.startswith("---"): - return {}, text - - lines = text.splitlines() - if not lines or lines[0].strip() != "---": - return {}, text - - end_idx = None - for i in range(1, len(lines)): - if lines[i].strip() == "---": - end_idx = i - break - - if end_idx is None: - return {}, text - - data: dict[str, str] = {} - for line in lines[1:end_idx]: - if ":" not in line: - continue - key, value = line.split(":", 1) - key = key.strip().lower() - value = value.strip().strip('"').strip("'") - if key in {"name", "description"} and value: - data[key] = value - - body = "\n".join(lines[end_idx + 1 :]).lstrip("\n") - return data, body - - -def _derive_description(markdown_body: str) -> str: - lines = markdown_body.splitlines() - - heading_idx = None - for i, line in enumerate(lines): - normalized = line.strip().lower() - if normalized in {"## 描述", "## description"}: - heading_idx = i - break - - if heading_idx is not None: - for line in lines[heading_idx + 1 :]: - text = line.strip() - if not text: - continue - if text.startswith("#"): - break - return text - - for line in lines: - text = line.strip() - if not text or text.startswith("#"): - continue - return text - - return "" - - -def _ensure_skill_frontmatter(markdown: str, *, skill_name: str, skill_key: str) -> str: - frontmatter, body = _parse_frontmatter(markdown) - - name = frontmatter.get("name") or skill_name - name = " ".join(str(name).split()) - description = frontmatter.get("description") or _derive_description(body) - if not description: - description = f"Synced skill for `{skill_key}`." - - description = " ".join(description.split()) - - header = f"---\nname: {name}\ndescription: {description}\n---\n\n" - body = body.strip("\n") - return f"{header}{body}\n" - - -@dataclass -class NeoSkillSyncResult: - skill_key: str - local_skill_name: str - release_id: str - candidate_id: str - payload_ref: str - map_path: str - synced_at: str - - -class NeoSkillSyncManager: - @staticmethod - def sync_result_to_dict(result: NeoSkillSyncResult) -> dict[str, str]: - return { - "skill_key": result.skill_key, - "local_skill_name": result.local_skill_name, - "release_id": result.release_id, - "candidate_id": result.candidate_id, - "payload_ref": result.payload_ref, - "map_path": result.map_path, - "synced_at": result.synced_at, - } - - def __init__( - self, - *, - skills_root: str | None = None, - map_path: str | None = None, - ) -> None: - self.skills_root = skills_root or get_astrbot_skills_path() - self.map_path = map_path or str(Path(self.skills_root) / _MAP_FILE_NAME) - os.makedirs(self.skills_root, exist_ok=True) - - def _load_map(self) -> dict[str, Any]: - if not os.path.exists(self.map_path): - return {"version": _MAP_VERSION, "items": {}} - try: - with open(self.map_path, encoding="utf-8") as f: - data = json.load(f) - if not isinstance(data, dict): - return {"version": _MAP_VERSION, "items": {}} - items = data.get("items", {}) - if not isinstance(items, dict): - items = {} - return {"version": int(data.get("version", _MAP_VERSION)), "items": items} - except Exception: - return {"version": _MAP_VERSION, "items": {}} - - def _save_map(self, data: dict[str, Any]) -> None: - os.makedirs(os.path.dirname(self.map_path), exist_ok=True) - with open(self.map_path, "w", encoding="utf-8") as f: - json.dump(data, f, ensure_ascii=False, indent=2) - - @staticmethod - def normalize_skill_name(skill_key: str) -> str: - normalized = _SKILL_NAME_RE.sub("-", skill_key.strip().lower()) - normalized = normalized.strip("._-") - if not normalized: - normalized = "skill" - return f"neo_{normalized}" - - def _resolve_local_skill_name(self, skill_key: str, mapping: dict[str, Any]) -> str: - items = mapping.get("items", {}) - if not isinstance(items, dict): - items = {} - existing = items.get(skill_key) - if isinstance(existing, dict): - local_name = existing.get("local_skill_name") - if isinstance(local_name, str) and local_name: - return local_name - - base = self.normalize_skill_name(skill_key) - used_names = { - str(v.get("local_skill_name")) - for v in items.values() - if isinstance(v, dict) and v.get("local_skill_name") - } - if base not in used_names: - return base - suffix = hashlib.sha1(skill_key.encode("utf-8")).hexdigest()[:8] - return f"{base}-{suffix}" - - async def _find_release(self, client: Any, *, release_id: str) -> dict[str, Any]: - offset = 0 - while True: - page = await client.skills.list_releases(limit=100, offset=offset) - page_json = _to_jsonable(page) - items = page_json.get("items", []) - if not isinstance(items, list): - items = [] - for item in items: - if isinstance(item, dict) and item.get("id") == release_id: - return item - total = int(page_json.get("total", 0) or 0) - offset += len(items) - if offset >= total or not items: - break - raise ValueError(f"Release not found: {release_id}") - - async def _find_active_stable_release( - self, - client: Any, - *, - skill_key: str, - ) -> dict[str, Any]: - page = await client.skills.list_releases( - skill_key=skill_key, - active_only=True, - stage="stable", - limit=1, - offset=0, - ) - page_json = _to_jsonable(page) - items = page_json.get("items", []) - if not isinstance(items, list) or not items: - raise ValueError( - f"No active stable release found for skill_key: {skill_key}" - ) - if not isinstance(items[0], dict): - raise ValueError("Unexpected release payload format.") - return items[0] - - async def sync_release( - self, - client: Any, - *, - release_id: str | None = None, - skill_key: str | None = None, - require_stable: bool = True, - ) -> NeoSkillSyncResult: - if release_id: - release = await self._find_release(client, release_id=release_id) - elif skill_key: - release = await self._find_active_stable_release( - client, skill_key=skill_key - ) - else: - raise ValueError("release_id or skill_key is required for sync.") - - release_id_val = str(release.get("id") or "") - release_stage_raw = release.get("stage") - release_stage_value = getattr(release_stage_raw, "value", release_stage_raw) - release_stage = str(release_stage_value or "").strip().lower() - skill_key_val = str(release.get("skill_key") or "") - candidate_id = str(release.get("candidate_id") or "") - - if not release_id_val or not skill_key_val or not candidate_id: - raise ValueError("Release payload is incomplete.") - if require_stable and release_stage != "stable": - raise ValueError( - "Only stable releases can be synced to local SKILL.md " - f"(got: {release_stage_raw})." - ) - - candidate = await client.skills.get_candidate(candidate_id) - candidate_json = _to_jsonable(candidate) - payload_ref = candidate_json.get("payload_ref") - if not isinstance(payload_ref, str) or not payload_ref: - raise ValueError("Candidate payload_ref is missing.") - - payload_resp = await client.skills.get_payload(payload_ref) - payload_json = _to_jsonable(payload_resp) - payload = payload_json.get("payload") - if not isinstance(payload, dict): - raise ValueError("Skill payload must be a JSON object.") - - skill_markdown = payload.get("skill_markdown") - if not isinstance(skill_markdown, str) or not skill_markdown.strip(): - raise ValueError( - "payload.skill_markdown is required for stable sync to local skill." - ) - - mapping = self._load_map() - local_skill_name = self._resolve_local_skill_name(skill_key_val, mapping) - skill_dir = Path(self.skills_root) / local_skill_name - skill_dir.mkdir(parents=True, exist_ok=True) - - normalized_markdown = _ensure_skill_frontmatter( - skill_markdown, - skill_name=local_skill_name, - skill_key=skill_key_val, - ) - - skill_md_path = skill_dir / "SKILL.md" - skill_md_path.write_text(normalized_markdown, encoding="utf-8") - - items = mapping.setdefault("items", {}) - items[skill_key_val] = { - "local_skill_name": local_skill_name, - "latest_release_id": release_id_val, - "latest_candidate_id": candidate_id, - "latest_payload_ref": payload_ref, - "updated_at": _now_iso(), - } - mapping["version"] = _MAP_VERSION - self._save_map(mapping) - - # Ensure local skill is visible to AstrBot skill manager. - SkillManager().set_skill_active(local_skill_name, True) - - # Best-effort synchronization to active sandboxes. - await sync_skills_to_active_sandboxes() - - return NeoSkillSyncResult( - skill_key=skill_key_val, - local_skill_name=local_skill_name, - release_id=release_id_val, - candidate_id=candidate_id, - payload_ref=payload_ref, - map_path=self.map_path, - synced_at=_now_iso(), - ) - - async def promote_with_optional_sync( - self, - client: Any, - *, - candidate_id: str, - stage: str, - sync_to_local: bool, - ) -> dict[str, Any]: - release = await client.skills.promote_candidate(candidate_id, stage=stage) - release_json = _to_jsonable(release) - - sync_json: dict[str, Any] | None = None - rollback_json: dict[str, Any] | None = None - sync_error: str | None = None - - if stage == "stable" and sync_to_local: - try: - sync_result = await self.sync_release( - client, - release_id=str(release_json.get("id", "")), - require_stable=True, - ) - sync_json = self.sync_result_to_dict(sync_result) - except Exception as err: - sync_error = str(err) - try: - rollback = await client.skills.rollback_release( - str(release_json.get("id", "")) - ) - rollback_json = _to_jsonable(rollback) - except Exception as rollback_err: - rollback_msg = str(rollback_err) - if "no previous release exists" in rollback_msg.lower(): - rollback_json = { - "skipped": True, - "reason": rollback_msg, - } - else: - raise RuntimeError( - "stable release synced failed and auto rollback also failed; " - f"sync_error={sync_error}; rollback_error={rollback_err}" - ) from rollback_err - - return { - "release": release_json, - "sync": sync_json, - "rollback": rollback_json, - "sync_error": sync_error, - } diff --git a/astrbot/core/skills/skill_manager.py b/astrbot/core/skills/skill_manager.py index d15876526d..85190ecdfb 100644 --- a/astrbot/core/skills/skill_manager.py +++ b/astrbot/core/skills/skill_manager.py @@ -7,7 +7,6 @@ import tempfile import zipfile from dataclasses import dataclass -from datetime import datetime, timezone from pathlib import Path, PurePosixPath from astrbot.core.utils.astrbot_path import ( @@ -17,11 +16,9 @@ ) SKILLS_CONFIG_FILENAME = "skills.json" -SANDBOX_SKILLS_CACHE_FILENAME = "sandbox_skills_cache.json" DEFAULT_SKILLS_CONFIG: dict[str, dict] = {"skills": {}} +# SANDBOX_SKILLS_ROOT = "/home/shared/skills" SANDBOX_SKILLS_ROOT = "skills" -SANDBOX_WORKSPACE_ROOT = "/workspace" -_SANDBOX_SKILLS_CACHE_VERSION = 1 _SKILL_NAME_RE = re.compile(r"^[A-Za-z0-9._-]+$") @@ -32,23 +29,9 @@ class SkillInfo: description: str path: str active: bool - source_type: str = "local_only" - source_label: str = "local" - local_exists: bool = True - sandbox_exists: bool = False def _parse_frontmatter_description(text: str) -> str: - """Extract the ``description`` value from YAML frontmatter. - - Expects the standard SKILL.md format used by OpenAI Codex CLI and - Anthropic Claude Skills:: - - --- - name: my-skill - description: What this skill does and when to use it. - --- - """ if not text.startswith("---"): return "" lines = text.splitlines() @@ -70,74 +53,45 @@ def _parse_frontmatter_description(text: str) -> str: return "" -# Regex for sanitizing paths used in prompt examples — only allow -# safe path characters to prevent prompt injection via crafted skill paths. -_SAFE_PATH_RE = re.compile(r"[^A-Za-z0-9_./ -]") - - def build_skills_prompt(skills: list[SkillInfo]) -> str: - """Build the skills section of the system prompt. - - Generates a markdown-formatted skill inventory for the LLM. Only - ``name`` and ``description`` are shown upfront; the LLM must read - the full ``SKILL.md`` before execution (progressive disclosure). - """ - skills_lines: list[str] = [] - example_path = "" + skills_lines = [] for skill in skills: description = skill.description or "No description" - skills_lines.append( - f"- **{skill.name}**: {description}\n File: `{skill.path}`" - ) - if not example_path: - example_path = skill.path + skills_lines.append(f"- {skill.name}: {description} (file: {skill.path})") skills_block = "\n".join(skills_lines) - # Sanitize example_path — it may originate from sandbox cache (untrusted) - example_path = _SAFE_PATH_RE.sub("", example_path) if example_path else "" - example_path = example_path or "//SKILL.md" - + # Based on openai/codex return ( - "## Skills\n\n" - "You have specialized skills — reusable instruction bundles stored " - "in `SKILL.md` files. Each skill has a **name** and a **description** " - "that tells you what it does and when to use it.\n\n" - "### Available skills\n\n" - f"{skills_block}\n\n" - "### Skill rules\n\n" - "1. **Discovery** — The list above is the complete skill inventory " - "for this session. Full instructions are in the referenced " - "`SKILL.md` file.\n" - "2. **When to trigger** — Use a skill if the user names it " - "explicitly, or if the task clearly matches the skill's description. " - "*Never silently skip a matching skill* — either use it or briefly " - "explain why you chose not to.\n" - "3. **Mandatory grounding** — Before executing any skill you MUST " - "first read its `SKILL.md` by running a shell command with the " - f"**absolute path** shown above (e.g. `cat {example_path}`). " - "Never rely on memory or assumptions about a skill's content.\n" - "4. **Progressive disclosure** — Load only what is directly " - "referenced from `SKILL.md`:\n" - " - If `scripts/` exist, prefer running or patching them over " - "rewriting code from scratch.\n" - " - If `assets/` or templates exist, reuse them.\n" - " - Do NOT bulk-load every file in the skill directory.\n" - "5. **Coordination** — When multiple skills apply, pick the minimal " - "set needed. Announce which skill(s) you are using and why " - "(one short line). Prefer `astrbot_*` tools when running skill " - "scripts.\n" - "6. **Context hygiene** — Avoid deep reference chasing; open only " - "files that are directly linked from `SKILL.md`.\n" - "7. **Failure handling** — If a skill cannot be applied, state the " - "issue clearly and continue with the best alternative.\n" + "## Skills\n" + "You have many useful skills that can help you accomplish various tasks.\n" + "A skill is a set of local instructions stored in a `SKILL.md` file.\n" + "### Available skills\n" + f"{skills_block}\n" + "### Skill Rules\n" + "\n" + "- Discovery: The list above shows all skills available in this session. Full instructions live in the referenced `SKILL.md`.\n" + "- Trigger rules: Use a skill if the user names it or the task matches its description. Do not carry skills across turns unless re-mentioned\n" + "### How to use a skill (progressive disclosure):\n" + " 0) Mandatory grounding: Before using any skill, you MUST inspect its `SKILL.md` using shell tools" + " (e.g., `cat`, `head`, `sed`, `awk`, `grep`). Do not rely on assumptions or memory.\n" + " 1) Load only directly referenced files, DO NOT bulk-load everything.\n" + " 2) If `scripts/` exist, prefer running or patching them instead of retyping large blocks of code.\n" + " 3) If `assets/` or templates exist, reuse them rather than recreating everything from scratch.\n" + "- Coordination:\n" + " - If multiple skills apply, choose the minimal set that covers the request and state the order in which you will use them.\n" + " - Announce which skill(s) you are using and why (one short line). If you skip an obvious skill, explain why.\n" + " - Prefer to use `astrbot_*` tools to perform skills that need to run scripts.\n" + "- Context hygiene:\n" + " - Avoid deep reference chasing: unless blocked, open only files that are directly linked from `SKILL.md`.\n" + "- Failure handling: If a skill cannot be applied, state the issue and continue with the best alternative.\n" + "### Example\n" + "When you decided to use a skill, use shell tool to read its `SKILL.md`, e.g., `head -40 skills/code_formatter/SKILL.md`, and you can increase or decrease the number of lines as needed.\n" ) class SkillManager: def __init__(self, skills_root: str | None = None) -> None: self.skills_root = skills_root or get_astrbot_skills_path() - data_path = Path(get_astrbot_data_path()) - self.config_path = str(data_path / SKILLS_CONFIG_FILENAME) - self.sandbox_skills_cache_path = str(data_path / SANDBOX_SKILLS_CACHE_FILENAME) + self.config_path = os.path.join(get_astrbot_data_path(), SKILLS_CONFIG_FILENAME) os.makedirs(self.skills_root, exist_ok=True) def _load_config(self) -> dict: @@ -154,66 +108,6 @@ def _save_config(self, config: dict) -> None: with open(self.config_path, "w", encoding="utf-8") as f: json.dump(config, f, ensure_ascii=False, indent=4) - def _load_sandbox_skills_cache(self) -> dict: - if not os.path.exists(self.sandbox_skills_cache_path): - return {"version": _SANDBOX_SKILLS_CACHE_VERSION, "skills": []} - try: - with open(self.sandbox_skills_cache_path, encoding="utf-8") as f: - data = json.load(f) - if not isinstance(data, dict): - return {"version": _SANDBOX_SKILLS_CACHE_VERSION, "skills": []} - skills = data.get("skills", []) - if not isinstance(skills, list): - skills = [] - return { - "version": int(data.get("version", _SANDBOX_SKILLS_CACHE_VERSION)), - "skills": skills, - "updated_at": data.get("updated_at"), - } - except Exception: - return {"version": _SANDBOX_SKILLS_CACHE_VERSION, "skills": []} - - def _save_sandbox_skills_cache(self, cache: dict) -> None: - cache["version"] = _SANDBOX_SKILLS_CACHE_VERSION - cache["updated_at"] = datetime.now(timezone.utc).isoformat() - with open(self.sandbox_skills_cache_path, "w", encoding="utf-8") as f: - json.dump(cache, f, ensure_ascii=False, indent=2) - - def set_sandbox_skills_cache(self, skills: list[dict]) -> None: - """Persist sandbox skill metadata discovered from runtime side.""" - deduped: dict[str, dict[str, str]] = {} - for item in skills: - if not isinstance(item, dict): - continue - name = str(item.get("name", "")).strip() - if not name or not _SKILL_NAME_RE.match(name): - continue - description = str(item.get("description", "") or "") - path = str(item.get("path", "") or "") - if not path: - path = f"{SANDBOX_WORKSPACE_ROOT}/{SANDBOX_SKILLS_ROOT}/{name}/SKILL.md" - deduped[name] = { - "name": name, - "description": description, - "path": path.replace("\\", "/"), - } - cache = { - "version": _SANDBOX_SKILLS_CACHE_VERSION, - "skills": [deduped[name] for name in sorted(deduped)], - } - self._save_sandbox_skills_cache(cache) - - def get_sandbox_skills_cache_status(self) -> dict[str, object]: - cache = self._load_sandbox_skills_cache() - skills = cache.get("skills", []) - count = len(skills) if isinstance(skills, list) else 0 - return { - "exists": os.path.exists(self.sandbox_skills_cache_path), - "ready": count > 0, - "count": count, - "updated_at": cache.get("updated_at"), - } - def list_skills( self, *, @@ -230,21 +124,7 @@ def list_skills( config = self._load_config() skill_configs = config.get("skills", {}) modified = False - skills_by_name: dict[str, SkillInfo] = {} - - sandbox_cached_paths: dict[str, str] = {} - sandbox_cached_descriptions: dict[str, str] = {} - cache_for_paths = self._load_sandbox_skills_cache() - for item in cache_for_paths.get("skills", []): - if not isinstance(item, dict): - continue - name = str(item.get("name", "") or "").strip() - path = str(item.get("path", "") or "").strip().replace("\\", "/") - if not name or not _SKILL_NAME_RE.match(name): - continue - sandbox_cached_descriptions[name] = str(item.get("description", "") or "") - if path: - sandbox_cached_paths[name] = path + skills: list[SkillInfo] = [] for entry in sorted(Path(self.skills_root).iterdir()): if not entry.is_dir(): @@ -265,129 +145,36 @@ def list_skills( description = _parse_frontmatter_description(content) except Exception: description = "" - sandbox_exists = ( - runtime == "sandbox" and skill_name in sandbox_cached_descriptions - ) - source_type = "both" if sandbox_exists else "local_only" - source_label = "synced" if sandbox_exists else "local" if runtime == "sandbox" and show_sandbox_path: - path_str = sandbox_cached_paths.get(skill_name) or ( - f"{SANDBOX_WORKSPACE_ROOT}/{SANDBOX_SKILLS_ROOT}/{skill_name}/SKILL.md" - ) + path_str = f"{SANDBOX_SKILLS_ROOT}/{skill_name}/SKILL.md" else: path_str = str(skill_md) path_str = path_str.replace("\\", "/") - skills_by_name[skill_name] = SkillInfo( - name=skill_name, - description=description, - path=path_str, - active=active, - source_type=source_type, - source_label=source_label, - local_exists=True, - sandbox_exists=sandbox_exists, - ) - - if runtime == "sandbox": - cache = self._load_sandbox_skills_cache() - for item in cache.get("skills", []): - if not isinstance(item, dict): - continue - skill_name = str(item.get("name", "")).strip() - if ( - not skill_name - or skill_name in skills_by_name - or not _SKILL_NAME_RE.match(skill_name) - ): - continue - active = skill_configs.get(skill_name, {}).get("active", True) - if skill_name not in skill_configs: - skill_configs[skill_name] = {"active": active} - modified = True - if active_only and not active: - continue - description = sandbox_cached_descriptions.get(skill_name, "") - if show_sandbox_path: - path_str = f"{SANDBOX_WORKSPACE_ROOT}/{SANDBOX_SKILLS_ROOT}/{skill_name}/SKILL.md" - else: - path_str = sandbox_cached_paths.get(skill_name, "") - if not path_str: - path_str = f"{SANDBOX_WORKSPACE_ROOT}/{SANDBOX_SKILLS_ROOT}/{skill_name}/SKILL.md" - skills_by_name[skill_name] = SkillInfo( + skills.append( + SkillInfo( name=skill_name, description=description, - path=path_str.replace("\\", "/"), + path=path_str, active=active, - source_type="sandbox_only", - source_label="sandbox_preset", - local_exists=False, - sandbox_exists=True, ) + ) if modified: config["skills"] = skill_configs self._save_config(config) - return [skills_by_name[name] for name in sorted(skills_by_name)] - - def is_sandbox_only_skill(self, name: str) -> bool: - skill_dir = Path(self.skills_root) / name - skill_md_exists = (skill_dir / "SKILL.md").exists() - if skill_md_exists: - return False - cache = self._load_sandbox_skills_cache() - skills = cache.get("skills", []) - if not isinstance(skills, list): - return False - for item in skills: - if not isinstance(item, dict): - continue - if str(item.get("name", "")).strip() == name: - return True - return False + return skills def set_skill_active(self, name: str, active: bool) -> None: - if self.is_sandbox_only_skill(name): - raise PermissionError( - "Sandbox preset skill cannot be enabled/disabled from local skill management." - ) config = self._load_config() config.setdefault("skills", {}) config["skills"][name] = {"active": bool(active)} self._save_config(config) - def _remove_skill_from_sandbox_cache(self, name: str) -> None: - cache = self._load_sandbox_skills_cache() - skills = cache.get("skills", []) - if not isinstance(skills, list): - return - - filtered = [ - item - for item in skills - if not ( - isinstance(item, dict) and str(item.get("name", "")).strip() == name - ) - ] - - if len(filtered) != len(skills): - cache["skills"] = filtered - self._save_sandbox_skills_cache(cache) - def delete_skill(self, name: str) -> None: - if self.is_sandbox_only_skill(name): - raise PermissionError( - "Sandbox preset skill cannot be deleted from local skill management." - ) - skill_dir = Path(self.skills_root) / name if skill_dir.exists(): shutil.rmtree(skill_dir) - - # Ensure UI consistency even when there is no active sandbox session - # to refresh cache from runtime side. - self._remove_skill_from_sandbox_cache(name) - config = self._load_config() if name in config.get("skills", {}): config["skills"].pop(name, None) @@ -409,7 +196,7 @@ def install_skill_from_zip(self, zip_path: str, *, overwrite: bool = True) -> st top_dirs = { PurePosixPath(name).parts[0] for name in file_names if name.strip() } - + print(top_dirs) if len(top_dirs) != 1: raise ValueError("Zip archive must contain a single top-level folder.") skill_name = next(iter(top_dirs)) diff --git a/astrbot/dashboard/routes/config.py b/astrbot/dashboard/routes/config.py index 823d0fb9dd..08b8c12b83 100644 --- a/astrbot/dashboard/routes/config.py +++ b/astrbot/dashboard/routes/config.py @@ -206,110 +206,12 @@ def validate(data: dict, metadata: dict = schema, path="") -> None: return errors, data -def _log_computer_config_changes(old_config: dict, new_config: dict) -> None: - """Compare and log Computer/sandbox configuration changes.""" - old_ps = old_config.get("provider_settings", {}) - new_ps = new_config.get("provider_settings", {}) - - # Check computer_use_runtime - old_runtime = old_ps.get("computer_use_runtime", "none") - new_runtime = new_ps.get("computer_use_runtime", "none") - if old_runtime != new_runtime: - logger.info( - "[Computer] Config changed: computer_use_runtime %s -> %s", - old_runtime, - new_runtime, - ) - - # Check sandbox sub-keys - old_sandbox = old_ps.get("sandbox", {}) - new_sandbox = new_ps.get("sandbox", {}) - all_keys = set(old_sandbox.keys()) | set(new_sandbox.keys()) - for key in sorted(all_keys): - old_val = old_sandbox.get(key) - new_val = new_sandbox.get(key) - if old_val != new_val: - # Mask tokens/secrets in log output - if "token" in key or "secret" in key: - old_display = "***" if old_val else "(empty)" - new_display = "***" if new_val else "(empty)" - else: - old_display = old_val - new_display = new_val - logger.info( - "[Computer] Config changed: sandbox.%s %s -> %s", - key, - old_display, - new_display, - ) - - -async def _validate_neo_connectivity( - post_config: dict, -) -> str | None: - """Check if Bay is reachable when Shipyard Neo sandbox is configured. - - Returns a warning message string if Bay isn't reachable, or None if - everything looks fine (or Neo isn't configured). - """ - ps = post_config.get("provider_settings", {}) - runtime = ps.get("computer_use_runtime", "none") - sandbox = ps.get("sandbox", {}) - booter = sandbox.get("booter", "") - - # Only check when sandbox mode + shipyard_neo is selected - if runtime != "sandbox" or booter != "shipyard_neo": - return None - - endpoint = sandbox.get("shipyard_neo_endpoint", "").rstrip("/") - if not endpoint: - return "⚠️ Shipyard Neo endpoint 未设置" - - access_token = sandbox.get("shipyard_neo_access_token", "") - if not access_token: - # Try auto-discovery - from astrbot.core.computer.computer_client import _discover_bay_credentials - - access_token = _discover_bay_credentials(endpoint) - - if not access_token: - return ( - "⚠️ 未找到 Bay API Key。请填写访问令牌," - "或确保 Bay 的 credentials.json 可被自动发现。" - ) - - # Connectivity check - import aiohttp - - health_url = f"{endpoint}/health" - try: - async with aiohttp.ClientSession() as session: - async with session.get( - health_url, - timeout=aiohttp.ClientTimeout(total=5), - ) as resp: - if resp.status != 200: - return ( - f"⚠️ Bay 健康检查失败 (HTTP {resp.status})," - f"请确认 Bay 正在运行: {endpoint}" - ) - except Exception: - return f"⚠️ 无法连接 Bay ({endpoint}),请确认 Bay 已启动。" - - return None - - def save_config( post_config: dict, config: AstrBotConfig, is_core: bool = False ) -> None: """验证并保存配置""" errors = None logger.info(f"Saving config, is_core={is_core}") - - # Snapshot old Computer config for change detection - if is_core: - _log_computer_config_changes(dict(config), post_config) - try: if is_core: errors, post_config = validate_config( @@ -1026,11 +928,6 @@ async def post_astrbot_configs(self): await self._save_astrbot_configs(config, conf_id) await self.core_lifecycle.reload_pipeline_scheduler(conf_id) - - # Non-blocking Bay connectivity check - warning = await _validate_neo_connectivity(config) - if warning: - return Response().ok(None, f"保存成功。{warning}").__dict__ return Response().ok(None, "保存成功~").__dict__ except Exception as e: logger.error(traceback.format_exc()) diff --git a/astrbot/dashboard/routes/skills.py b/astrbot/dashboard/routes/skills.py index adad49615f..5604d3d825 100644 --- a/astrbot/dashboard/routes/skills.py +++ b/astrbot/dashboard/routes/skills.py @@ -1,48 +1,15 @@ import os -import re -import shutil import traceback -from collections.abc import Awaitable, Callable -from pathlib import Path -from typing import Any -from quart import request, send_file +from quart import request from astrbot.core import DEMO_MODE, logger -from astrbot.core.computer.computer_client import ( - _discover_bay_credentials, - sync_skills_to_active_sandboxes, -) -from astrbot.core.skills.neo_skill_sync import NeoSkillSyncManager from astrbot.core.skills.skill_manager import SkillManager from astrbot.core.utils.astrbot_path import get_astrbot_temp_path from .route import Response, Route, RouteContext -def _to_jsonable(value: Any) -> Any: - if isinstance(value, dict): - return {k: _to_jsonable(v) for k, v in value.items()} - if isinstance(value, list): - return [_to_jsonable(v) for v in value] - if hasattr(value, "model_dump"): - return _to_jsonable(value.model_dump()) - return value - - -def _to_bool(value: Any, default: bool = False) -> bool: - if value is None: - return default - if isinstance(value, bool): - return value - if isinstance(value, str): - return value.strip().lower() in {"1", "true", "yes", "y", "on"} - return bool(value) - - -_SKILL_NAME_RE = re.compile(r"^[A-Za-z0-9._-]+$") - - class SkillsRoute(Route): def __init__(self, context: RouteContext, core_lifecycle) -> None: super().__init__(context) @@ -50,81 +17,18 @@ def __init__(self, context: RouteContext, core_lifecycle) -> None: self.routes = { "/skills": ("GET", self.get_skills), "/skills/upload": ("POST", self.upload_skill), - "/skills/download": ("GET", self.download_skill), "/skills/update": ("POST", self.update_skill), "/skills/delete": ("POST", self.delete_skill), - "/skills/neo/candidates": ("GET", self.get_neo_candidates), - "/skills/neo/releases": ("GET", self.get_neo_releases), - "/skills/neo/payload": ("GET", self.get_neo_payload), - "/skills/neo/evaluate": ("POST", self.evaluate_neo_candidate), - "/skills/neo/promote": ("POST", self.promote_neo_candidate), - "/skills/neo/rollback": ("POST", self.rollback_neo_release), - "/skills/neo/sync": ("POST", self.sync_neo_release), - "/skills/neo/delete-candidate": ("POST", self.delete_neo_candidate), - "/skills/neo/delete-release": ("POST", self.delete_neo_release), } self.register_routes() - def _get_neo_client_config(self) -> tuple[str, str]: - provider_settings = self.core_lifecycle.astrbot_config.get( - "provider_settings", - {}, - ) - sandbox = provider_settings.get("sandbox", {}) - endpoint = sandbox.get("shipyard_neo_endpoint", "") - access_token = sandbox.get("shipyard_neo_access_token", "") - - # Auto-discover token from Bay's credentials.json if not configured - if not access_token and endpoint: - access_token = _discover_bay_credentials(endpoint) - - if not endpoint or not access_token: - raise ValueError( - "Shipyard Neo endpoint or access token not configured. " - "Set them in Dashboard or ensure Bay's credentials.json is accessible." - ) - return endpoint, access_token - - async def _delete_neo_release( - self, client: Any, release_id: str, reason: str | None - ): - return await client.skills.delete_release(release_id, reason=reason) - - async def _delete_neo_candidate( - self, client: Any, candidate_id: str, reason: str | None - ): - return await client.skills.delete_candidate(candidate_id, reason=reason) - - async def _with_neo_client( - self, - operation: Callable[[Any], Awaitable[dict]], - ) -> dict: - try: - endpoint, access_token = self._get_neo_client_config() - - from shipyard_neo import BayClient - - async with BayClient( - endpoint_url=endpoint, - access_token=access_token, - ) as client: - return await operation(client) - except ValueError as e: - # Config not ready — expected when Neo isn't set up yet - logger.debug("[Neo] %s", e) - return Response().error(str(e)).__dict__ - except Exception as e: - logger.error(traceback.format_exc()) - return Response().error(str(e)).__dict__ - async def get_skills(self): try: provider_settings = self.core_lifecycle.astrbot_config.get( "provider_settings", {} ) runtime = provider_settings.get("computer_use_runtime", "local") - skill_mgr = SkillManager() - skills = skill_mgr.list_skills( + skills = SkillManager().list_skills( active_only=False, runtime=runtime, show_sandbox_path=False ) return ( @@ -132,8 +36,6 @@ async def get_skills(self): .ok( { "skills": [skill.__dict__ for skill in skills], - "runtime": runtime, - "sandbox_cache": skill_mgr.get_sandbox_skills_cache_status(), } ) .__dict__ @@ -168,11 +70,6 @@ async def upload_skill(self): skill_mgr = SkillManager() skill_name = skill_mgr.install_skill_from_zip(temp_path, overwrite=True) - try: - await sync_skills_to_active_sandboxes() - except Exception: - logger.warning("Failed to sync uploaded skills to active sandboxes.") - return ( Response() .ok({"name": skill_name}, "Skill uploaded successfully.") @@ -188,53 +85,6 @@ async def upload_skill(self): except Exception: logger.warning(f"Failed to remove temp skill file: {temp_path}") - async def download_skill(self): - try: - name = str(request.args.get("name") or "").strip() - if not name: - return Response().error("Missing skill name").__dict__ - if not _SKILL_NAME_RE.match(name): - return Response().error("Invalid skill name").__dict__ - - skill_mgr = SkillManager() - if skill_mgr.is_sandbox_only_skill(name): - return ( - Response() - .error( - "Sandbox preset skill cannot be downloaded from local skill files." - ) - .__dict__ - ) - - skill_dir = Path(skill_mgr.skills_root) / name - skill_md = skill_dir / "SKILL.md" - if not skill_dir.is_dir() or not skill_md.exists(): - return Response().error("Local skill not found").__dict__ - - export_dir = Path(get_astrbot_temp_path()) / "skill_exports" - export_dir.mkdir(parents=True, exist_ok=True) - zip_base = export_dir / name - zip_path = zip_base.with_suffix(".zip") - if zip_path.exists(): - zip_path.unlink() - - shutil.make_archive( - str(zip_base), - "zip", - root_dir=str(skill_mgr.skills_root), - base_dir=name, - ) - - return await send_file( - str(zip_path), - as_attachment=True, - attachment_filename=f"{name}.zip", - conditional=True, - ) - except Exception as e: - logger.error(traceback.format_exc()) - return Response().error(str(e)).__dict__ - async def update_skill(self): if DEMO_MODE: return ( @@ -267,262 +117,7 @@ async def delete_skill(self): if not name: return Response().error("Missing skill name").__dict__ SkillManager().delete_skill(name) - try: - await sync_skills_to_active_sandboxes() - except Exception: - logger.warning("Failed to sync deleted skills to active sandboxes.") return Response().ok({"name": name}).__dict__ except Exception as e: logger.error(traceback.format_exc()) return Response().error(str(e)).__dict__ - - async def get_neo_candidates(self): - logger.info("[Neo] GET /skills/neo/candidates requested.") - status = request.args.get("status") - skill_key = request.args.get("skill_key") - limit = int(request.args.get("limit", 100)) - offset = int(request.args.get("offset", 0)) - - async def _do(client): - candidates = await client.skills.list_candidates( - status=status, - skill_key=skill_key, - limit=limit, - offset=offset, - ) - result = _to_jsonable(candidates) - total = result.get("total", "?") if isinstance(result, dict) else "?" - logger.info(f"[Neo] Candidates fetched: total={total}") - return Response().ok(result).__dict__ - - return await self._with_neo_client(_do) - - async def get_neo_releases(self): - logger.info("[Neo] GET /skills/neo/releases requested.") - skill_key = request.args.get("skill_key") - stage = request.args.get("stage") - active_only = _to_bool(request.args.get("active_only"), False) - limit = int(request.args.get("limit", 100)) - offset = int(request.args.get("offset", 0)) - - async def _do(client): - releases = await client.skills.list_releases( - skill_key=skill_key, - active_only=active_only, - stage=stage, - limit=limit, - offset=offset, - ) - result = _to_jsonable(releases) - total = result.get("total", "?") if isinstance(result, dict) else "?" - logger.info(f"[Neo] Releases fetched: total={total}") - return Response().ok(result).__dict__ - - return await self._with_neo_client(_do) - - async def get_neo_payload(self): - logger.info("[Neo] GET /skills/neo/payload requested.") - payload_ref = request.args.get("payload_ref", "") - if not payload_ref: - return Response().error("Missing payload_ref").__dict__ - - async def _do(client): - payload = await client.skills.get_payload(payload_ref) - logger.info(f"[Neo] Payload fetched: ref={payload_ref}") - return Response().ok(_to_jsonable(payload)).__dict__ - - return await self._with_neo_client(_do) - - async def evaluate_neo_candidate(self): - if DEMO_MODE: - return ( - Response() - .error("You are not permitted to do this operation in demo mode") - .__dict__ - ) - logger.info("[Neo] POST /skills/neo/evaluate requested.") - data = await request.get_json() - candidate_id = data.get("candidate_id") - passed_value = data.get("passed") - if not candidate_id or passed_value is None: - return Response().error("Missing candidate_id or passed").__dict__ - passed = _to_bool(passed_value, False) - - async def _do(client): - result = await client.skills.evaluate_candidate( - candidate_id, - passed=passed, - score=data.get("score"), - benchmark_id=data.get("benchmark_id"), - report=data.get("report"), - ) - logger.info( - f"[Neo] Candidate evaluated: id={candidate_id}, passed={passed}" - ) - return Response().ok(_to_jsonable(result)).__dict__ - - return await self._with_neo_client(_do) - - async def promote_neo_candidate(self): - if DEMO_MODE: - return ( - Response() - .error("You are not permitted to do this operation in demo mode") - .__dict__ - ) - logger.info("[Neo] POST /skills/neo/promote requested.") - data = await request.get_json() - candidate_id = data.get("candidate_id") - stage = data.get("stage", "canary") - sync_to_local = _to_bool(data.get("sync_to_local"), True) - if not candidate_id: - return Response().error("Missing candidate_id").__dict__ - if stage not in {"canary", "stable"}: - return Response().error("Invalid stage, must be canary/stable").__dict__ - - async def _do(client): - sync_mgr = NeoSkillSyncManager() - result = await sync_mgr.promote_with_optional_sync( - client, - candidate_id=candidate_id, - stage=stage, - sync_to_local=sync_to_local, - ) - release_json = result.get("release") - logger.info(f"[Neo] Candidate promoted: id={candidate_id}, stage={stage}") - - sync_json = result.get("sync") - did_sync_to_local = bool(sync_json) - if did_sync_to_local: - logger.info( - f"[Neo] Stable release synced to local: skill={sync_json.get('local_skill_name', '')}" - ) - - if result.get("sync_error"): - resp = Response().error( - "Stable promote synced failed and has been rolled back. " - f"sync_error={result['sync_error']}" - ) - resp.data = { - "release": release_json, - "rollback": result.get("rollback"), - } - return resp.__dict__ - - # Try to push latest local skills to all active sandboxes. - if not did_sync_to_local: - try: - await sync_skills_to_active_sandboxes() - except Exception: - logger.warning("Failed to sync skills to active sandboxes.") - - return Response().ok({"release": release_json, "sync": sync_json}).__dict__ - - return await self._with_neo_client(_do) - - async def rollback_neo_release(self): - if DEMO_MODE: - return ( - Response() - .error("You are not permitted to do this operation in demo mode") - .__dict__ - ) - logger.info("[Neo] POST /skills/neo/rollback requested.") - data = await request.get_json() - release_id = data.get("release_id") - if not release_id: - return Response().error("Missing release_id").__dict__ - - async def _do(client): - result = await client.skills.rollback_release(release_id) - logger.info(f"[Neo] Release rolled back: id={release_id}") - return Response().ok(_to_jsonable(result)).__dict__ - - return await self._with_neo_client(_do) - - async def sync_neo_release(self): - if DEMO_MODE: - return ( - Response() - .error("You are not permitted to do this operation in demo mode") - .__dict__ - ) - logger.info("[Neo] POST /skills/neo/sync requested.") - data = await request.get_json() - release_id = data.get("release_id") - skill_key = data.get("skill_key") - require_stable = _to_bool(data.get("require_stable"), True) - if not release_id and not skill_key: - return Response().error("Missing release_id or skill_key").__dict__ - - async def _do(client): - sync_mgr = NeoSkillSyncManager() - result = await sync_mgr.sync_release( - client, - release_id=release_id, - skill_key=skill_key, - require_stable=require_stable, - ) - logger.info( - f"[Neo] Release synced to local: skill={result.local_skill_name}, " - f"release_id={result.release_id}" - ) - return ( - Response() - .ok( - { - "skill_key": result.skill_key, - "local_skill_name": result.local_skill_name, - "release_id": result.release_id, - "candidate_id": result.candidate_id, - "payload_ref": result.payload_ref, - "map_path": result.map_path, - "synced_at": result.synced_at, - } - ) - .__dict__ - ) - - return await self._with_neo_client(_do) - - async def delete_neo_candidate(self): - if DEMO_MODE: - return ( - Response() - .error("You are not permitted to do this operation in demo mode") - .__dict__ - ) - logger.info("[Neo] POST /skills/neo/delete-candidate requested.") - data = await request.get_json() - candidate_id = data.get("candidate_id") - reason = data.get("reason") - if not candidate_id: - return Response().error("Missing candidate_id").__dict__ - - async def _do(client): - result = await self._delete_neo_candidate(client, candidate_id, reason) - logger.info(f"[Neo] Candidate deleted: id={candidate_id}") - return Response().ok(_to_jsonable(result)).__dict__ - - return await self._with_neo_client(_do) - - async def delete_neo_release(self): - if DEMO_MODE: - return ( - Response() - .error("You are not permitted to do this operation in demo mode") - .__dict__ - ) - logger.info("[Neo] POST /skills/neo/delete-release requested.") - data = await request.get_json() - release_id = data.get("release_id") - reason = data.get("reason") - if not release_id: - return Response().error("Missing release_id").__dict__ - - async def _do(client): - result = await self._delete_neo_release(client, release_id, reason) - logger.info(f"[Neo] Release deleted: id={release_id}") - return Response().ok(_to_jsonable(result)).__dict__ - - return await self._with_neo_client(_do) diff --git a/dashboard/src/components/extension/SkillsSection.vue b/dashboard/src/components/extension/SkillsSection.vue index d8ec137e08..becd09ea3a 100644 --- a/dashboard/src/components/extension/SkillsSection.vue +++ b/dashboard/src/components/extension/SkillsSection.vue @@ -1,295 +1,60 @@