diff --git a/.gitignore b/.gitignore index e3ffbd473..ef33bfa57 100644 --- a/.gitignore +++ b/.gitignore @@ -54,3 +54,7 @@ IFLOW.md # genie_tts data CharacterModels/ GenieData/ +.agent/ +.codex/ +.opencode/ +.kilocode/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 47404d563..bfdf904e0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -46,6 +46,32 @@ 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 @@ -88,3 +114,29 @@ 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 d8fdb04ba..1a981e537 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: worktree worktree-add worktree-rm +.PHONY: worktree worktree-add worktree-rm pr-test-neo pr-test-full pr-test-full-fast WORKTREE_DIR ?= ../astrbot_worktree BRANCH ?= $(word 2,$(MAKECMDGOALS)) @@ -27,6 +27,15 @@ 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 8934fd104..0f51a29c0 100644 --- a/astrbot/core/astr_main_agent.py +++ b/astrbot/core/astr_main_agent.py @@ -20,18 +20,32 @@ from astrbot.core.astr_agent_hooks import MAIN_AGENT_HOOKS 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, @@ -832,7 +846,8 @@ def _apply_sandbox_tools( ) -> None: if req.func_tool is None: req.func_tool = ToolSet() - if config.sandbox_cfg.get("booter") == "shipyard": + booter = config.sandbox_cfg.get("booter", "shipyard_neo") + if booter == "shipyard": ep = config.sandbox_cfg.get("shipyard_endpoint", "") at = config.sandbox_cfg.get("shipyard_access_token", "") if not ep or not at: @@ -840,11 +855,64 @@ 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) - req.system_prompt = f"{req.system_prompt}\n{SANDBOX_MODE_PROMPT}\n" + 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" 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 634647e7a..2e0d8b0aa 100644 --- a/astrbot/core/astr_main_agent_resources.py +++ b/astrbot/core/astr_main_agent_resources.py @@ -13,11 +13,25 @@ from astrbot.core.agent.tool import FunctionTool, ToolExecResult 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 @@ -449,6 +463,20 @@ PYTHON_TOOL = PythonTool() 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 ea93a3d6d..4c74e5edd 100644 --- a/astrbot/core/computer/booters/base.py +++ b/astrbot/core/computer/booters/base.py @@ -1,4 +1,9 @@ -from ..olayer import FileSystemComponent, PythonComponent, ShellComponent +from ..olayer import ( + BrowserComponent, + FileSystemComponent, + PythonComponent, + ShellComponent, +) class ComputerBooter: @@ -11,6 +16,19 @@ class ComputerBooter: @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 new file mode 100644 index 000000000..24fa379e8 --- /dev/null +++ b/astrbot/core/computer/booters/bay_manager.py @@ -0,0 +1,258 @@ +"""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 373f6cee0..70064fdd4 100644 --- a/astrbot/core/computer/booters/boxlite.py +++ b/astrbot/core/computer/booters/boxlite.py @@ -64,6 +64,10 @@ class MockShipyardSandboxClient: 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 b13503431..6379d1e48 100644 --- a/astrbot/core/computer/booters/shipyard.py +++ b/astrbot/core/computer/booters/shipyard.py @@ -31,7 +31,7 @@ class ShipyardBooter(ComputerBooter): self._ship = ship async def shutdown(self) -> None: - pass + logger.info("[Computer] Shipyard booter shutdown.") @property def fs(self) -> FileSystemComponent: @@ -47,11 +47,19 @@ class ShipyardBooter(ComputerBooter): async def upload_file(self, path: str, file_name: str) -> dict: """Upload file to sandbox""" - return await self._ship.upload_file(path, file_name) + result = await self._ship.upload_file(path, file_name) + logger.info("[Computer] File uploaded to Shipyard sandbox: %s", file_name) + return result async def download_file(self, remote_path: str, local_path: str): """Download file from sandbox.""" - return await self._ship.download_file(remote_path, local_path) + 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 async def available(self) -> bool: """Check if the sandbox is available.""" @@ -59,8 +67,17 @@ class ShipyardBooter(ComputerBooter): 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 new file mode 100644 index 000000000..6304696ad --- /dev/null +++ b/astrbot/core/computer/booters/shipyard_neo.py @@ -0,0 +1,513 @@ +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 9750e7b64..1853abf75 100644 --- a/astrbot/core/computer/computer_client.py +++ b/astrbot/core/computer/computer_client.py @@ -1,10 +1,10 @@ -import os +import json import shutil import uuid from pathlib import Path from astrbot.api import logger -from astrbot.core.skills.skill_manager import SANDBOX_SKILLS_ROOT +from astrbot.core.skills.skill_manager import SANDBOX_SKILLS_ROOT, SkillManager from astrbot.core.star.context import Context from astrbot.core.utils.astrbot_path import ( get_astrbot_skills_path, @@ -16,45 +16,403 @@ from .booters.local import LocalBooter 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: - skills_root = get_astrbot_skills_path() - if not os.path.isdir(skills_root): - return - if not any(Path(skills_root).iterdir()): - return + """Sync local skills to sandbox and refresh cache. - 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" + 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(): + 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") try: - 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}" + 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), ) finally: - if os.path.exists(zip_path): + if zip_path.exists(): try: - os.remove(zip_path) + zip_path.unlink() except Exception: logger.warning(f"Failed to remove temp skills zip: {zip_path}") @@ -66,7 +424,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") + booter_type = sandbox_cfg.get("booter", "shipyard_neo") if session_id in session_booter: booter = session_booter[session_id] @@ -75,6 +433,9 @@ 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 @@ -86,6 +447,27 @@ 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 @@ -95,6 +477,9 @@ 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}") @@ -104,6 +489,24 @@ 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 f099c079a..e2348671e 100644 --- a/astrbot/core/computer/olayer/__init__.py +++ b/astrbot/core/computer/olayer/__init__.py @@ -1,5 +1,11 @@ +from .browser import BrowserComponent from .filesystem import FileSystemComponent from .python import PythonComponent from .shell import ShellComponent -__all__ = ["PythonComponent", "ShellComponent", "FileSystemComponent"] +__all__ = [ + "PythonComponent", + "ShellComponent", + "FileSystemComponent", + "BrowserComponent", +] diff --git a/astrbot/core/computer/olayer/browser.py b/astrbot/core/computer/olayer/browser.py new file mode 100644 index 000000000..aa69f4501 --- /dev/null +++ b/astrbot/core/computer/olayer/browser.py @@ -0,0 +1,46 @@ +""" +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 79994fb9b..598abbb6e 100644 --- a/astrbot/core/computer/tools/__init__.py +++ b/astrbot/core/computer/tools/__init__.py @@ -1,8 +1,36 @@ +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 new file mode 100644 index 000000000..70061ac31 --- /dev/null +++ b/astrbot/core/computer/tools/browser.py @@ -0,0 +1,204 @@ +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 new file mode 100644 index 000000000..492b6e45e --- /dev/null +++ b/astrbot/core/computer/tools/neo_skills.py @@ -0,0 +1,542 @@ +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 01d0be2af..37edeb312 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -132,11 +132,15 @@ DEFAULT_CONFIG = { "computer_use_runtime": "none", "computer_use_require_admin": True, "sandbox": { - "booter": "shipyard", + "booter": "shipyard_neo", "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: @@ -2871,12 +2875,48 @@ CONFIG_METADATA_3 = { "provider_settings.sandbox.booter": { "description": "沙箱环境驱动器", "type": "string", - "options": ["shipyard"], - "labels": ["Shipyard"], + "options": ["shipyard_neo", "shipyard"], + "labels": ["Shipyard Neo", "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 new file mode 100644 index 000000000..5fe2b7832 --- /dev/null +++ b/astrbot/core/skills/neo_skill_sync.py @@ -0,0 +1,372 @@ +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 85190ecdf..d15876526 100644 --- a/astrbot/core/skills/skill_manager.py +++ b/astrbot/core/skills/skill_manager.py @@ -7,6 +7,7 @@ import shutil 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 ( @@ -16,9 +17,11 @@ from astrbot.core.utils.astrbot_path import ( ) 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._-]+$") @@ -29,9 +32,23 @@ 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() @@ -53,45 +70,74 @@ 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: - skills_lines = [] + """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 = "" for skill in skills: description = skill.description or "No description" - skills_lines.append(f"- {skill.name}: {description} (file: {skill.path})") + skills_lines.append( + f"- **{skill.name}**: {description}\n File: `{skill.path}`" + ) + if not example_path: + example_path = skill.path skills_block = "\n".join(skills_lines) - # Based on openai/codex + # 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" + return ( - "## 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" + "## 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" ) class SkillManager: def __init__(self, skills_root: str | None = None) -> None: self.skills_root = skills_root or get_astrbot_skills_path() - self.config_path = os.path.join(get_astrbot_data_path(), SKILLS_CONFIG_FILENAME) + 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) os.makedirs(self.skills_root, exist_ok=True) def _load_config(self) -> dict: @@ -108,6 +154,66 @@ class SkillManager: 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, *, @@ -124,7 +230,21 @@ class SkillManager: config = self._load_config() skill_configs = config.get("skills", {}) modified = False - skills: list[SkillInfo] = [] + 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 for entry in sorted(Path(self.skills_root).iterdir()): if not entry.is_dir(): @@ -145,36 +265,129 @@ class SkillManager: 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 = f"{SANDBOX_SKILLS_ROOT}/{skill_name}/SKILL.md" + path_str = sandbox_cached_paths.get(skill_name) or ( + f"{SANDBOX_WORKSPACE_ROOT}/{SANDBOX_SKILLS_ROOT}/{skill_name}/SKILL.md" + ) else: path_str = str(skill_md) path_str = path_str.replace("\\", "/") - skills.append( - SkillInfo( + 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( name=skill_name, description=description, - path=path_str, + path=path_str.replace("\\", "/"), 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 + 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 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) @@ -196,7 +409,7 @@ class SkillManager: 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 08b8c12b8..823d0fb9d 100644 --- a/astrbot/dashboard/routes/config.py +++ b/astrbot/dashboard/routes/config.py @@ -206,12 +206,110 @@ def validate_config(data, schema: dict, is_core: bool) -> tuple[list[str], dict] 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( @@ -928,6 +1026,11 @@ class ConfigRoute(Route): 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 5604d3d82..adad49615 100644 --- a/astrbot/dashboard/routes/skills.py +++ b/astrbot/dashboard/routes/skills.py @@ -1,15 +1,48 @@ 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 +from quart import request, send_file 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) @@ -17,18 +50,81 @@ class SkillsRoute(Route): 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") - skills = SkillManager().list_skills( + skill_mgr = SkillManager() + skills = skill_mgr.list_skills( active_only=False, runtime=runtime, show_sandbox_path=False ) return ( @@ -36,6 +132,8 @@ class SkillsRoute(Route): .ok( { "skills": [skill.__dict__ for skill in skills], + "runtime": runtime, + "sandbox_cache": skill_mgr.get_sandbox_skills_cache_status(), } ) .__dict__ @@ -70,6 +168,11 @@ class SkillsRoute(Route): 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.") @@ -85,6 +188,53 @@ class SkillsRoute(Route): 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 ( @@ -117,7 +267,262 @@ class SkillsRoute(Route): 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 becd09ea3..d8ec137e0 100644 --- a/dashboard/src/components/extension/SkillsSection.vue +++ b/dashboard/src/components/extension/SkillsSection.vue @@ -1,60 +1,295 @@