Compare commits

...

1 Commits

Author SHA1 Message Date
Ruochen Pan 3d31a4f354 Revert "feat: 接入 Shipyard Neo 自迭代 Skill 闭环与管理能力" 2026-03-02 09:41:29 +08:00
41 changed files with 175 additions and 6221 deletions
-4
View File
@@ -54,7 +54,3 @@ IFLOW.md
# genie_tts data
CharacterModels/
GenieData/
.agent/
.codex/
.opencode/
.kilocode/
-52
View File
@@ -46,32 +46,6 @@ ruff check .
如果您使用 VSCode,可以安装 `Ruff` 插件。
##### PR 功能完整性验证(推荐)
如果您希望在本地做一套接近 CI 的完整验证,可使用:
```bash
make pr-test-neo
```
该命令会执行:
- `uv sync --group dev`
- `ruff format --check .``ruff check .`
- Neo 相关关键测试
- `main.py` 启动 smoke test(检测 `http://localhost:6185`
需要全量验证时可使用:
```bash
make pr-test-full
```
如果只想快速重复执行(跳过依赖同步和 dashboard 构建):
```bash
make pr-test-full-fast
```
## Contributing Guide
@@ -114,29 +88,3 @@ We use Ruff as our code formatter and static analysis tool. Before submitting yo
ruff format .
ruff check .
```
##### PR completeness checks (recommended)
To run a local validation flow close to CI, use:
```bash
make pr-test-neo
```
This command runs:
- `uv sync --group dev`
- `ruff format --check .` and `ruff check .`
- Neo-related critical tests
- a startup smoke test against `http://localhost:6185`
For full validation, use:
```bash
make pr-test-full
```
For faster repeated runs (skip dependency sync and dashboard build), use:
```bash
make pr-test-full-fast
```
+1 -10
View File
@@ -1,4 +1,4 @@
.PHONY: worktree worktree-add worktree-rm pr-test-neo pr-test-full pr-test-full-fast
.PHONY: worktree worktree-add worktree-rm
WORKTREE_DIR ?= ../astrbot_worktree
BRANCH ?= $(word 2,$(MAKECMDGOALS))
@@ -27,15 +27,6 @@ endif
echo "Worktree $(WORKTREE_DIR)/$(BRANCH) not found."; \
fi
pr-test-neo:
./scripts/pr_test_env.sh --profile neo
pr-test-full:
./scripts/pr_test_env.sh --profile full
pr-test-full-fast:
./scripts/pr_test_env.sh --profile full --skip-sync --no-dashboard
# Swallow extra args (branch/base) so make doesn't treat them as targets
%:
@true
+2 -70
View File
@@ -20,32 +20,18 @@ 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,
@@ -846,8 +832,7 @@ def _apply_sandbox_tools(
) -> None:
if req.func_tool is None:
req.func_tool = ToolSet()
booter = config.sandbox_cfg.get("booter", "shipyard_neo")
if booter == "shipyard":
if config.sandbox_cfg.get("booter") == "shipyard":
ep = config.sandbox_cfg.get("shipyard_endpoint", "")
at = config.sandbox_cfg.get("shipyard_access_token", "")
if not ep or not at:
@@ -855,64 +840,11 @@ def _apply_sandbox_tools(
return
os.environ["SHIPYARD_ENDPOINT"] = ep
os.environ["SHIPYARD_ACCESS_TOKEN"] = at
req.func_tool.add_tool(EXECUTE_SHELL_TOOL)
req.func_tool.add_tool(PYTHON_TOOL)
req.func_tool.add_tool(FILE_UPLOAD_TOOL)
req.func_tool.add_tool(FILE_DOWNLOAD_TOOL)
if booter == "shipyard_neo":
# Neo-specific path rule: filesystem tools operate relative to sandbox
# workspace root. Do not prepend "/workspace".
req.system_prompt += (
"\n[Shipyard Neo File Path Rule]\n"
"When using sandbox filesystem tools (upload/download/read/write/list/delete), "
"always pass paths relative to the sandbox workspace root. "
"Example: use `baidu_homepage.png` instead of `/workspace/baidu_homepage.png`.\n"
)
req.system_prompt += (
"\n[Neo Skill Lifecycle Workflow]\n"
"When user asks to create/update a reusable skill in Neo mode, use lifecycle tools instead of directly writing local skill folders.\n"
"Preferred sequence:\n"
"1) Use `astrbot_create_skill_payload` to store canonical payload content and get `payload_ref`.\n"
"2) Use `astrbot_create_skill_candidate` with `skill_key` + `source_execution_ids` (and optional `payload_ref`) to create a candidate.\n"
"3) Use `astrbot_promote_skill_candidate` to release: `stage=canary` for trial; `stage=stable` for production.\n"
"For stable release, set `sync_to_local=true` to sync `payload.skill_markdown` into local `SKILL.md`.\n"
"Do not treat ad-hoc generated files as reusable Neo skills unless they are captured via payload/candidate/release.\n"
"To update an existing skill, create a new payload/candidate and promote a new release version; avoid patching old local folders directly.\n"
)
# Determine sandbox capabilities from an already-booted session.
# If no session exists yet (first request), capabilities is None
# and we register all tools conservatively.
from astrbot.core.computer.computer_client import session_booter
sandbox_capabilities: list[str] | None = None
existing_booter = session_booter.get(session_id)
if existing_booter is not None:
sandbox_capabilities = getattr(existing_booter, "capabilities", None)
# Browser tools: only register if profile supports browser
# (or if capabilities are unknown because sandbox hasn't booted yet)
if sandbox_capabilities is None or "browser" in sandbox_capabilities:
req.func_tool.add_tool(BROWSER_EXEC_TOOL)
req.func_tool.add_tool(BROWSER_BATCH_EXEC_TOOL)
req.func_tool.add_tool(RUN_BROWSER_SKILL_TOOL)
# Neo-specific tools (always available for shipyard_neo)
req.func_tool.add_tool(GET_EXECUTION_HISTORY_TOOL)
req.func_tool.add_tool(ANNOTATE_EXECUTION_TOOL)
req.func_tool.add_tool(CREATE_SKILL_PAYLOAD_TOOL)
req.func_tool.add_tool(GET_SKILL_PAYLOAD_TOOL)
req.func_tool.add_tool(CREATE_SKILL_CANDIDATE_TOOL)
req.func_tool.add_tool(LIST_SKILL_CANDIDATES_TOOL)
req.func_tool.add_tool(EVALUATE_SKILL_CANDIDATE_TOOL)
req.func_tool.add_tool(PROMOTE_SKILL_CANDIDATE_TOOL)
req.func_tool.add_tool(LIST_SKILL_RELEASES_TOOL)
req.func_tool.add_tool(ROLLBACK_SKILL_RELEASE_TOOL)
req.func_tool.add_tool(SYNC_SKILL_RELEASE_TOOL)
req.system_prompt = f"{req.system_prompt or ''}\n{SANDBOX_MODE_PROMPT}\n"
req.system_prompt = f"{req.system_prompt}\n{SANDBOX_MODE_PROMPT}\n"
def _proactive_cron_job_tools(req: ProviderRequest) -> None:
-28
View File
@@ -13,25 +13,11 @@ 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
@@ -463,20 +449,6 @@ 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
+1 -19
View File
@@ -1,9 +1,4 @@
from ..olayer import (
BrowserComponent,
FileSystemComponent,
PythonComponent,
ShellComponent,
)
from ..olayer import FileSystemComponent, PythonComponent, ShellComponent
class ComputerBooter:
@@ -16,19 +11,6 @@ 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: ...
@@ -1,258 +0,0 @@
"""Manage Bay container lifecycle for zero-config Shipyard Neo integration.
When no Bay endpoint is configured, AstrBot can automatically start a Bay
container using the Docker socket (like BoxliteBooter does for Ship
containers).
"""
from __future__ import annotations
import asyncio
import io
import json
import tarfile
from typing import Any
import aiodocker
import aiohttp
from astrbot.api import logger
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
BAY_IMAGE = "ghcr.io/astrbotdevs/shipyard-neo-bay:latest"
BAY_CONTAINER_NAME = "astrbot-bay"
BAY_LABEL = "astrbot.bay.managed"
BAY_PORT = 8114
HEALTH_TIMEOUT_S = 60
HEALTH_POLL_INTERVAL_S = 2
class BayContainerManager:
"""Start / reuse / stop a Bay container via Docker Engine API."""
def __init__(
self,
image: str = BAY_IMAGE,
host_port: int = BAY_PORT,
) -> None:
self._image = image
self._host_port = host_port
self._docker: aiodocker.Docker | None = None
self._container: Any = None
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
async def ensure_running(self) -> str:
"""Make sure a Bay container is running. Returns the endpoint URL.
If a container labelled ``astrbot.bay.managed`` already exists
and is running, it will be reused. Otherwise a new container is
created from *self._image*.
"""
try:
self._docker = aiodocker.Docker()
except Exception as exc:
raise RuntimeError(
"Failed to connect to Docker daemon. "
"Ensure Docker is installed and running, or configure "
"an explicit Bay endpoint instead of auto-start mode."
) from exc
# 1. Look for an existing managed container
existing = await self._find_managed_container()
if existing is not None:
state = existing["State"]
if state.get("Running"):
cid = existing["Id"][:12]
logger.info("[BayManager] Reusing existing Bay container: %s", cid)
self._container = await self._docker.containers.get(existing["Id"])
return f"http://127.0.0.1:{self._host_port}"
else:
# Container exists but stopped — restart it
logger.info("[BayManager] Restarting stopped Bay container")
container = await self._docker.containers.get(existing["Id"])
await container.start()
self._container = container
return f"http://127.0.0.1:{self._host_port}"
# 2. Pull image if needed
await self._pull_image_if_needed()
# 3. Create and start container
logger.info(
"[BayManager] Starting Bay container: image=%s, port=%d",
self._image,
self._host_port,
)
config = {
"Image": self._image,
"Labels": {BAY_LABEL: "true"},
"Env": [
"BAY_SERVER__HOST=0.0.0.0",
f"BAY_SERVER__PORT={BAY_PORT}",
"BAY_DATA_DIR=/app/data",
# allow_anonymous=false → auto-provisions API key
"BAY_SECURITY__ALLOW_ANONYMOUS=false",
],
"HostConfig": {
"PortBindings": {
f"{BAY_PORT}/tcp": [{"HostPort": str(self._host_port)}],
},
"Binds": [
# Bay needs Docker socket to create sandbox containers
"/var/run/docker.sock:/var/run/docker.sock",
],
"RestartPolicy": {"Name": "unless-stopped"},
},
}
self._container = await self._docker.containers.create_or_replace(
BAY_CONTAINER_NAME, config
)
await self._container.start()
logger.info("[BayManager] Bay container started: %s", BAY_CONTAINER_NAME)
return f"http://127.0.0.1:{self._host_port}"
async def wait_healthy(self, timeout: int = HEALTH_TIMEOUT_S) -> None:
"""Block until Bay's ``/health`` endpoint returns 200."""
url = f"http://127.0.0.1:{self._host_port}/health"
deadline = asyncio.get_event_loop().time() + timeout
last_error: str = ""
async with aiohttp.ClientSession() as session:
while asyncio.get_event_loop().time() < deadline:
try:
async with session.get(
url, timeout=aiohttp.ClientTimeout(total=3)
) as resp:
if resp.status == 200:
logger.info("[BayManager] Bay is healthy")
return
last_error = f"HTTP {resp.status}"
except Exception as exc:
last_error = str(exc)
await asyncio.sleep(HEALTH_POLL_INTERVAL_S)
raise TimeoutError(
f"Bay did not become healthy within {timeout}s (last error: {last_error})"
)
async def read_credentials(self) -> str:
"""Read auto-provisioned API key from Bay container.
Bay writes ``credentials.json`` to its data directory when
``allow_anonymous=false`` and no explicit API key is set.
"""
if self._container is None:
return ""
try:
# Read credentials.json from container filesystem
tar_stream = await self._container.get_archive("/app/data/credentials.json")
# get_archive returns (tar_data, stat)
tar_data = tar_stream
if isinstance(tar_data, dict):
raw = tar_data.get("data", b"")
elif isinstance(tar_data, tuple):
# (stream, stat_info)
raw = b""
stream = tar_data[0]
if hasattr(stream, "read"):
raw = await stream.read()
elif isinstance(stream, bytes):
raw = stream
else:
# It might be a chunked response
chunks = []
async for chunk in stream:
chunks.append(chunk)
raw = b"".join(chunks)
else:
raw = tar_data if isinstance(tar_data, bytes) else b""
if not raw:
logger.debug("[BayManager] Empty tar response from container")
return ""
tario = io.BytesIO(raw)
with tarfile.open(fileobj=tario) as tar:
for member in tar.getmembers():
f = tar.extractfile(member)
if f:
creds = json.loads(f.read().decode("utf-8"))
api_key = creds.get("api_key", "")
if api_key:
masked = (
f"{api_key[:8]}..."
if len(api_key) >= 10
else "redacted"
)
logger.info(
"[BayManager] Auto-discovered Bay API key: %s",
masked,
)
return api_key
except Exception as exc:
logger.debug(
"[BayManager] Failed to read credentials from container: %s", exc
)
return ""
async def close_client(self) -> None:
"""Close the Docker client without stopping the container.
The Bay container stays running for reuse by future sessions.
"""
if self._docker is not None:
await self._docker.close()
self._docker = None
async def stop(self) -> None:
"""Stop and remove the managed Bay container."""
if self._container is not None:
try:
await self._container.stop()
await self._container.delete(force=True)
logger.info("[BayManager] Bay container stopped and removed")
except Exception as exc:
logger.debug("[BayManager] Error stopping Bay container: %s", exc)
finally:
self._container = None
await self.close_client()
# ------------------------------------------------------------------
# Private helpers
# ------------------------------------------------------------------
async def _find_managed_container(self) -> dict | None:
"""Find an existing container with our management label."""
assert self._docker is not None
containers = await self._docker.containers.list(
all=True,
filters=json.dumps({"label": [f"{BAY_LABEL}=true"]}),
)
if containers:
# Inspect first match to get full state
return await containers[0].show()
return None
async def _pull_image_if_needed(self) -> None:
"""Pull the Bay image if it doesn't exist locally."""
assert self._docker is not None
try:
await self._docker.images.inspect(self._image)
logger.debug("[BayManager] Image %s already exists", self._image)
except aiodocker.exceptions.DockerError:
logger.info("[BayManager] Pulling image %s ...", self._image)
# Pull with progress logging
await self._docker.images.pull(self._image)
logger.info("[BayManager] Image %s pulled successfully", self._image)
-4
View File
@@ -64,10 +64,6 @@ 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",
+3 -20
View File
@@ -31,7 +31,7 @@ class ShipyardBooter(ComputerBooter):
self._ship = ship
async def shutdown(self) -> None:
logger.info("[Computer] Shipyard booter shutdown.")
pass
@property
def fs(self) -> FileSystemComponent:
@@ -47,19 +47,11 @@ class ShipyardBooter(ComputerBooter):
async def upload_file(self, path: str, file_name: str) -> dict:
"""Upload file to sandbox"""
result = await self._ship.upload_file(path, file_name)
logger.info("[Computer] File uploaded to Shipyard sandbox: %s", file_name)
return result
return await self._ship.upload_file(path, file_name)
async def download_file(self, remote_path: str, local_path: str):
"""Download file from sandbox."""
result = await self._ship.download_file(remote_path, local_path)
logger.info(
"[Computer] File downloaded from Shipyard sandbox: %s -> %s",
remote_path,
local_path,
)
return result
return await self._ship.download_file(remote_path, local_path)
async def available(self) -> bool:
"""Check if the sandbox is available."""
@@ -67,17 +59,8 @@ 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}")
@@ -1,513 +0,0 @@
from __future__ import annotations
import os
import shlex
from typing import Any, cast
from astrbot.api import logger
from ..olayer import (
BrowserComponent,
FileSystemComponent,
PythonComponent,
ShellComponent,
)
from .base import ComputerBooter
def _maybe_model_dump(value: Any) -> dict[str, Any]:
if isinstance(value, dict):
return value
if hasattr(value, "model_dump"):
dumped = value.model_dump()
if isinstance(dumped, dict):
return dumped
return {}
class NeoPythonComponent(PythonComponent):
def __init__(self, sandbox: Any) -> None:
self._sandbox = sandbox
async def exec(
self,
code: str,
kernel_id: str | None = None,
timeout: int = 30,
silent: bool = False,
) -> dict[str, Any]:
_ = kernel_id # Bay runtime does not expose kernel_id in current SDK.
result = await self._sandbox.python.exec(code, timeout=timeout)
payload = _maybe_model_dump(result)
output_text = payload.get("output", "") or ""
error_text = payload.get("error", "") or ""
data = payload.get("data") if isinstance(payload.get("data"), dict) else {}
rich_output = data.get("output") if isinstance(data.get("output"), dict) else {}
if not isinstance(rich_output.get("images"), list):
rich_output["images"] = []
if "text" not in rich_output:
rich_output["text"] = output_text
if silent:
rich_output["text"] = ""
return {
"success": bool(payload.get("success", error_text == "")),
"data": {
"output": rich_output,
"error": error_text,
},
"execution_id": payload.get("execution_id"),
"execution_time_ms": payload.get("execution_time_ms"),
"code": payload.get("code"),
"output": output_text,
"error": error_text,
}
class NeoShellComponent(ShellComponent):
def __init__(self, sandbox: Any) -> None:
self._sandbox = sandbox
async def exec(
self,
command: str,
cwd: str | None = None,
env: dict[str, str] | None = None,
timeout: int | None = 30,
shell: bool = True,
background: bool = False,
) -> dict[str, Any]:
if not shell:
return {
"stdout": "",
"stderr": "error: only shell mode is supported in shipyard_neo booter.",
"exit_code": 2,
"success": False,
}
run_command = command
if env:
env_prefix = " ".join(
f"{k}={shlex.quote(str(v))}" for k, v in sorted(env.items())
)
run_command = f"{env_prefix} {run_command}"
if background:
run_command = f"nohup sh -lc {shlex.quote(run_command)} >/tmp/astrbot_bg.log 2>&1 & echo $!"
result = await self._sandbox.shell.exec(
run_command,
timeout=timeout or 30,
cwd=cwd,
)
payload = _maybe_model_dump(result)
stdout = payload.get("output", "") or ""
stderr = payload.get("error", "") or ""
exit_code = payload.get("exit_code")
if background:
pid: int | None = None
try:
pid = int(stdout.strip().splitlines()[-1])
except Exception:
pid = None
return {
"pid": pid,
"stdout": stdout,
"stderr": stderr,
"exit_code": exit_code,
"success": bool(payload.get("success", not stderr)),
"execution_id": payload.get("execution_id"),
"execution_time_ms": payload.get("execution_time_ms"),
"command": payload.get("command"),
}
return {
"stdout": stdout,
"stderr": stderr,
"exit_code": exit_code,
"success": bool(payload.get("success", not stderr)),
"execution_id": payload.get("execution_id"),
"execution_time_ms": payload.get("execution_time_ms"),
"command": payload.get("command"),
}
class NeoFileSystemComponent(FileSystemComponent):
def __init__(self, sandbox: Any) -> None:
self._sandbox = sandbox
async def create_file(
self,
path: str,
content: str = "",
mode: int = 0o644,
) -> dict[str, Any]:
_ = mode
await self._sandbox.filesystem.write_file(path, content)
return {"success": True, "path": path}
async def read_file(self, path: str, encoding: str = "utf-8") -> dict[str, Any]:
_ = encoding
content = await self._sandbox.filesystem.read_file(path)
return {"success": True, "path": path, "content": content}
async def write_file(
self,
path: str,
content: str,
mode: str = "w",
encoding: str = "utf-8",
) -> dict[str, Any]:
_ = mode
_ = encoding
await self._sandbox.filesystem.write_file(path, content)
return {"success": True, "path": path}
async def delete_file(self, path: str) -> dict[str, Any]:
await self._sandbox.filesystem.delete(path)
return {"success": True, "path": path}
async def list_dir(
self,
path: str = ".",
show_hidden: bool = False,
) -> dict[str, Any]:
entries = await self._sandbox.filesystem.list_dir(path)
data = []
for entry in entries:
item = _maybe_model_dump(entry)
if not show_hidden and str(item.get("name", "")).startswith("."):
continue
data.append(item)
return {"success": True, "path": path, "entries": data}
class NeoBrowserComponent(BrowserComponent):
def __init__(self, sandbox: Any) -> None:
self._sandbox = sandbox
async def exec(
self,
cmd: str,
timeout: int = 30,
description: str | None = None,
tags: str | None = None,
learn: bool = False,
include_trace: bool = False,
) -> dict[str, Any]:
result = await self._sandbox.browser.exec(
cmd,
timeout=timeout,
description=description,
tags=tags,
learn=learn,
include_trace=include_trace,
)
return _maybe_model_dump(result)
async def exec_batch(
self,
commands: list[str],
timeout: int = 60,
stop_on_error: bool = True,
description: str | None = None,
tags: str | None = None,
learn: bool = False,
include_trace: bool = False,
) -> dict[str, Any]:
result = await self._sandbox.browser.exec_batch(
commands,
timeout=timeout,
stop_on_error=stop_on_error,
description=description,
tags=tags,
learn=learn,
include_trace=include_trace,
)
return _maybe_model_dump(result)
async def run_skill(
self,
skill_key: str,
timeout: int = 60,
stop_on_error: bool = True,
include_trace: bool = False,
description: str | None = None,
tags: str | None = None,
) -> dict[str, Any]:
result = await self._sandbox.browser.run_skill(
skill_key=skill_key,
timeout=timeout,
stop_on_error=stop_on_error,
include_trace=include_trace,
description=description,
tags=tags,
)
return _maybe_model_dump(result)
class ShipyardNeoBooter(ComputerBooter):
"""Booter backed by Shipyard Neo (Bay).
If *endpoint_url* is empty or set to ``"__auto__"``, Bay will be
started automatically as a Docker container (like Boxlite does for
Ship containers).
"""
AUTO_SENTINEL = "__auto__"
DEFAULT_PROFILE = "python-default"
def __init__(
self,
endpoint_url: str,
access_token: str,
profile: str = DEFAULT_PROFILE,
ttl: int = 3600,
) -> None:
self._endpoint_url = endpoint_url
self._access_token = access_token
self._profile = profile
self._ttl = ttl
self._client: Any = None
self._sandbox: Any = None
self._bay_manager: Any = None # BayContainerManager when auto-started
self._fs: FileSystemComponent | None = None
self._python: PythonComponent | None = None
self._shell: ShellComponent | None = None
self._browser: BrowserComponent | None = None
@property
def bay_client(self) -> Any:
return self._client
@property
def sandbox(self) -> Any:
return self._sandbox
@property
def capabilities(self) -> tuple[str, ...] | None:
"""Sandbox capabilities from the Bay profile.
Returns an immutable tuple after :meth:`boot`; ``None`` before boot.
"""
if self._sandbox is None:
return None
caps = getattr(self._sandbox, "capabilities", None)
return tuple(caps) if caps is not None else None
@property
def is_auto_mode(self) -> bool:
"""True when Bay should be auto-started."""
ep = (self._endpoint_url or "").strip()
return not ep or ep == self.AUTO_SENTINEL
async def boot(self, session_id: str) -> None:
_ = session_id
# --- Auto-start Bay if needed ---
if self.is_auto_mode:
from .bay_manager import BayContainerManager
# Clean up previous manager if re-booting
if self._bay_manager is not None:
await self._bay_manager.close_client()
logger.info("[Computer] Neo auto-start mode: launching Bay container")
self._bay_manager = BayContainerManager()
self._endpoint_url = await self._bay_manager.ensure_running()
await self._bay_manager.wait_healthy()
# Read auto-provisioned credentials
if not self._access_token:
self._access_token = await self._bay_manager.read_credentials()
logger.info("[Computer] Bay auto-started at %s", self._endpoint_url)
if not self._endpoint_url or not self._access_token:
if self._bay_manager is not None:
raise ValueError(
"Bay container started but credentials could not be read. "
"Ensure Bay generated credentials.json, or set access_token manually."
)
raise ValueError(
"Shipyard Neo sandbox configuration is incomplete. "
"Set endpoint (default http://127.0.0.1:8114) and access token, "
"or ensure Bay's credentials.json is accessible for auto-discovery."
)
from shipyard_neo import BayClient
self._client = BayClient(
endpoint_url=self._endpoint_url,
access_token=self._access_token,
)
await self._client.__aenter__()
# Resolve profile: user-specified > smart selection > default
resolved_profile = await self._resolve_profile(self._client)
self._sandbox = await self._client.create_sandbox(
profile=resolved_profile,
ttl=self._ttl,
)
self._fs = NeoFileSystemComponent(self._sandbox)
self._python = NeoPythonComponent(self._sandbox)
self._shell = NeoShellComponent(self._sandbox)
caps = self.capabilities or ()
self._browser = (
NeoBrowserComponent(self._sandbox) if "browser" in caps else None
)
logger.info(
"Got Shipyard Neo sandbox: %s (profile=%s, capabilities=%s, auto=%s)",
self._sandbox.id,
resolved_profile,
list(caps),
bool(self._bay_manager),
)
async def _resolve_profile(self, client: Any) -> str:
"""Pick the best profile for this session.
Resolution order:
1. User-specified profile (non-empty, non-default) → use as-is.
2. Query ``GET /v1/profiles`` and pick the profile with the most
capabilities, preferring profiles that include ``"browser"``.
3. Fall back to :attr:`DEFAULT_PROFILE`.
Auth errors (401/403) are re-raised immediately — they indicate a
misconfigured token, and silently falling back would just delay the
real failure to ``create_sandbox``.
"""
# User explicitly set a profile → honour it
if self._profile and self._profile != self.DEFAULT_PROFILE:
logger.info("[Computer] Using user-specified profile: %s", self._profile)
return self._profile
# Query Bay for available profiles
from shipyard_neo.errors import ForbiddenError, UnauthorizedError
try:
profile_list = await client.list_profiles()
profiles = profile_list.items
except (UnauthorizedError, ForbiddenError):
raise # auth errors must not be silenced
except Exception as exc:
logger.warning(
"[Computer] Failed to query Bay profiles, falling back to %s: %s",
self.DEFAULT_PROFILE,
exc,
)
return self.DEFAULT_PROFILE
if not profiles:
return self.DEFAULT_PROFILE
def _score(p: Any) -> tuple[int, int]:
"""(has_browser, capability_count) — higher is better."""
caps = getattr(p, "capabilities", []) or []
return (1 if "browser" in caps else 0, len(caps))
best = max(profiles, key=_score)
chosen = getattr(best, "id", self.DEFAULT_PROFILE)
if chosen != self.DEFAULT_PROFILE:
caps = getattr(best, "capabilities", [])
logger.info(
"[Computer] Auto-selected profile %s (capabilities=%s)",
chosen,
caps,
)
return chosen
async def shutdown(self) -> None:
if self._client is not None:
sandbox_id = getattr(self._sandbox, "id", "unknown")
logger.info(
"[Computer] Shutting down Shipyard Neo sandbox: id=%s", sandbox_id
)
await self._client.__aexit__(None, None, None)
self._client = None
self._sandbox = None
logger.info("[Computer] Shipyard Neo sandbox shut down: id=%s", sandbox_id)
# NOTE: We intentionally do NOT stop the Bay container here.
# It stays running for reuse by future sessions. The user can
# stop it manually or via ``BayContainerManager.stop()``.
if self._bay_manager is not None:
await self._bay_manager.close_client()
@property
def fs(self) -> FileSystemComponent:
if self._fs is None:
raise RuntimeError("ShipyardNeoBooter is not initialized.")
return self._fs
@property
def python(self) -> PythonComponent:
if self._python is None:
raise RuntimeError("ShipyardNeoBooter is not initialized.")
return self._python
@property
def shell(self) -> ShellComponent:
if self._shell is None:
raise RuntimeError("ShipyardNeoBooter is not initialized.")
return self._shell
@property
def browser(self) -> BrowserComponent:
if self._browser is None:
raise RuntimeError("ShipyardNeoBooter is not initialized.")
return self._browser
async def upload_file(self, path: str, file_name: str) -> dict:
if self._sandbox is None:
raise RuntimeError("ShipyardNeoBooter is not initialized.")
with open(path, "rb") as f:
content = f.read()
remote_path = file_name.lstrip("/")
await self._sandbox.filesystem.upload(remote_path, content)
logger.info("[Computer] File uploaded to Neo sandbox: %s", remote_path)
return {
"success": True,
"message": "File uploaded successfully",
"file_path": remote_path,
}
async def download_file(self, remote_path: str, local_path: str) -> None:
if self._sandbox is None:
raise RuntimeError("ShipyardNeoBooter is not initialized.")
content = await self._sandbox.filesystem.download(remote_path.lstrip("/"))
local_dir = os.path.dirname(local_path)
if local_dir:
os.makedirs(local_dir, exist_ok=True)
with open(local_path, "wb") as f:
f.write(cast(bytes, content))
logger.info(
"[Computer] File downloaded from Neo sandbox: %s -> %s",
remote_path,
local_path,
)
async def available(self) -> bool:
if self._sandbox is None:
return False
try:
await self._sandbox.refresh()
status = getattr(self._sandbox.status, "value", str(self._sandbox.status))
healthy = status not in {"failed", "expired"}
logger.info(
"[Computer] Neo sandbox health check: id=%s, status=%s, healthy=%s",
getattr(self._sandbox, "id", "unknown"),
status,
healthy,
)
return healthy
except Exception as e:
logger.error(f"Error checking Shipyard Neo sandbox availability: {e}")
return False
+32 -435
View File
@@ -1,10 +1,10 @@
import json
import os
import shutil
import uuid
from pathlib import Path
from astrbot.api import logger
from astrbot.core.skills.skill_manager import SANDBOX_SKILLS_ROOT, SkillManager
from astrbot.core.skills.skill_manager import SANDBOX_SKILLS_ROOT
from astrbot.core.star.context import Context
from astrbot.core.utils.astrbot_path import (
get_astrbot_skills_path,
@@ -16,403 +16,45 @@ 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:
"""Sync local skills to sandbox and refresh cache.
Backward-compatible orchestrator: keep historical behavior while internally
splitting into `apply` and `scan` phases.
"""
skills_root = Path(get_astrbot_skills_path())
if not skills_root.is_dir():
skills_root = get_astrbot_skills_path()
if not os.path.isdir(skills_root):
return
if not any(Path(skills_root).iterdir()):
return
local_skill_dirs = _list_local_skill_dirs(skills_root)
temp_dir = Path(get_astrbot_temp_path())
temp_dir.mkdir(parents=True, exist_ok=True)
zip_base = temp_dir / "skills_bundle"
zip_path = zip_base.with_suffix(".zip")
temp_dir = get_astrbot_temp_path()
os.makedirs(temp_dir, exist_ok=True)
zip_base = os.path.join(temp_dir, "skills_bundle")
zip_path = f"{zip_base}.zip"
try:
if local_skill_dirs:
if zip_path.exists():
zip_path.unlink()
shutil.make_archive(str(zip_base), "zip", str(skills_root))
remote_zip = Path(SANDBOX_SKILLS_ROOT) / "skills.zip"
logger.info("Uploading skills bundle to sandbox...")
await booter.shell.exec(f"mkdir -p {SANDBOX_SKILLS_ROOT}")
upload_result = await booter.upload_file(str(zip_path), str(remote_zip))
if not upload_result.get("success", False):
raise RuntimeError("Failed to upload skills bundle to sandbox.")
else:
logger.info(
"No local skills found. Keeping sandbox built-ins and refreshing metadata."
)
await booter.shell.exec(f"rm -f {SANDBOX_SKILLS_ROOT}/skills.zip")
# Keep backward-compatible behavior while splitting lifecycle into two
# observable phases: apply (filesystem mutation) + scan (metadata read).
await _apply_skills_to_sandbox(booter)
payload = await _scan_sandbox_skills(booter)
_update_sandbox_skills_cache(payload)
managed = payload.get("managed_skills", []) if isinstance(payload, dict) else []
logger.info(
"[Computer] Sandbox skill sync complete: managed=%d",
len(managed),
if os.path.exists(zip_path):
os.remove(zip_path)
shutil.make_archive(zip_base, "zip", skills_root)
remote_zip = Path(SANDBOX_SKILLS_ROOT) / "skills.zip"
logger.info("Uploading skills bundle to sandbox...")
await booter.shell.exec(f"mkdir -p {SANDBOX_SKILLS_ROOT}")
upload_result = await booter.upload_file(zip_path, str(remote_zip))
if not upload_result.get("success", False):
raise RuntimeError("Failed to upload skills bundle to sandbox.")
# Use -n flag to never overwrite existing files, fallback to Python if unzip unavailable
await booter.shell.exec(
f"unzip -n {remote_zip} -d {SANDBOX_SKILLS_ROOT} || "
f"python3 -c \"import zipfile, os, pathlib; z=zipfile.ZipFile('{remote_zip}'); "
f"[z.extract(m, '{SANDBOX_SKILLS_ROOT}') for m in z.namelist() "
f"if not os.path.exists(os.path.join('{SANDBOX_SKILLS_ROOT}', m))]\" || "
f"python -c \"import zipfile, os, pathlib; z=zipfile.ZipFile('{remote_zip}'); "
f"[z.extract(m, '{SANDBOX_SKILLS_ROOT}') for m in z.namelist() "
f"if not os.path.exists(os.path.join('{SANDBOX_SKILLS_ROOT}', m))]\"; "
f"rm -f {remote_zip}"
)
finally:
if zip_path.exists():
if os.path.exists(zip_path):
try:
zip_path.unlink()
os.remove(zip_path)
except Exception:
logger.warning(f"Failed to remove temp skills zip: {zip_path}")
@@ -424,7 +66,7 @@ async def get_booter(
config = context.get_config(umo=session_id)
sandbox_cfg = config.get("provider_settings", {}).get("sandbox", {})
booter_type = sandbox_cfg.get("booter", "shipyard_neo")
booter_type = sandbox_cfg.get("booter", "shipyard")
if session_id in session_booter:
booter = session_booter[session_id]
@@ -433,9 +75,6 @@ async def get_booter(
session_booter.pop(session_id, None)
if session_id not in session_booter:
uuid_str = uuid.uuid5(uuid.NAMESPACE_DNS, session_id).hex
logger.info(
f"[Computer] Initializing booter: type={booter_type}, session={session_id}"
)
if booter_type == "shipyard":
from .booters.shipyard import ShipyardBooter
@@ -447,27 +86,6 @@ async def get_booter(
client = ShipyardBooter(
endpoint_url=ep, access_token=token, ttl=ttl, session_num=max_sessions
)
elif booter_type == "shipyard_neo":
from .booters.shipyard_neo import ShipyardNeoBooter
ep = sandbox_cfg.get("shipyard_neo_endpoint", "")
token = sandbox_cfg.get("shipyard_neo_access_token", "")
ttl = sandbox_cfg.get("shipyard_neo_ttl", 3600)
profile = sandbox_cfg.get("shipyard_neo_profile", "python-default")
# Auto-discover token from Bay's credentials.json if not configured
if not token:
token = _discover_bay_credentials(ep)
logger.info(
f"[Computer] Shipyard Neo config: endpoint={ep}, profile={profile}, ttl={ttl}"
)
client = ShipyardNeoBooter(
endpoint_url=ep,
access_token=token,
profile=profile,
ttl=ttl,
)
elif booter_type == "boxlite":
from .booters.boxlite import BoxliteBooter
@@ -477,9 +95,6 @@ async def get_booter(
try:
await client.boot(uuid_str)
logger.info(
f"[Computer] Sandbox booted successfully: type={booter_type}, session={session_id}"
)
await _sync_skills_to_sandbox(client)
except Exception as e:
logger.error(f"Error booting sandbox for session {session_id}: {e}")
@@ -489,24 +104,6 @@ async def get_booter(
return session_booter[session_id]
async def sync_skills_to_active_sandboxes() -> None:
"""Best-effort skills synchronization for all active sandbox sessions."""
logger.info(
"[Computer] Syncing skills to %d active sandbox(es)", len(session_booter)
)
for session_id, booter in list(session_booter.items()):
try:
if not await booter.available():
continue
await _sync_skills_to_sandbox(booter)
except Exception as e:
logger.warning(
"Failed to sync skills to sandbox for session %s: %s",
session_id,
e,
)
def get_local_booter() -> ComputerBooter:
global local_booter
if local_booter is None:
+1 -7
View File
@@ -1,11 +1,5 @@
from .browser import BrowserComponent
from .filesystem import FileSystemComponent
from .python import PythonComponent
from .shell import ShellComponent
__all__ = [
"PythonComponent",
"ShellComponent",
"FileSystemComponent",
"BrowserComponent",
]
__all__ = ["PythonComponent", "ShellComponent", "FileSystemComponent"]
-46
View File
@@ -1,46 +0,0 @@
"""
Browser automation component
"""
from typing import Any, Protocol
class BrowserComponent(Protocol):
"""Browser operations component"""
async def exec(
self,
cmd: str,
timeout: int = 30,
description: str | None = None,
tags: str | None = None,
learn: bool = False,
include_trace: bool = False,
) -> dict[str, Any]:
"""Execute a browser automation command"""
...
async def exec_batch(
self,
commands: list[str],
timeout: int = 60,
stop_on_error: bool = True,
description: str | None = None,
tags: str | None = None,
learn: bool = False,
include_trace: bool = False,
) -> dict[str, Any]:
"""Execute a browser automation command batch"""
...
async def run_skill(
self,
skill_key: str,
timeout: int = 60,
stop_on_error: bool = True,
include_trace: bool = False,
description: str | None = None,
tags: str | None = None,
) -> dict[str, Any]:
"""Run a browser skill by skill key"""
...
-28
View File
@@ -1,36 +1,8 @@
from .browser import BrowserBatchExecTool, BrowserExecTool, RunBrowserSkillTool
from .fs import FileDownloadTool, FileUploadTool
from .neo_skills import (
AnnotateExecutionTool,
CreateSkillCandidateTool,
CreateSkillPayloadTool,
EvaluateSkillCandidateTool,
GetExecutionHistoryTool,
GetSkillPayloadTool,
ListSkillCandidatesTool,
ListSkillReleasesTool,
PromoteSkillCandidateTool,
RollbackSkillReleaseTool,
SyncSkillReleaseTool,
)
from .python import LocalPythonTool, PythonTool
from .shell import ExecuteShellTool
__all__ = [
"BrowserExecTool",
"BrowserBatchExecTool",
"RunBrowserSkillTool",
"GetExecutionHistoryTool",
"AnnotateExecutionTool",
"CreateSkillPayloadTool",
"GetSkillPayloadTool",
"CreateSkillCandidateTool",
"ListSkillCandidatesTool",
"EvaluateSkillCandidateTool",
"PromoteSkillCandidateTool",
"ListSkillReleasesTool",
"RollbackSkillReleaseTool",
"SyncSkillReleaseTool",
"FileUploadTool",
"PythonTool",
"LocalPythonTool",
-204
View File
@@ -1,204 +0,0 @@
import json
from dataclasses import dataclass, field
from typing import Any
from astrbot.api import FunctionTool
from astrbot.core.agent.run_context import ContextWrapper
from astrbot.core.agent.tool import ToolExecResult
from astrbot.core.astr_agent_context import AstrAgentContext
from ..computer_client import get_booter
def _to_json(data: Any) -> str:
return json.dumps(data, ensure_ascii=False, default=str)
def _ensure_admin(context: ContextWrapper[AstrAgentContext]) -> str | None:
if context.context.event.role != "admin":
return (
"error: Permission denied. Browser and skill lifecycle tools are only allowed "
"for admin users."
)
return None
async def _get_browser_component(context: ContextWrapper[AstrAgentContext]) -> Any:
booter = await get_booter(
context.context.context,
context.context.event.unified_msg_origin,
)
browser = getattr(booter, "browser", None)
if browser is None:
raise RuntimeError(
"Current sandbox booter does not support browser capability. "
"Please switch to shipyard_neo."
)
return browser
@dataclass
class BrowserExecTool(FunctionTool):
name: str = "astrbot_execute_browser"
description: str = "Execute one browser automation command in the sandbox."
parameters: dict = field(
default_factory=lambda: {
"type": "object",
"properties": {
"cmd": {"type": "string", "description": "Browser command to execute."},
"timeout": {"type": "integer", "default": 30},
"description": {
"type": "string",
"description": "Optional execution description.",
},
"tags": {"type": "string", "description": "Optional tags."},
"learn": {
"type": "boolean",
"description": "Whether to mark execution as learn evidence.",
"default": False,
},
"include_trace": {
"type": "boolean",
"description": "Whether to include trace_ref in response.",
"default": False,
},
},
"required": ["cmd"],
}
)
async def call(
self,
context: ContextWrapper[AstrAgentContext],
cmd: str,
timeout: int = 30,
description: str | None = None,
tags: str | None = None,
learn: bool = False,
include_trace: bool = False,
) -> ToolExecResult:
if err := _ensure_admin(context):
return err
try:
browser = await _get_browser_component(context)
result = await browser.exec(
cmd=cmd,
timeout=timeout,
description=description,
tags=tags,
learn=learn,
include_trace=include_trace,
)
return _to_json(result)
except Exception as e:
return f"Error executing browser command: {str(e)}"
@dataclass
class BrowserBatchExecTool(FunctionTool):
name: str = "astrbot_execute_browser_batch"
description: str = "Execute a browser command batch in the sandbox."
parameters: dict = field(
default_factory=lambda: {
"type": "object",
"properties": {
"commands": {
"type": "array",
"items": {"type": "string"},
"description": "Ordered browser commands.",
},
"timeout": {"type": "integer", "default": 60},
"stop_on_error": {"type": "boolean", "default": True},
"description": {
"type": "string",
"description": "Optional execution description.",
},
"tags": {"type": "string", "description": "Optional tags."},
"learn": {
"type": "boolean",
"description": "Whether to mark execution as learn evidence.",
"default": False,
},
"include_trace": {
"type": "boolean",
"description": "Whether to include trace_ref in response.",
"default": False,
},
},
"required": ["commands"],
}
)
async def call(
self,
context: ContextWrapper[AstrAgentContext],
commands: list[str],
timeout: int = 60,
stop_on_error: bool = True,
description: str | None = None,
tags: str | None = None,
learn: bool = False,
include_trace: bool = False,
) -> ToolExecResult:
if err := _ensure_admin(context):
return err
try:
browser = await _get_browser_component(context)
result = await browser.exec_batch(
commands=commands,
timeout=timeout,
stop_on_error=stop_on_error,
description=description,
tags=tags,
learn=learn,
include_trace=include_trace,
)
return _to_json(result)
except Exception as e:
return f"Error executing browser batch command: {str(e)}"
@dataclass
class RunBrowserSkillTool(FunctionTool):
name: str = "astrbot_run_browser_skill"
description: str = "Run a released browser skill in the sandbox by skill_key."
parameters: dict = field(
default_factory=lambda: {
"type": "object",
"properties": {
"skill_key": {"type": "string"},
"timeout": {"type": "integer", "default": 60},
"stop_on_error": {"type": "boolean", "default": True},
"include_trace": {"type": "boolean", "default": False},
"description": {"type": "string"},
"tags": {"type": "string"},
},
"required": ["skill_key"],
}
)
async def call(
self,
context: ContextWrapper[AstrAgentContext],
skill_key: str,
timeout: int = 60,
stop_on_error: bool = True,
include_trace: bool = False,
description: str | None = None,
tags: str | None = None,
) -> ToolExecResult:
if err := _ensure_admin(context):
return err
try:
browser = await _get_browser_component(context)
result = await browser.run_skill(
skill_key=skill_key,
timeout=timeout,
stop_on_error=stop_on_error,
include_trace=include_trace,
description=description,
tags=tags,
)
return _to_json(result)
except Exception as e:
return f"Error running browser skill: {str(e)}"
-542
View File
@@ -1,542 +0,0 @@
import json
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from typing import Any
from astrbot.api import FunctionTool
from astrbot.core.agent.run_context import ContextWrapper
from astrbot.core.agent.tool import ToolExecResult
from astrbot.core.astr_agent_context import AstrAgentContext
from astrbot.core.skills.neo_skill_sync import NeoSkillSyncManager
from ..computer_client import get_booter
def _to_jsonable(model_like: Any) -> Any:
if isinstance(model_like, dict):
return model_like
if isinstance(model_like, list):
return [_to_jsonable(i) for i in model_like]
if hasattr(model_like, "model_dump"):
return _to_jsonable(model_like.model_dump())
return model_like
def _to_json_text(data: Any) -> str:
return json.dumps(_to_jsonable(data), ensure_ascii=False, default=str)
def _ensure_admin(context: ContextWrapper[AstrAgentContext]) -> str | None:
if context.context.event.role != "admin":
return "error: Permission denied. Skill lifecycle tools are only allowed for admin users."
return None
async def _get_neo_context(
context: ContextWrapper[AstrAgentContext],
) -> tuple[Any, Any]:
booter = await get_booter(
context.context.context,
context.context.event.unified_msg_origin,
)
client = getattr(booter, "bay_client", None)
sandbox = getattr(booter, "sandbox", None)
if client is None or sandbox is None:
raise RuntimeError(
"Current sandbox booter does not support Neo skill lifecycle APIs. "
"Please switch to shipyard_neo."
)
return client, sandbox
@dataclass
class NeoSkillToolBase(FunctionTool):
error_prefix: str = "Error"
async def _run(
self,
context: ContextWrapper[AstrAgentContext],
neo_call: Callable[[Any, Any], Awaitable[Any]],
error_action: str,
) -> ToolExecResult:
if err := _ensure_admin(context):
return err
try:
client, sandbox = await _get_neo_context(context)
result = await neo_call(client, sandbox)
return _to_json_text(result)
except Exception as e:
return f"{self.error_prefix} {error_action}: {str(e)}"
@dataclass
class GetExecutionHistoryTool(NeoSkillToolBase):
name: str = "astrbot_get_execution_history"
description: str = "Get execution history from current sandbox."
parameters: dict = field(
default_factory=lambda: {
"type": "object",
"properties": {
"exec_type": {"type": "string"},
"success_only": {"type": "boolean", "default": False},
"limit": {"type": "integer", "default": 100},
"offset": {"type": "integer", "default": 0},
"tags": {"type": "string"},
"has_notes": {"type": "boolean", "default": False},
"has_description": {"type": "boolean", "default": False},
},
"required": [],
}
)
async def call(
self,
context: ContextWrapper[AstrAgentContext],
exec_type: str | None = None,
success_only: bool = False,
limit: int = 100,
offset: int = 0,
tags: str | None = None,
has_notes: bool = False,
has_description: bool = False,
) -> ToolExecResult:
return await self._run(
context,
lambda _client, sandbox: sandbox.get_execution_history(
exec_type=exec_type,
success_only=success_only,
limit=limit,
offset=offset,
tags=tags,
has_notes=has_notes,
has_description=has_description,
),
error_action="getting execution history",
)
@dataclass
class AnnotateExecutionTool(NeoSkillToolBase):
name: str = "astrbot_annotate_execution"
description: str = "Annotate one execution history record."
parameters: dict = field(
default_factory=lambda: {
"type": "object",
"properties": {
"execution_id": {"type": "string"},
"description": {"type": "string"},
"tags": {"type": "string"},
"notes": {"type": "string"},
},
"required": ["execution_id"],
}
)
async def call(
self,
context: ContextWrapper[AstrAgentContext],
execution_id: str,
description: str | None = None,
tags: str | None = None,
notes: str | None = None,
) -> ToolExecResult:
return await self._run(
context,
lambda _client, sandbox: sandbox.annotate_execution(
execution_id=execution_id,
description=description,
tags=tags,
notes=notes,
),
error_action="annotating execution",
)
@dataclass
class CreateSkillPayloadTool(NeoSkillToolBase):
name: str = "astrbot_create_skill_payload"
description: str = (
"Step 1/3 for Neo skill authoring: create immutable payload content and return payload_ref. "
"Use this to store skill_markdown and structured metadata; do NOT write local skill folders directly."
)
parameters: dict = field(
default_factory=lambda: {
"type": "object",
"properties": {
"payload": {
"anyOf": [{"type": "object"}, {"type": "array"}],
"description": (
"Skill payload JSON. Typical schema: {skill_markdown, inputs, outputs, meta}. "
"This only stores content and returns payload_ref; it does not create a candidate or release."
),
},
"kind": {
"type": "string",
"description": "Payload kind.",
"default": "astrbot_skill_v1",
},
},
"required": ["payload"],
}
)
async def call(
self,
context: ContextWrapper[AstrAgentContext],
payload: dict[str, Any] | list[Any],
kind: str = "astrbot_skill_v1",
) -> ToolExecResult:
return await self._run(
context,
lambda client, _sandbox: client.skills.create_payload(
payload=payload,
kind=kind,
),
error_action="creating skill payload",
)
@dataclass
class GetSkillPayloadTool(NeoSkillToolBase):
name: str = "astrbot_get_skill_payload"
description: str = "Get one skill payload by payload_ref."
parameters: dict = field(
default_factory=lambda: {
"type": "object",
"properties": {
"payload_ref": {"type": "string"},
},
"required": ["payload_ref"],
}
)
async def call(
self,
context: ContextWrapper[AstrAgentContext],
payload_ref: str,
) -> ToolExecResult:
return await self._run(
context,
lambda client, _sandbox: client.skills.get_payload(payload_ref),
error_action="getting skill payload",
)
@dataclass
class CreateSkillCandidateTool(NeoSkillToolBase):
name: str = "astrbot_create_skill_candidate"
description: str = (
"Step 2/3 for Neo skill authoring: create a candidate by binding execution evidence "
"(source_execution_ids) with skill identity (skill_key) and optional payload_ref."
)
parameters: dict = field(
default_factory=lambda: {
"type": "object",
"properties": {
"skill_key": {
"type": "string",
"description": "Stable logical identifier, e.g. image-collage-9grid.",
},
"source_execution_ids": {
"type": "array",
"items": {"type": "string"},
"description": "Execution evidence IDs captured from sandbox history.",
},
"scenario_key": {
"type": "string",
"description": "Optional scenario namespace for grouping candidates.",
},
"payload_ref": {
"type": "string",
"description": "Optional payload reference created by astrbot_create_skill_payload.",
},
},
"required": ["skill_key", "source_execution_ids"],
}
)
async def call(
self,
context: ContextWrapper[AstrAgentContext],
skill_key: str,
source_execution_ids: list[str],
scenario_key: str | None = None,
payload_ref: str | None = None,
) -> ToolExecResult:
return await self._run(
context,
lambda client, _sandbox: client.skills.create_candidate(
skill_key=skill_key,
source_execution_ids=source_execution_ids,
scenario_key=scenario_key,
payload_ref=payload_ref,
),
error_action="creating skill candidate",
)
@dataclass
class ListSkillCandidatesTool(NeoSkillToolBase):
name: str = "astrbot_list_skill_candidates"
description: str = "List skill candidates."
parameters: dict = field(
default_factory=lambda: {
"type": "object",
"properties": {
"status": {"type": "string"},
"skill_key": {"type": "string"},
"limit": {"type": "integer", "default": 100},
"offset": {"type": "integer", "default": 0},
},
"required": [],
}
)
async def call(
self,
context: ContextWrapper[AstrAgentContext],
status: str | None = None,
skill_key: str | None = None,
limit: int = 100,
offset: int = 0,
) -> ToolExecResult:
return await self._run(
context,
lambda client, _sandbox: client.skills.list_candidates(
status=status,
skill_key=skill_key,
limit=limit,
offset=offset,
),
error_action="listing skill candidates",
)
@dataclass
class EvaluateSkillCandidateTool(NeoSkillToolBase):
name: str = "astrbot_evaluate_skill_candidate"
description: str = "Evaluate a skill candidate."
parameters: dict = field(
default_factory=lambda: {
"type": "object",
"properties": {
"candidate_id": {"type": "string"},
"passed": {"type": "boolean"},
"score": {"type": "number"},
"benchmark_id": {"type": "string"},
"report": {"type": "string"},
},
"required": ["candidate_id", "passed"],
}
)
async def call(
self,
context: ContextWrapper[AstrAgentContext],
candidate_id: str,
passed: bool,
score: float | None = None,
benchmark_id: str | None = None,
report: str | None = None,
) -> ToolExecResult:
return await self._run(
context,
lambda client, _sandbox: client.skills.evaluate_candidate(
candidate_id,
passed=passed,
score=score,
benchmark_id=benchmark_id,
report=report,
),
error_action="evaluating skill candidate",
)
@dataclass
class PromoteSkillCandidateTool(NeoSkillToolBase):
name: str = "astrbot_promote_skill_candidate"
description: str = (
"Step 3/3 for Neo skill authoring: promote candidate to canary/stable release. "
"If stage=stable and sync_to_local=true, payload.skill_markdown is synced to local SKILL.md automatically."
)
parameters: dict = field(
default_factory=lambda: {
"type": "object",
"properties": {
"candidate_id": {"type": "string"},
"stage": {
"type": "string",
"description": "Release stage: canary/stable",
"default": "canary",
},
"sync_to_local": {
"type": "boolean",
"description": (
"Only used with stage=stable. true means sync payload.skill_markdown to local SKILL.md; "
"false means release remains Neo-side only."
),
"default": True,
},
},
"required": ["candidate_id"],
}
)
async def call(
self,
context: ContextWrapper[AstrAgentContext],
candidate_id: str,
stage: str = "canary",
sync_to_local: bool = True,
) -> ToolExecResult:
if err := _ensure_admin(context):
return err
if stage not in {"canary", "stable"}:
return "Error promoting skill candidate: stage must be canary or stable."
try:
client, _sandbox = await _get_neo_context(context)
sync_mgr = NeoSkillSyncManager()
result = await sync_mgr.promote_with_optional_sync(
client,
candidate_id=candidate_id,
stage=stage,
sync_to_local=sync_to_local,
)
if result.get("sync_error"):
rollback_json = result.get("rollback")
if rollback_json:
return (
"Error promoting skill candidate: stable release synced failed; "
f"auto rollback succeeded. sync_error={result['sync_error']}; "
f"rollback={_to_json_text(rollback_json)}"
)
return _to_json_text(
{
"release": result.get("release"),
"sync": result.get("sync"),
"rollback": result.get("rollback"),
}
)
except Exception as e:
return f"Error promoting skill candidate: {str(e)}"
@dataclass
class ListSkillReleasesTool(NeoSkillToolBase):
name: str = "astrbot_list_skill_releases"
description: str = "List skill releases."
parameters: dict = field(
default_factory=lambda: {
"type": "object",
"properties": {
"skill_key": {"type": "string"},
"active_only": {"type": "boolean", "default": False},
"stage": {"type": "string"},
"limit": {"type": "integer", "default": 100},
"offset": {"type": "integer", "default": 0},
},
"required": [],
}
)
async def call(
self,
context: ContextWrapper[AstrAgentContext],
skill_key: str | None = None,
active_only: bool = False,
stage: str | None = None,
limit: int = 100,
offset: int = 0,
) -> ToolExecResult:
return await self._run(
context,
lambda client, _sandbox: client.skills.list_releases(
skill_key=skill_key,
active_only=active_only,
stage=stage,
limit=limit,
offset=offset,
),
error_action="listing skill releases",
)
@dataclass
class RollbackSkillReleaseTool(NeoSkillToolBase):
name: str = "astrbot_rollback_skill_release"
description: str = "Rollback one skill release."
parameters: dict = field(
default_factory=lambda: {
"type": "object",
"properties": {
"release_id": {"type": "string"},
},
"required": ["release_id"],
}
)
async def call(
self,
context: ContextWrapper[AstrAgentContext],
release_id: str,
) -> ToolExecResult:
return await self._run(
context,
lambda client, _sandbox: client.skills.rollback_release(release_id),
error_action="rolling back skill release",
)
@dataclass
class SyncSkillReleaseTool(NeoSkillToolBase):
name: str = "astrbot_sync_skill_release"
description: str = (
"Sync stable Neo release payload to local SKILL.md and update mapping metadata."
)
parameters: dict = field(
default_factory=lambda: {
"type": "object",
"properties": {
"release_id": {"type": "string"},
"skill_key": {"type": "string"},
"require_stable": {"type": "boolean", "default": True},
},
"required": [],
}
)
async def call(
self,
context: ContextWrapper[AstrAgentContext],
release_id: str | None = None,
skill_key: str | None = None,
require_stable: bool = True,
) -> ToolExecResult:
return await self._run(
context,
lambda client, _sandbox: _sync_release_to_dict(
client,
release_id=release_id,
skill_key=skill_key,
require_stable=require_stable,
),
error_action="syncing skill release",
)
async def _sync_release_to_dict(
client: Any,
*,
release_id: str | None,
skill_key: str | None,
require_stable: bool,
) -> dict[str, str]:
sync_mgr = NeoSkillSyncManager()
result = await sync_mgr.sync_release(
client,
release_id=release_id,
skill_key=skill_key,
require_stable=require_stable,
)
return sync_mgr.sync_result_to_dict(result)
+3 -43
View File
@@ -132,15 +132,11 @@ DEFAULT_CONFIG = {
"computer_use_runtime": "none",
"computer_use_require_admin": True,
"sandbox": {
"booter": "shipyard_neo",
"booter": "shipyard",
"shipyard_endpoint": "",
"shipyard_access_token": "",
"shipyard_ttl": 3600,
"shipyard_max_sessions": 10,
"shipyard_neo_endpoint": "",
"shipyard_neo_access_token": "",
"shipyard_neo_profile": "python-default",
"shipyard_neo_ttl": 3600,
},
},
# SubAgent orchestrator mode:
@@ -2875,48 +2871,12 @@ CONFIG_METADATA_3 = {
"provider_settings.sandbox.booter": {
"description": "沙箱环境驱动器",
"type": "string",
"options": ["shipyard_neo", "shipyard"],
"labels": ["Shipyard Neo", "Shipyard"],
"options": ["shipyard"],
"labels": ["Shipyard"],
"condition": {
"provider_settings.computer_use_runtime": "sandbox",
},
},
"provider_settings.sandbox.shipyard_neo_endpoint": {
"description": "Shipyard Neo API Endpoint",
"type": "string",
"hint": "Shipyard Neo(Bay) 服务的 API 地址,默认 http://127.0.0.1:8114。",
"condition": {
"provider_settings.computer_use_runtime": "sandbox",
"provider_settings.sandbox.booter": "shipyard_neo",
},
},
"provider_settings.sandbox.shipyard_neo_access_token": {
"description": "Shipyard Neo Access Token",
"type": "string",
"hint": "Bay 的 API Keysk-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",
-372
View File
@@ -1,372 +0,0 @@
from __future__ import annotations
import hashlib
import json
import os
import re
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from astrbot.core.computer.computer_client import sync_skills_to_active_sandboxes
from astrbot.core.skills.skill_manager import SkillManager
from astrbot.core.utils.astrbot_path import get_astrbot_skills_path
_MAP_VERSION = 1
_MAP_FILE_NAME = "neo_skill_map.json"
_SKILL_NAME_RE = re.compile(r"[^a-zA-Z0-9._-]+")
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def _to_jsonable(model_like: Any) -> dict[str, Any]:
if isinstance(model_like, dict):
return model_like
if hasattr(model_like, "model_dump"):
dumped = model_like.model_dump()
if isinstance(dumped, dict):
return dumped
return {}
def _parse_frontmatter(text: str) -> tuple[dict[str, str], str]:
if not text.startswith("---"):
return {}, text
lines = text.splitlines()
if not lines or lines[0].strip() != "---":
return {}, text
end_idx = None
for i in range(1, len(lines)):
if lines[i].strip() == "---":
end_idx = i
break
if end_idx is None:
return {}, text
data: dict[str, str] = {}
for line in lines[1:end_idx]:
if ":" not in line:
continue
key, value = line.split(":", 1)
key = key.strip().lower()
value = value.strip().strip('"').strip("'")
if key in {"name", "description"} and value:
data[key] = value
body = "\n".join(lines[end_idx + 1 :]).lstrip("\n")
return data, body
def _derive_description(markdown_body: str) -> str:
lines = markdown_body.splitlines()
heading_idx = None
for i, line in enumerate(lines):
normalized = line.strip().lower()
if normalized in {"## 描述", "## description"}:
heading_idx = i
break
if heading_idx is not None:
for line in lines[heading_idx + 1 :]:
text = line.strip()
if not text:
continue
if text.startswith("#"):
break
return text
for line in lines:
text = line.strip()
if not text or text.startswith("#"):
continue
return text
return ""
def _ensure_skill_frontmatter(markdown: str, *, skill_name: str, skill_key: str) -> str:
frontmatter, body = _parse_frontmatter(markdown)
name = frontmatter.get("name") or skill_name
name = " ".join(str(name).split())
description = frontmatter.get("description") or _derive_description(body)
if not description:
description = f"Synced skill for `{skill_key}`."
description = " ".join(description.split())
header = f"---\nname: {name}\ndescription: {description}\n---\n\n"
body = body.strip("\n")
return f"{header}{body}\n"
@dataclass
class NeoSkillSyncResult:
skill_key: str
local_skill_name: str
release_id: str
candidate_id: str
payload_ref: str
map_path: str
synced_at: str
class NeoSkillSyncManager:
@staticmethod
def sync_result_to_dict(result: NeoSkillSyncResult) -> dict[str, str]:
return {
"skill_key": result.skill_key,
"local_skill_name": result.local_skill_name,
"release_id": result.release_id,
"candidate_id": result.candidate_id,
"payload_ref": result.payload_ref,
"map_path": result.map_path,
"synced_at": result.synced_at,
}
def __init__(
self,
*,
skills_root: str | None = None,
map_path: str | None = None,
) -> None:
self.skills_root = skills_root or get_astrbot_skills_path()
self.map_path = map_path or str(Path(self.skills_root) / _MAP_FILE_NAME)
os.makedirs(self.skills_root, exist_ok=True)
def _load_map(self) -> dict[str, Any]:
if not os.path.exists(self.map_path):
return {"version": _MAP_VERSION, "items": {}}
try:
with open(self.map_path, encoding="utf-8") as f:
data = json.load(f)
if not isinstance(data, dict):
return {"version": _MAP_VERSION, "items": {}}
items = data.get("items", {})
if not isinstance(items, dict):
items = {}
return {"version": int(data.get("version", _MAP_VERSION)), "items": items}
except Exception:
return {"version": _MAP_VERSION, "items": {}}
def _save_map(self, data: dict[str, Any]) -> None:
os.makedirs(os.path.dirname(self.map_path), exist_ok=True)
with open(self.map_path, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
@staticmethod
def normalize_skill_name(skill_key: str) -> str:
normalized = _SKILL_NAME_RE.sub("-", skill_key.strip().lower())
normalized = normalized.strip("._-")
if not normalized:
normalized = "skill"
return f"neo_{normalized}"
def _resolve_local_skill_name(self, skill_key: str, mapping: dict[str, Any]) -> str:
items = mapping.get("items", {})
if not isinstance(items, dict):
items = {}
existing = items.get(skill_key)
if isinstance(existing, dict):
local_name = existing.get("local_skill_name")
if isinstance(local_name, str) and local_name:
return local_name
base = self.normalize_skill_name(skill_key)
used_names = {
str(v.get("local_skill_name"))
for v in items.values()
if isinstance(v, dict) and v.get("local_skill_name")
}
if base not in used_names:
return base
suffix = hashlib.sha1(skill_key.encode("utf-8")).hexdigest()[:8]
return f"{base}-{suffix}"
async def _find_release(self, client: Any, *, release_id: str) -> dict[str, Any]:
offset = 0
while True:
page = await client.skills.list_releases(limit=100, offset=offset)
page_json = _to_jsonable(page)
items = page_json.get("items", [])
if not isinstance(items, list):
items = []
for item in items:
if isinstance(item, dict) and item.get("id") == release_id:
return item
total = int(page_json.get("total", 0) or 0)
offset += len(items)
if offset >= total or not items:
break
raise ValueError(f"Release not found: {release_id}")
async def _find_active_stable_release(
self,
client: Any,
*,
skill_key: str,
) -> dict[str, Any]:
page = await client.skills.list_releases(
skill_key=skill_key,
active_only=True,
stage="stable",
limit=1,
offset=0,
)
page_json = _to_jsonable(page)
items = page_json.get("items", [])
if not isinstance(items, list) or not items:
raise ValueError(
f"No active stable release found for skill_key: {skill_key}"
)
if not isinstance(items[0], dict):
raise ValueError("Unexpected release payload format.")
return items[0]
async def sync_release(
self,
client: Any,
*,
release_id: str | None = None,
skill_key: str | None = None,
require_stable: bool = True,
) -> NeoSkillSyncResult:
if release_id:
release = await self._find_release(client, release_id=release_id)
elif skill_key:
release = await self._find_active_stable_release(
client, skill_key=skill_key
)
else:
raise ValueError("release_id or skill_key is required for sync.")
release_id_val = str(release.get("id") or "")
release_stage_raw = release.get("stage")
release_stage_value = getattr(release_stage_raw, "value", release_stage_raw)
release_stage = str(release_stage_value or "").strip().lower()
skill_key_val = str(release.get("skill_key") or "")
candidate_id = str(release.get("candidate_id") or "")
if not release_id_val or not skill_key_val or not candidate_id:
raise ValueError("Release payload is incomplete.")
if require_stable and release_stage != "stable":
raise ValueError(
"Only stable releases can be synced to local SKILL.md "
f"(got: {release_stage_raw})."
)
candidate = await client.skills.get_candidate(candidate_id)
candidate_json = _to_jsonable(candidate)
payload_ref = candidate_json.get("payload_ref")
if not isinstance(payload_ref, str) or not payload_ref:
raise ValueError("Candidate payload_ref is missing.")
payload_resp = await client.skills.get_payload(payload_ref)
payload_json = _to_jsonable(payload_resp)
payload = payload_json.get("payload")
if not isinstance(payload, dict):
raise ValueError("Skill payload must be a JSON object.")
skill_markdown = payload.get("skill_markdown")
if not isinstance(skill_markdown, str) or not skill_markdown.strip():
raise ValueError(
"payload.skill_markdown is required for stable sync to local skill."
)
mapping = self._load_map()
local_skill_name = self._resolve_local_skill_name(skill_key_val, mapping)
skill_dir = Path(self.skills_root) / local_skill_name
skill_dir.mkdir(parents=True, exist_ok=True)
normalized_markdown = _ensure_skill_frontmatter(
skill_markdown,
skill_name=local_skill_name,
skill_key=skill_key_val,
)
skill_md_path = skill_dir / "SKILL.md"
skill_md_path.write_text(normalized_markdown, encoding="utf-8")
items = mapping.setdefault("items", {})
items[skill_key_val] = {
"local_skill_name": local_skill_name,
"latest_release_id": release_id_val,
"latest_candidate_id": candidate_id,
"latest_payload_ref": payload_ref,
"updated_at": _now_iso(),
}
mapping["version"] = _MAP_VERSION
self._save_map(mapping)
# Ensure local skill is visible to AstrBot skill manager.
SkillManager().set_skill_active(local_skill_name, True)
# Best-effort synchronization to active sandboxes.
await sync_skills_to_active_sandboxes()
return NeoSkillSyncResult(
skill_key=skill_key_val,
local_skill_name=local_skill_name,
release_id=release_id_val,
candidate_id=candidate_id,
payload_ref=payload_ref,
map_path=self.map_path,
synced_at=_now_iso(),
)
async def promote_with_optional_sync(
self,
client: Any,
*,
candidate_id: str,
stage: str,
sync_to_local: bool,
) -> dict[str, Any]:
release = await client.skills.promote_candidate(candidate_id, stage=stage)
release_json = _to_jsonable(release)
sync_json: dict[str, Any] | None = None
rollback_json: dict[str, Any] | None = None
sync_error: str | None = None
if stage == "stable" and sync_to_local:
try:
sync_result = await self.sync_release(
client,
release_id=str(release_json.get("id", "")),
require_stable=True,
)
sync_json = self.sync_result_to_dict(sync_result)
except Exception as err:
sync_error = str(err)
try:
rollback = await client.skills.rollback_release(
str(release_json.get("id", ""))
)
rollback_json = _to_jsonable(rollback)
except Exception as rollback_err:
rollback_msg = str(rollback_err)
if "no previous release exists" in rollback_msg.lower():
rollback_json = {
"skipped": True,
"reason": rollback_msg,
}
else:
raise RuntimeError(
"stable release synced failed and auto rollback also failed; "
f"sync_error={sync_error}; rollback_error={rollback_err}"
) from rollback_err
return {
"release": release_json,
"sync": sync_json,
"rollback": rollback_json,
"sync_error": sync_error,
}
+37 -250
View File
@@ -7,7 +7,6 @@ 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 (
@@ -17,11 +16,9 @@ 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._-]+$")
@@ -32,23 +29,9 @@ class SkillInfo:
description: str
path: str
active: bool
source_type: str = "local_only"
source_label: str = "local"
local_exists: bool = True
sandbox_exists: bool = False
def _parse_frontmatter_description(text: str) -> str:
"""Extract the ``description`` value from YAML frontmatter.
Expects the standard SKILL.md format used by OpenAI Codex CLI and
Anthropic Claude Skills::
---
name: my-skill
description: What this skill does and when to use it.
---
"""
if not text.startswith("---"):
return ""
lines = text.splitlines()
@@ -70,74 +53,45 @@ def _parse_frontmatter_description(text: str) -> str:
return ""
# Regex for sanitizing paths used in prompt examples — only allow
# safe path characters to prevent prompt injection via crafted skill paths.
_SAFE_PATH_RE = re.compile(r"[^A-Za-z0-9_./ -]")
def build_skills_prompt(skills: list[SkillInfo]) -> str:
"""Build the skills section of the system prompt.
Generates a markdown-formatted skill inventory for the LLM. Only
``name`` and ``description`` are shown upfront; the LLM must read
the full ``SKILL.md`` before execution (progressive disclosure).
"""
skills_lines: list[str] = []
example_path = ""
skills_lines = []
for skill in skills:
description = skill.description or "No description"
skills_lines.append(
f"- **{skill.name}**: {description}\n File: `{skill.path}`"
)
if not example_path:
example_path = skill.path
skills_lines.append(f"- {skill.name}: {description} (file: {skill.path})")
skills_block = "\n".join(skills_lines)
# Sanitize example_path — it may originate from sandbox cache (untrusted)
example_path = _SAFE_PATH_RE.sub("", example_path) if example_path else ""
example_path = example_path or "<skills_root>/<skill_name>/SKILL.md"
# Based on openai/codex
return (
"## Skills\n\n"
"You have specialized skills — reusable instruction bundles stored "
"in `SKILL.md` files. Each skill has a **name** and a **description** "
"that tells you what it does and when to use it.\n\n"
"### Available skills\n\n"
f"{skills_block}\n\n"
"### Skill rules\n\n"
"1. **Discovery** — The list above is the complete skill inventory "
"for this session. Full instructions are in the referenced "
"`SKILL.md` file.\n"
"2. **When to trigger** — Use a skill if the user names it "
"explicitly, or if the task clearly matches the skill's description. "
"*Never silently skip a matching skill* — either use it or briefly "
"explain why you chose not to.\n"
"3. **Mandatory grounding** — Before executing any skill you MUST "
"first read its `SKILL.md` by running a shell command with the "
f"**absolute path** shown above (e.g. `cat {example_path}`). "
"Never rely on memory or assumptions about a skill's content.\n"
"4. **Progressive disclosure** — Load only what is directly "
"referenced from `SKILL.md`:\n"
" - If `scripts/` exist, prefer running or patching them over "
"rewriting code from scratch.\n"
" - If `assets/` or templates exist, reuse them.\n"
" - Do NOT bulk-load every file in the skill directory.\n"
"5. **Coordination** — When multiple skills apply, pick the minimal "
"set needed. Announce which skill(s) you are using and why "
"(one short line). Prefer `astrbot_*` tools when running skill "
"scripts.\n"
"6. **Context hygiene** — Avoid deep reference chasing; open only "
"files that are directly linked from `SKILL.md`.\n"
"7. **Failure handling** — If a skill cannot be applied, state the "
"issue clearly and continue with the best alternative.\n"
"## Skills\n"
"You have many useful skills that can help you accomplish various tasks.\n"
"A skill is a set of local instructions stored in a `SKILL.md` file.\n"
"### Available skills\n"
f"{skills_block}\n"
"### Skill Rules\n"
"\n"
"- Discovery: The list above shows all skills available in this session. Full instructions live in the referenced `SKILL.md`.\n"
"- Trigger rules: Use a skill if the user names it or the task matches its description. Do not carry skills across turns unless re-mentioned\n"
"### How to use a skill (progressive disclosure):\n"
" 0) Mandatory grounding: Before using any skill, you MUST inspect its `SKILL.md` using shell tools"
" (e.g., `cat`, `head`, `sed`, `awk`, `grep`). Do not rely on assumptions or memory.\n"
" 1) Load only directly referenced files, DO NOT bulk-load everything.\n"
" 2) If `scripts/` exist, prefer running or patching them instead of retyping large blocks of code.\n"
" 3) If `assets/` or templates exist, reuse them rather than recreating everything from scratch.\n"
"- Coordination:\n"
" - If multiple skills apply, choose the minimal set that covers the request and state the order in which you will use them.\n"
" - Announce which skill(s) you are using and why (one short line). If you skip an obvious skill, explain why.\n"
" - Prefer to use `astrbot_*` tools to perform skills that need to run scripts.\n"
"- Context hygiene:\n"
" - Avoid deep reference chasing: unless blocked, open only files that are directly linked from `SKILL.md`.\n"
"- Failure handling: If a skill cannot be applied, state the issue and continue with the best alternative.\n"
"### Example\n"
"When you decided to use a skill, use shell tool to read its `SKILL.md`, e.g., `head -40 skills/code_formatter/SKILL.md`, and you can increase or decrease the number of lines as needed.\n"
)
class SkillManager:
def __init__(self, skills_root: str | None = None) -> None:
self.skills_root = skills_root or get_astrbot_skills_path()
data_path = Path(get_astrbot_data_path())
self.config_path = str(data_path / SKILLS_CONFIG_FILENAME)
self.sandbox_skills_cache_path = str(data_path / SANDBOX_SKILLS_CACHE_FILENAME)
self.config_path = os.path.join(get_astrbot_data_path(), SKILLS_CONFIG_FILENAME)
os.makedirs(self.skills_root, exist_ok=True)
def _load_config(self) -> dict:
@@ -154,66 +108,6 @@ 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,
*,
@@ -230,21 +124,7 @@ class SkillManager:
config = self._load_config()
skill_configs = config.get("skills", {})
modified = False
skills_by_name: dict[str, SkillInfo] = {}
sandbox_cached_paths: dict[str, str] = {}
sandbox_cached_descriptions: dict[str, str] = {}
cache_for_paths = self._load_sandbox_skills_cache()
for item in cache_for_paths.get("skills", []):
if not isinstance(item, dict):
continue
name = str(item.get("name", "") or "").strip()
path = str(item.get("path", "") or "").strip().replace("\\", "/")
if not name or not _SKILL_NAME_RE.match(name):
continue
sandbox_cached_descriptions[name] = str(item.get("description", "") or "")
if path:
sandbox_cached_paths[name] = path
skills: list[SkillInfo] = []
for entry in sorted(Path(self.skills_root).iterdir()):
if not entry.is_dir():
@@ -265,129 +145,36 @@ 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 = sandbox_cached_paths.get(skill_name) or (
f"{SANDBOX_WORKSPACE_ROOT}/{SANDBOX_SKILLS_ROOT}/{skill_name}/SKILL.md"
)
path_str = f"{SANDBOX_SKILLS_ROOT}/{skill_name}/SKILL.md"
else:
path_str = str(skill_md)
path_str = path_str.replace("\\", "/")
skills_by_name[skill_name] = SkillInfo(
name=skill_name,
description=description,
path=path_str,
active=active,
source_type=source_type,
source_label=source_label,
local_exists=True,
sandbox_exists=sandbox_exists,
)
if runtime == "sandbox":
cache = self._load_sandbox_skills_cache()
for item in cache.get("skills", []):
if not isinstance(item, dict):
continue
skill_name = str(item.get("name", "")).strip()
if (
not skill_name
or skill_name in skills_by_name
or not _SKILL_NAME_RE.match(skill_name)
):
continue
active = skill_configs.get(skill_name, {}).get("active", True)
if skill_name not in skill_configs:
skill_configs[skill_name] = {"active": active}
modified = True
if active_only and not active:
continue
description = sandbox_cached_descriptions.get(skill_name, "")
if show_sandbox_path:
path_str = f"{SANDBOX_WORKSPACE_ROOT}/{SANDBOX_SKILLS_ROOT}/{skill_name}/SKILL.md"
else:
path_str = sandbox_cached_paths.get(skill_name, "")
if not path_str:
path_str = f"{SANDBOX_WORKSPACE_ROOT}/{SANDBOX_SKILLS_ROOT}/{skill_name}/SKILL.md"
skills_by_name[skill_name] = SkillInfo(
skills.append(
SkillInfo(
name=skill_name,
description=description,
path=path_str.replace("\\", "/"),
path=path_str,
active=active,
source_type="sandbox_only",
source_label="sandbox_preset",
local_exists=False,
sandbox_exists=True,
)
)
if modified:
config["skills"] = skill_configs
self._save_config(config)
return [skills_by_name[name] for name in sorted(skills_by_name)]
def is_sandbox_only_skill(self, name: str) -> bool:
skill_dir = Path(self.skills_root) / name
skill_md_exists = (skill_dir / "SKILL.md").exists()
if skill_md_exists:
return False
cache = self._load_sandbox_skills_cache()
skills = cache.get("skills", [])
if not isinstance(skills, list):
return False
for item in skills:
if not isinstance(item, dict):
continue
if str(item.get("name", "")).strip() == name:
return True
return False
return skills
def set_skill_active(self, name: str, active: bool) -> None:
if self.is_sandbox_only_skill(name):
raise PermissionError(
"Sandbox preset skill cannot be enabled/disabled from local skill management."
)
config = self._load_config()
config.setdefault("skills", {})
config["skills"][name] = {"active": bool(active)}
self._save_config(config)
def _remove_skill_from_sandbox_cache(self, name: str) -> None:
cache = self._load_sandbox_skills_cache()
skills = cache.get("skills", [])
if not isinstance(skills, list):
return
filtered = [
item
for item in skills
if not (
isinstance(item, dict) and str(item.get("name", "")).strip() == name
)
]
if len(filtered) != len(skills):
cache["skills"] = filtered
self._save_sandbox_skills_cache(cache)
def delete_skill(self, name: str) -> None:
if self.is_sandbox_only_skill(name):
raise PermissionError(
"Sandbox preset skill cannot be deleted from local skill management."
)
skill_dir = Path(self.skills_root) / name
if skill_dir.exists():
shutil.rmtree(skill_dir)
# Ensure UI consistency even when there is no active sandbox session
# to refresh cache from runtime side.
self._remove_skill_from_sandbox_cache(name)
config = self._load_config()
if name in config.get("skills", {}):
config["skills"].pop(name, None)
@@ -409,7 +196,7 @@ 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))
-103
View File
@@ -206,110 +206,12 @@ 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(
@@ -1026,11 +928,6 @@ 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())
+2 -407
View File
@@ -1,48 +1,15 @@
import os
import re
import shutil
import traceback
from collections.abc import Awaitable, Callable
from pathlib import Path
from typing import Any
from quart import request, send_file
from quart import request
from astrbot.core import DEMO_MODE, logger
from astrbot.core.computer.computer_client import (
_discover_bay_credentials,
sync_skills_to_active_sandboxes,
)
from astrbot.core.skills.neo_skill_sync import NeoSkillSyncManager
from astrbot.core.skills.skill_manager import SkillManager
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
from .route import Response, Route, RouteContext
def _to_jsonable(value: Any) -> Any:
if isinstance(value, dict):
return {k: _to_jsonable(v) for k, v in value.items()}
if isinstance(value, list):
return [_to_jsonable(v) for v in value]
if hasattr(value, "model_dump"):
return _to_jsonable(value.model_dump())
return value
def _to_bool(value: Any, default: bool = False) -> bool:
if value is None:
return default
if isinstance(value, bool):
return value
if isinstance(value, str):
return value.strip().lower() in {"1", "true", "yes", "y", "on"}
return bool(value)
_SKILL_NAME_RE = re.compile(r"^[A-Za-z0-9._-]+$")
class SkillsRoute(Route):
def __init__(self, context: RouteContext, core_lifecycle) -> None:
super().__init__(context)
@@ -50,81 +17,18 @@ 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")
skill_mgr = SkillManager()
skills = skill_mgr.list_skills(
skills = SkillManager().list_skills(
active_only=False, runtime=runtime, show_sandbox_path=False
)
return (
@@ -132,8 +36,6 @@ class SkillsRoute(Route):
.ok(
{
"skills": [skill.__dict__ for skill in skills],
"runtime": runtime,
"sandbox_cache": skill_mgr.get_sandbox_skills_cache_status(),
}
)
.__dict__
@@ -168,11 +70,6 @@ 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.")
@@ -188,53 +85,6 @@ 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 (
@@ -267,262 +117,7 @@ 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)
@@ -1,295 +1,60 @@
<template>
<div class="skills-page">
<v-container fluid class="pa-0" elevation="0">
<v-row class="d-flex justify-space-between align-center px-4 py-3 pb-4">
<v-row class="d-flex justify-space-between align-center px-4 py-3 pb-8">
<div>
<v-btn
v-if="mode === 'local'"
color="success"
prepend-icon="mdi-upload"
class="me-2"
variant="tonal"
@click="uploadDialog = true"
>
{{ tm("skills.upload") }}
<v-btn color="success" prepend-icon="mdi-upload" class="me-2" variant="tonal" @click="uploadDialog = true">
{{ tm('skills.upload') }}
</v-btn>
<v-btn color="primary" prepend-icon="mdi-refresh" variant="tonal" @click="refreshCurrentMode">
{{ tm("skills.refresh") }}
<v-btn color="primary" prepend-icon="mdi-refresh" variant="tonal" @click="fetchSkills">
{{ tm('skills.refresh') }}
</v-btn>
</div>
<v-btn-toggle v-model="mode" mandatory divided density="comfortable">
<v-btn value="local">{{ tm("skills.modeLocal") }}</v-btn>
<v-btn value="neo" :disabled="!neoEnabled">{{ tm("skills.modeNeo") }}</v-btn>
</v-btn-toggle>
</v-row>
<div v-if="mode === 'local'" class="px-2 pb-2 d-flex flex-column ga-2">
<small style="color: grey;">{{ tm("skills.runtimeHint") }}</small>
<v-alert
v-if="runtime === 'sandbox' && !sandboxCache.ready"
type="info"
variant="tonal"
density="comfortable"
border="start"
>
{{ tm("skills.sandboxDiscoveryPending") }}
</v-alert>
<div class="px-2 pb-2">
<small style="color: grey;">{{ tm('skills.runtimeHint') }}</small>
</div>
<div v-if="mode === 'neo' && !neoEnabled" class="px-3 pb-3">
<v-alert type="warning" variant="tonal" density="comfortable" border="start">
{{ neoUnavailableMessage }}
</v-alert>
<v-progress-linear v-if="loading" indeterminate color="primary"></v-progress-linear>
<div v-else-if="skills.length === 0" class="text-center pa-8">
<v-icon size="64" color="grey-lighten-1">mdi-folder-open</v-icon>
<p class="text-grey mt-4">{{ tm('skills.empty') }}</p>
<small class="text-grey">{{ tm('skills.emptyHint') }}</small>
</div>
<template v-if="mode === 'local'">
<v-progress-linear v-if="loading" indeterminate color="primary"></v-progress-linear>
<div v-else-if="skills.length === 0" class="text-center pa-8">
<v-icon size="64" color="grey-lighten-1">mdi-folder-open</v-icon>
<p class="text-grey mt-4">{{ tm("skills.empty") }}</p>
<small class="text-grey">{{ tm("skills.emptyHint") }}</small>
</div>
<v-row v-else align="stretch">
<v-col
v-for="skill in skills"
:key="skill.name"
cols="12"
md="6"
lg="4"
xl="3"
class="d-flex"
>
<item-card
:item="skill"
title-field="name"
enabled-field="active"
:loading="itemLoading[skill.name] || false"
:show-edit-button="false"
:disable-toggle="isSandboxPresetSkill(skill)"
:disable-delete="isSandboxPresetSkill(skill)"
@toggle-enabled="toggleSkill"
@delete="confirmDelete"
>
<template #item-details="{ item }">
<div class="d-flex align-center mb-2 ga-2 flex-wrap">
<v-chip
size="x-small"
variant="tonal"
:color="sourceTypeColor(item.source_type)"
>
{{ sourceTypeLabel(item.source_type) }}
</v-chip>
<div class="text-caption text-medium-emphasis skill-description">
<v-icon size="small" class="me-1">mdi-text</v-icon>
{{ item.description || tm("skills.noDescription") }}
</div>
</div>
<div class="text-caption text-medium-emphasis skill-path">
<v-icon size="small" class="me-1">mdi-file-document</v-icon>
{{ tm("skills.path") }}: {{ item.path }}
</div>
</template>
<template #actions="{ item }">
<v-btn
variant="tonal"
color="primary"
size="small"
rounded="xl"
:disabled="itemLoading[item.name] || false || isSandboxPresetSkill(item)"
@click="downloadSkill(item)"
>
{{ tm("skills.download") }}
</v-btn>
</template>
</item-card>
</v-col>
</v-row>
</template>
<template v-else-if="mode === 'neo' && neoEnabled">
<v-card class="mx-3 mb-4 pa-4 neo-filter-card" variant="outlined">
<div class="d-flex flex-wrap justify-space-between align-center ga-2 mb-3">
<div>
<div class="text-subtitle-1 font-weight-bold">Neo Skills</div>
<div class="text-caption text-medium-emphasis">{{ tm("skills.neoFilterHint") }}</div>
</div>
<v-btn color="primary" prepend-icon="mdi-refresh" variant="flat" @click="fetchNeoData">
{{ tm("skills.refresh") }}
</v-btn>
</div>
<v-row class="ga-md-0 ga-2">
<v-col cols="12" md="4">
<v-text-field
v-model="neoFilters.skill_key"
:label="tm('skills.neoSkillKey')"
prepend-inner-icon="mdi-key-outline"
density="comfortable"
hide-details
variant="outlined"
/>
</v-col>
<v-col cols="12" md="4">
<v-select
v-model="neoFilters.status"
:label="tm('skills.neoStatus')"
:items="candidateStatusItems"
item-title="title"
item-value="value"
prepend-inner-icon="mdi-progress-check"
density="comfortable"
hide-details
variant="outlined"
/>
</v-col>
<v-col cols="12" md="4">
<v-select
v-model="neoFilters.stage"
:label="tm('skills.neoStage')"
:items="releaseStageItems"
item-title="title"
item-value="value"
prepend-inner-icon="mdi-layers-outline"
density="comfortable"
hide-details
variant="outlined"
/>
</v-col>
</v-row>
</v-card>
<v-progress-linear v-if="neoLoading" indeterminate color="primary"></v-progress-linear>
<div class="mx-3 mb-3 d-flex flex-wrap ga-2">
<v-chip size="small" color="primary" variant="tonal">Candidates: {{ neoCandidates.length }}</v-chip>
<v-chip size="small" color="indigo" variant="tonal">Releases: {{ neoReleases.length }}</v-chip>
<v-chip size="small" color="success" variant="tonal">Active: {{ activeReleaseCount }}</v-chip>
</div>
<v-card class="mx-3 mb-4 neo-table-card" variant="outlined">
<v-card-title class="text-subtitle-1 font-weight-bold">{{ tm("skills.neoCandidates") }}</v-card-title>
<v-data-table
:headers="candidateHeaders"
:items="neoCandidates"
density="compact"
:items-per-page="10"
class="neo-data-table"
>
<template #item.latest_score="{ item }">
{{ item.latest_score ?? "-" }}
</template>
<template #item.actions="{ item }">
<div class="d-flex ga-1 flex-wrap">
<v-btn size="x-small" color="success" variant="tonal" @click="evaluateCandidate(item, true)">
{{ tm("skills.neoPass") }}
</v-btn>
<v-btn size="x-small" color="warning" variant="tonal" @click="evaluateCandidate(item, false)">
{{ tm("skills.neoReject") }}
</v-btn>
<v-btn
size="x-small"
color="primary"
variant="tonal"
:loading="isCandidatePromoteLoading(item.id, 'canary')"
:disabled="isCandidatePromoting(item.id)"
@click="promoteCandidate(item, 'canary')"
>
Canary
</v-btn>
<v-btn
size="x-small"
color="primary"
variant="tonal"
:loading="isCandidatePromoteLoading(item.id, 'stable')"
:disabled="isCandidatePromoting(item.id)"
@click="promoteCandidate(item, 'stable')"
>
Stable
</v-btn>
<v-btn
size="x-small"
variant="tonal"
@click="viewPayload(item.payload_ref)"
:disabled="!item.payload_ref"
>
Payload
</v-btn>
<v-btn
size="x-small"
color="error"
variant="tonal"
@click="deleteCandidate(item)"
>
{{ tm("skills.neoDelete") }}
</v-btn>
<v-row v-else>
<v-col v-for="skill in skills" :key="skill.name" cols="12" md="6" lg="4" xl="3">
<item-card :item="skill" title-field="name" enabled-field="active" :loading="itemLoading[skill.name] || false"
:show-edit-button="false" @toggle-enabled="toggleSkill" @delete="confirmDelete">
<template v-slot:item-details="{ item }">
<div class="text-caption text-medium-emphasis mb-2 skill-description">
<v-icon size="small" class="me-1">mdi-text</v-icon>
{{ item.description || tm('skills.noDescription') }}
</div>
<div class="text-caption text-medium-emphasis">
<v-icon size="small" class="me-1">mdi-file-document</v-icon>
{{ tm('skills.path') }}: {{ item.path }}
</div>
</template>
</v-data-table>
</v-card>
<v-card class="mx-3 mb-4 neo-table-card" variant="outlined">
<v-card-title class="text-subtitle-1 font-weight-bold">{{ tm("skills.neoReleases") }}</v-card-title>
<v-data-table
:headers="releaseHeaders"
:items="neoReleases"
density="compact"
:items-per-page="10"
class="neo-data-table"
>
<template #item.is_active="{ item }">
<v-chip size="small" :color="item.is_active ? 'success' : 'default'" variant="tonal">
{{ item.is_active ? "active" : "inactive" }}
</v-chip>
</template>
<template #item.actions="{ item }">
<div class="d-flex ga-1 flex-wrap">
<v-btn
size="x-small"
color="warning"
variant="tonal"
@click="handleReleaseLifecycleAction(item)"
>
{{ item.is_active ? tm("skills.neoDeactivate") : tm("skills.neoRollback") }}
</v-btn>
<v-btn size="x-small" color="primary" variant="tonal" @click="syncRelease(item)">
{{ tm("skills.neoSync") }}
</v-btn>
<v-btn
size="x-small"
color="error"
variant="tonal"
@click="deleteRelease(item)"
>
{{ tm("skills.neoDelete") }}
</v-btn>
</div>
</template>
</v-data-table>
</v-card>
</template>
</item-card>
</v-col>
</v-row>
</v-container>
<v-dialog v-model="uploadDialog" max-width="520px">
<v-card>
<v-card-title class="text-h3 pa-4 pb-0 pl-6">{{ tm("skills.uploadDialogTitle") }}</v-card-title>
<v-card-title class="text-h3 pa-4 pb-0 pl-6">{{ tm('skills.uploadDialogTitle') }}</v-card-title>
<v-card-text>
<small class="text-grey">{{ tm("skills.uploadHint") }}</small>
<v-file-input
v-model="uploadFile"
accept=".zip"
:label="tm('skills.selectFile')"
prepend-icon="mdi-folder-zip-outline"
variant="outlined"
class="mt-4"
:multiple="false"
/>
<small class="text-grey">{{ tm('skills.uploadHint') }}</small>
<v-file-input v-model="uploadFile" accept=".zip" :label="tm('skills.selectFile')"
prepend-icon="mdi-folder-zip-outline" variant="outlined" class="mt-4" :multiple="false" />
</v-card-text>
<v-card-actions class="d-flex justify-end">
<v-btn variant="text" @click="uploadDialog = false">{{ tm("skills.cancel") }}</v-btn>
<v-btn variant="text" @click="uploadDialog = false">{{ tm('skills.cancel') }}</v-btn>
<v-btn color="primary" :loading="uploading" :disabled="!uploadFile" @click="uploadSkill">
{{ tm("skills.confirmUpload") }}
{{ tm('skills.confirmUpload') }}
</v-btn>
</v-card-actions>
</v-card>
@@ -297,30 +62,18 @@
<v-dialog v-model="deleteDialog" max-width="400px">
<v-card>
<v-card-title>{{ tm("skills.deleteTitle") }}</v-card-title>
<v-card-text>{{ tm("skills.deleteMessage") }}</v-card-text>
<v-card-title>{{ tm('skills.deleteTitle') }}</v-card-title>
<v-card-text>{{ tm('skills.deleteMessage') }}</v-card-text>
<v-card-actions class="d-flex justify-end">
<v-btn variant="text" @click="deleteDialog = false">{{ tm("skills.cancel") }}</v-btn>
<v-btn variant="text" @click="deleteDialog = false">{{ tm('skills.cancel') }}</v-btn>
<v-btn color="error" :loading="deleting" @click="deleteSkill">
{{ t("core.common.itemCard.delete") }}
{{ t('core.common.itemCard.delete') }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-dialog v-model="payloadDialog.show" max-width="820px">
<v-card>
<v-card-title>{{ tm("skills.neoPayloadTitle") }}</v-card-title>
<v-card-text>
<pre class="payload-preview">{{ payloadDialog.content }}</pre>
</v-card-text>
<v-card-actions class="d-flex justify-end">
<v-btn variant="text" @click="payloadDialog.show = false">{{ tm("skills.cancel") }}</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-snackbar v-model="snackbar.show" :timeout="3500" :color="snackbar.color" elevation="24">
<v-snackbar v-model="snackbar.show" :timeout="3000" :color="snackbar.color" elevation="24">
{{ snackbar.message }}
</v-snackbar>
</div>
@@ -328,7 +81,7 @@
<script>
import axios from "axios";
import { computed, onMounted, reactive, ref, watch } from "vue";
import { ref, reactive, onMounted } from "vue";
import ItemCard from "@/components/shared/ItemCard.vue";
import { useI18n, useModuleI18n } from "@/i18n/composables";
@@ -339,11 +92,8 @@ export default {
const { t } = useI18n();
const { tm } = useModuleI18n("features/extension");
const mode = ref("local");
const skills = ref([]);
const loading = ref(false);
const runtime = ref("local");
const sandboxCache = reactive({ ready: false, count: 0, updated_at: null });
const uploading = ref(false);
const uploadDialog = ref(false);
const uploadFile = ref(null);
@@ -353,109 +103,23 @@ export default {
const skillToDelete = ref(null);
const snackbar = reactive({ show: false, message: "", color: "success" });
const neoLoading = ref(false);
const neoCandidates = ref([]);
const neoReleases = ref([]);
const neoFilters = reactive({
skill_key: "",
status: "",
stage: "",
});
const candidatePromoteLoading = reactive({});
const payloadDialog = reactive({
show: false,
content: "",
});
const neoEnabled = ref(false);
const neoUnavailableMessage = ref("");
const candidateStatusItems = computed(() => [
{ title: tm("skills.neoAll"), value: "" },
{ title: "draft", value: "draft" },
{ title: "evaluating", value: "evaluating" },
{ title: "promoted", value: "promoted" },
{ title: "promoted_canary", value: "promoted_canary" },
{ title: "promoted_stable", value: "promoted_stable" },
{ title: "rejected", value: "rejected" },
{ title: "rolled_back", value: "rolled_back" },
]);
const releaseStageItems = computed(() => [
{ title: tm("skills.neoAll"), value: "" },
{ title: "canary", value: "canary" },
{ title: "stable", value: "stable" },
]);
const activeReleaseCount = computed(() => neoReleases.value.filter((item) => item?.is_active).length);
const candidateHeaders = computed(() => [
{ title: "ID", key: "id", width: "180px" },
{ title: "skill_key", key: "skill_key" },
{ title: "status", key: "status", width: "130px" },
{ title: "score", key: "latest_score", width: "90px" },
{ title: tm("skills.actions"), key: "actions", sortable: false, width: "420px" },
]);
const releaseHeaders = computed(() => [
{ title: "ID", key: "id", width: "180px" },
{ title: "skill_key", key: "skill_key" },
{ title: "stage", key: "stage", width: "100px" },
{ title: "version", key: "version", width: "90px" },
{ title: "active", key: "is_active", width: "110px" },
{ title: tm("skills.actions"), key: "actions", sortable: false, width: "220px" },
]);
const showMessage = (message, color = "success") => {
snackbar.message = message;
snackbar.color = color;
snackbar.show = true;
};
const normalizeSkillsPayload = (res) => {
const payload = res?.data?.data || [];
if (Array.isArray(payload)) {
runtime.value = "local";
sandboxCache.ready = false;
sandboxCache.count = 0;
sandboxCache.updated_at = null;
return payload;
}
runtime.value = payload.runtime || "local";
const cache = payload.sandbox_cache || {};
sandboxCache.ready = !!cache.ready;
sandboxCache.count = Number(cache.count || 0);
sandboxCache.updated_at = cache.updated_at || null;
return payload.skills || [];
};
const sourceTypeLabel = (sourceType) => {
if (sourceType === "sandbox_only") return tm("skills.sourceSandboxOnly");
if (sourceType === "both") return tm("skills.sourceBoth");
return tm("skills.sourceLocalOnly");
};
const sourceTypeColor = (sourceType) => {
if (sourceType === "sandbox_only") return "indigo";
if (sourceType === "both") return "success";
return "primary";
};
const isSandboxPresetSkill = (skill) => skill?.source_type === "sandbox_only";
const normalizeNeoItemsPayload = (res) => {
const payload = res?.data?.data || [];
if (Array.isArray(payload)) return payload;
if (Array.isArray(payload.items)) return payload.items;
return [];
};
const fetchSkills = async () => {
loading.value = true;
try {
const res = await axios.get("/api/skills");
skills.value = normalizeSkillsPayload(res);
} catch (_err) {
const payload = res.data?.data || [];
if (Array.isArray(payload)) {
skills.value = payload;
} else {
skills.value = payload.skills || [];
}
} catch (err) {
showMessage(tm("skills.loadFailed"), "error");
} finally {
loading.value = false;
@@ -477,7 +141,9 @@ export default {
uploading.value = true;
try {
const formData = new FormData();
const file = Array.isArray(uploadFile.value) ? uploadFile.value[0] : uploadFile.value;
const file = Array.isArray(uploadFile.value)
? uploadFile.value[0]
: uploadFile.value;
if (!file) {
uploading.value = false;
return;
@@ -486,12 +152,17 @@ export default {
const res = await axios.post("/api/skills/upload", formData, {
headers: { "Content-Type": "multipart/form-data" },
});
handleApiResponse(res, tm("skills.uploadSuccess"), tm("skills.uploadFailed"), async () => {
uploadDialog.value = false;
uploadFile.value = null;
await fetchSkills();
});
} catch (_err) {
handleApiResponse(
res,
tm("skills.uploadSuccess"),
tm("skills.uploadFailed"),
async () => {
uploadDialog.value = false;
uploadFile.value = null;
await fetchSkills();
}
);
} catch (err) {
showMessage(tm("skills.uploadFailed"), "error");
} finally {
uploading.value = false;
@@ -499,10 +170,6 @@ export default {
};
const toggleSkill = async (skill) => {
if (isSandboxPresetSkill(skill)) {
showMessage(tm("skills.sandboxPresetReadonly"), "warning");
return;
}
const nextActive = !skill.active;
itemLoading[skill.name] = true;
try {
@@ -510,10 +177,15 @@ export default {
name: skill.name,
active: nextActive,
});
handleApiResponse(res, tm("skills.updateSuccess"), tm("skills.updateFailed"), () => {
skill.active = nextActive;
});
} catch (_err) {
handleApiResponse(
res,
tm("skills.updateSuccess"),
tm("skills.updateFailed"),
() => {
skill.active = nextActive;
}
);
} catch (err) {
showMessage(tm("skills.updateFailed"), "error");
} finally {
itemLoading[skill.name] = false;
@@ -521,10 +193,6 @@ export default {
};
const confirmDelete = (skill) => {
if (isSandboxPresetSkill(skill)) {
showMessage(tm("skills.sandboxPresetReadonly"), "warning");
return;
}
skillToDelete.value = skill;
deleteDialog.value = true;
};
@@ -536,288 +204,29 @@ export default {
const res = await axios.post("/api/skills/delete", {
name: skillToDelete.value.name,
});
handleApiResponse(res, tm("skills.deleteSuccess"), tm("skills.deleteFailed"), async () => {
deleteDialog.value = false;
await fetchSkills();
});
} catch (_err) {
handleApiResponse(
res,
tm("skills.deleteSuccess"),
tm("skills.deleteFailed"),
async () => {
deleteDialog.value = false;
await fetchSkills();
}
);
} catch (err) {
showMessage(tm("skills.deleteFailed"), "error");
} finally {
deleting.value = false;
}
};
const downloadSkill = async (skill) => {
if (isSandboxPresetSkill(skill)) {
showMessage(tm("skills.sandboxPresetReadonly"), "warning");
return;
}
itemLoading[skill.name] = true;
try {
const res = await axios.get("/api/skills/download", {
params: { name: skill.name },
responseType: "blob",
});
const blob = new Blob([res.data], { type: "application/zip" });
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `${skill.name}.zip`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
showMessage(tm("skills.downloadSuccess"), "success");
} catch (_err) {
showMessage(tm("skills.downloadFailed"), "error");
} finally {
itemLoading[skill.name] = false;
}
};
const fetchNeoCandidates = async () => {
const params = {
skill_key: neoFilters.skill_key || undefined,
status: neoFilters.status || undefined,
};
const res = await axios.get("/api/skills/neo/candidates", { params });
neoCandidates.value = normalizeNeoItemsPayload(res);
};
const fetchNeoReleases = async () => {
const params = {
skill_key: neoFilters.skill_key || undefined,
stage: neoFilters.stage || undefined,
};
const res = await axios.get("/api/skills/neo/releases", { params });
neoReleases.value = normalizeNeoItemsPayload(res).map((item) => {
if (!item || typeof item !== "object") {
return item;
}
return {
...item,
is_active: item.is_active ?? item.active ?? false,
};
});
};
const loadNeoAvailability = async () => {
try {
const res = await axios.get("/api/config/get");
const config = res?.data?.data?.config || {};
const providerSettings = config?.provider_settings || {};
const runtime = providerSettings?.computer_use_runtime || "local";
const booter = providerSettings?.sandbox?.booter || "";
neoEnabled.value = runtime === "sandbox" && booter === "shipyard_neo";
} catch (_err) {
neoEnabled.value = false;
}
neoUnavailableMessage.value = tm("skills.neoRuntimeRequired");
if (!neoEnabled.value && mode.value === "neo") {
mode.value = "local";
}
};
const fetchNeoData = async () => {
neoLoading.value = true;
try {
await Promise.all([fetchNeoCandidates(), fetchNeoReleases()]);
} catch (_err) {
showMessage(tm("skills.neoLoadFailed"), "error");
} finally {
neoLoading.value = false;
}
};
const evaluateCandidate = async (candidate, passed) => {
try {
const res = await axios.post("/api/skills/neo/evaluate", {
candidate_id: candidate.id,
passed,
score: passed ? 1.0 : 0.0,
report: passed ? "approved_from_webui" : "rejected_from_webui",
});
handleApiResponse(res, tm("skills.neoEvaluateSuccess"), tm("skills.neoEvaluateFailed"), async () => {
await fetchNeoCandidates();
});
} catch (_err) {
showMessage(tm("skills.neoEvaluateFailed"), "error");
}
};
const candidatePromoteLoadingKey = (candidateId, stage) => `${candidateId}:${stage}`;
const isCandidatePromoteLoading = (candidateId, stage) =>
!!candidatePromoteLoading[candidatePromoteLoadingKey(candidateId, stage)];
const isCandidatePromoting = (candidateId) =>
isCandidatePromoteLoading(candidateId, "canary") || isCandidatePromoteLoading(candidateId, "stable");
const promoteCandidate = async (candidate, stage) => {
const candidateId = candidate?.id;
if (!candidateId) return;
const loadingKey = candidatePromoteLoadingKey(candidateId, stage);
if (candidatePromoteLoading[loadingKey]) return;
candidatePromoteLoading[loadingKey] = true;
try {
const res = await axios.post("/api/skills/neo/promote", {
candidate_id: candidateId,
stage,
sync_to_local: true,
});
const ok = res?.data?.status === "ok";
if (!ok) {
showMessage(res?.data?.message || tm("skills.neoPromoteFailed"), "error");
} else {
showMessage(tm("skills.neoPromoteSuccess"), "success");
}
await fetchNeoData();
if (stage === "stable") {
await fetchSkills();
}
} catch (_err) {
showMessage(tm("skills.neoPromoteFailed"), "error");
} finally {
candidatePromoteLoading[loadingKey] = false;
}
};
const rollbackRelease = async (release) => {
try {
const res = await axios.post("/api/skills/neo/rollback", {
release_id: release.id,
});
handleApiResponse(res, tm("skills.neoRollbackSuccess"), tm("skills.neoRollbackFailed"), async () => {
await fetchNeoData();
});
} catch (_err) {
showMessage(tm("skills.neoRollbackFailed"), "error");
}
};
const deactivateRelease = async (release) => {
try {
const res = await axios.post("/api/skills/neo/rollback", {
release_id: release.id,
});
handleApiResponse(
res,
tm("skills.neoDeactivateSuccess"),
tm("skills.neoDeactivateFailed"),
async () => {
await fetchNeoData();
},
);
} catch (_err) {
showMessage(tm("skills.neoDeactivateFailed"), "error");
}
};
const handleReleaseLifecycleAction = async (release) => {
if (release?.is_active) {
await deactivateRelease(release);
return;
}
await rollbackRelease(release);
};
const syncRelease = async (release) => {
try {
const res = await axios.post("/api/skills/neo/sync", {
release_id: release.id,
});
handleApiResponse(res, tm("skills.neoSyncSuccess"), tm("skills.neoSyncFailed"), async () => {
await fetchSkills();
});
} catch (_err) {
showMessage(tm("skills.neoSyncFailed"), "error");
}
};
const viewPayload = async (payloadRef) => {
if (!payloadRef) return;
try {
const res = await axios.get("/api/skills/neo/payload", {
params: { payload_ref: payloadRef },
});
if (res?.data?.status !== "ok") {
showMessage(res?.data?.message || tm("skills.neoPayloadFailed"), "error");
return;
}
const payload = res?.data?.data || {};
payloadDialog.content = JSON.stringify(payload, null, 2);
payloadDialog.show = true;
} catch (_err) {
showMessage(tm("skills.neoPayloadFailed"), "error");
}
};
const deleteCandidate = async (candidate) => {
try {
const res = await axios.post("/api/skills/neo/delete-candidate", {
candidate_id: candidate.id,
reason: "deleted_from_webui",
});
handleApiResponse(res, tm("skills.neoDeleteSuccess"), tm("skills.neoDeleteFailed"), async () => {
await fetchNeoData();
});
} catch (_err) {
showMessage(tm("skills.neoDeleteFailed"), "error");
}
};
const deleteRelease = async (release) => {
try {
const res = await axios.post("/api/skills/neo/delete-release", {
release_id: release.id,
reason: "deleted_from_webui",
});
handleApiResponse(res, tm("skills.neoDeleteSuccess"), tm("skills.neoDeleteFailed"), async () => {
await fetchNeoData();
});
} catch (_err) {
showMessage(tm("skills.neoDeleteFailed"), "error");
}
};
const refreshCurrentMode = async () => {
if (mode.value === "neo") {
await loadNeoAvailability();
if (neoEnabled.value) {
await fetchNeoData();
} else {
showMessage(tm("skills.neoRuntimeRequired"), "warning");
}
} else {
await fetchSkills();
}
};
watch(mode, async (nextMode) => {
if (nextMode === "neo") {
await loadNeoAvailability();
if (neoEnabled.value) {
await fetchNeoData();
}
} else {
await fetchSkills();
}
});
onMounted(async () => {
await Promise.all([fetchSkills(), loadNeoAvailability()]);
if (neoEnabled.value) {
await fetchNeoData();
}
});
onMounted(fetchSkills);
return {
t,
tm,
mode,
skills,
loading,
runtime,
sandboxCache,
uploadDialog,
uploadFile,
uploading,
@@ -825,39 +234,11 @@ export default {
deleteDialog,
deleting,
snackbar,
neoEnabled,
neoUnavailableMessage,
neoLoading,
neoCandidates,
neoReleases,
neoFilters,
candidateStatusItems,
releaseStageItems,
activeReleaseCount,
candidateHeaders,
releaseHeaders,
payloadDialog,
refreshCurrentMode,
fetchNeoData,
fetchSkills,
uploadSkill,
downloadSkill,
toggleSkill,
confirmDelete,
deleteSkill,
evaluateCandidate,
promoteCandidate,
isCandidatePromoteLoading,
isCandidatePromoting,
rollbackRelease,
deactivateRelease,
handleReleaseLifecycleAction,
syncRelease,
viewPayload,
deleteCandidate,
deleteRelease,
sourceTypeLabel,
sourceTypeColor,
isSandboxPresetSkill,
};
},
};
@@ -869,42 +250,5 @@ export default {
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
min-height: 20px;
}
.skill-path {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
min-height: 40px;
word-break: break-all;
}
.payload-preview {
max-height: 480px;
overflow: auto;
background: #111;
color: #ececec;
padding: 12px;
border-radius: 8px;
font-size: 12px;
}
.neo-filter-card {
border-radius: 14px;
border-color: rgba(var(--v-theme-primary), 0.25);
background: linear-gradient(180deg, rgba(var(--v-theme-primary), 0.03), rgba(var(--v-theme-surface), 1));
}
.neo-table-card {
border-radius: 14px;
}
.neo-data-table :deep(.v-data-table-header__content) {
font-weight: 700;
}
.neo-data-table :deep(tbody tr:hover) {
background: rgba(var(--v-theme-primary), 0.04);
}
</style>
+2 -11
View File
@@ -10,7 +10,7 @@
density="compact"
:model-value="getItemEnabled()"
:loading="loading"
:disabled="loading || disableToggle"
:disabled="loading"
v-bind="props"
@update:model-value="toggleEnabled"
></v-switch>
@@ -29,7 +29,7 @@
color="error"
size="small"
rounded="xl"
:disabled="loading || disableDelete"
:disabled="loading"
@click="$emit('delete', item)"
>
{{ t('core.common.itemCard.delete') }}
@@ -108,14 +108,6 @@ export default {
showEditButton: {
type: Boolean,
default: true
},
disableToggle: {
type: Boolean,
default: false
},
disableDelete: {
type: Boolean,
default: false
}
},
emits: ['toggle-enabled', 'delete', 'edit', 'copy'],
@@ -140,7 +132,6 @@ export default {
transition: all 0.3s ease;
overflow: hidden;
min-height: 220px;
height: 100%;
display: flex;
flex-direction: column;
justify-content: space-between;
@@ -161,22 +161,6 @@
"booter": {
"description": "Sandbox Environment Driver"
},
"shipyard_neo_endpoint": {
"description": "Shipyard Neo API Endpoint",
"hint": "Bay API address, default http://127.0.0.1:8114."
},
"shipyard_neo_access_token": {
"description": "Shipyard Neo Access Token",
"hint": "Bay API Key (sk-bay-...). Leave empty for auto-discovery from credentials.json."
},
"shipyard_neo_profile": {
"description": "Shipyard Neo Profile",
"hint": "Sandbox profile for Shipyard Neo, e.g. python-default."
},
"shipyard_neo_ttl": {
"description": "Shipyard Neo Sandbox TTL",
"hint": "Sandbox time-to-live in seconds."
},
"shipyard_endpoint": {
"description": "Shipyard API Endpoint",
"hint": "API access address for Shipyard service."
@@ -216,9 +216,6 @@
"enterUrl": "Enter extension repository URL"
},
"skills": {
"modeLocal": "Local Skills",
"modeNeo": "Neo Skills",
"actions": "Actions",
"upload": "Upload Skills",
"refresh": "Refresh",
"empty": "No Skills found",
@@ -232,9 +229,6 @@
"path": "Path",
"uploadSuccess": "Upload succeeded",
"uploadFailed": "Upload failed",
"download": "Download",
"downloadSuccess": "Download succeeded",
"downloadFailed": "Download failed",
"loadFailed": "Failed to load Skills",
"updateSuccess": "Updated successfully",
"updateFailed": "Update failed",
@@ -242,42 +236,8 @@
"deleteMessage": "Are you sure you want to delete this Skill?",
"deleteSuccess": "Deleted successfully",
"deleteFailed": "Delete failed",
"neoSkillKey": "Filter by skill_key",
"neoStatus": "Candidate Status",
"neoStage": "Release Stage",
"neoFilterHint": "Filter candidates and release records",
"neoAll": "All",
"neoCandidates": "Neo Candidates",
"neoReleases": "Neo Releases",
"neoLoadFailed": "Failed to load Neo skills data",
"neoPass": "Pass",
"neoReject": "Reject",
"neoEvaluateSuccess": "Evaluation updated",
"neoEvaluateFailed": "Failed to update evaluation",
"neoPromoteSuccess": "Promoted successfully",
"neoPromoteFailed": "Failed to promote",
"neoRollback": "Rollback",
"neoRollbackSuccess": "Rollback succeeded",
"neoRollbackFailed": "Rollback failed",
"neoDeactivate": "Deactivate",
"neoDeactivateSuccess": "Deactivated successfully",
"neoDeactivateFailed": "Failed to deactivate",
"neoSync": "Sync",
"neoSyncSuccess": "Sync succeeded",
"neoSyncFailed": "Sync failed",
"neoDelete": "Delete",
"neoDeleteSuccess": "Deleted successfully",
"neoDeleteFailed": "Failed to delete",
"neoPayloadTitle": "Neo Payload",
"neoPayloadFailed": "Failed to load payload",
"runtimeNoneWarning": "Computer Use runtime is set to None; Skills may not run correctly because no runtime is enabled.",
"runtimeHint": "Set the Computer Use runtime to Local or Sandbox in settings so AstrBot can use your Skills.",
"neoRuntimeRequired": "Neo Skills are available only when runtime is sandbox and sandbox booter is shipyard_neo.",
"sourceLocalOnly": "Local Skill",
"sourceSandboxOnly": "Sandbox Preset Skill",
"sourceBoth": "Local + Sandbox",
"sandboxDiscoveryPending": "Sandbox preset skills have not been discovered yet. Start at least one sandbox session to populate this list.",
"sandboxPresetReadonly": "Sandbox preset skills are read-only here. You cannot delete or enable/disable them from Local Skills."
"runtimeHint": "Set the Computer Use runtime to Local or Sandbox in settings so AstrBot can use your Skills."
},
"card": {
"actions": {
@@ -164,22 +164,6 @@
"booter": {
"description": "沙箱环境驱动器"
},
"shipyard_neo_endpoint": {
"description": "Shipyard Neo API Endpoint",
"hint": "Shipyard Neo(Bay) 服务的 API 地址,默认 http://127.0.0.1:8114。"
},
"shipyard_neo_access_token": {
"description": "Shipyard Neo 访问令牌",
"hint": "Bay 的 API Keysk-bay-...)。留空时自动从 credentials.json 发现。"
},
"shipyard_neo_profile": {
"description": "Shipyard Neo Profile",
"hint": "Shipyard Neo 沙箱 profile,例如 python-default。"
},
"shipyard_neo_ttl": {
"description": "Shipyard Neo Sandbox 存活时间(秒)",
"hint": "Shipyard Neo 沙箱的生存时间(秒)。"
},
"shipyard_endpoint": {
"description": "Shipyard API Endpoint",
"hint": "Shipyard 服务的 API 访问地址。"
@@ -216,9 +216,6 @@
"enterUrl": "输入插件仓库链接"
},
"skills": {
"modeLocal": "本地 Skills",
"modeNeo": "Neo Skills",
"actions": "操作",
"upload": "上传 Skills",
"refresh": "刷新",
"empty": "暂无 Skills",
@@ -232,9 +229,6 @@
"path": "路径",
"uploadSuccess": "上传成功",
"uploadFailed": "上传失败",
"download": "下载",
"downloadSuccess": "下载成功",
"downloadFailed": "下载失败",
"loadFailed": "加载 Skills 失败",
"updateSuccess": "更新成功",
"updateFailed": "更新失败",
@@ -242,42 +236,8 @@
"deleteMessage": "确定要删除该 Skill 吗?",
"deleteSuccess": "删除成功",
"deleteFailed": "删除失败",
"neoSkillKey": "skill_key 过滤",
"neoStatus": "候选状态",
"neoStage": "发布阶段",
"neoFilterHint": "筛选候选与发布记录",
"neoAll": "全部",
"neoCandidates": "Neo Candidates",
"neoReleases": "Neo Releases",
"neoLoadFailed": "加载 Neo Skills 数据失败",
"neoPass": "通过",
"neoReject": "拒绝",
"neoEvaluateSuccess": "评测更新成功",
"neoEvaluateFailed": "评测更新失败",
"neoPromoteSuccess": "发布成功",
"neoPromoteFailed": "发布失败",
"neoRollback": "回滚",
"neoRollbackSuccess": "回滚成功",
"neoRollbackFailed": "回滚失败",
"neoDeactivate": "失活",
"neoDeactivateSuccess": "失活成功",
"neoDeactivateFailed": "失活失败",
"neoSync": "同步",
"neoSyncSuccess": "同步成功",
"neoSyncFailed": "同步失败",
"neoDelete": "删除",
"neoDeleteSuccess": "删除成功",
"neoDeleteFailed": "删除失败",
"neoPayloadTitle": "Neo Payload 详情",
"neoPayloadFailed": "读取 Payload 失败",
"runtimeNoneWarning": "Computer Use 运行环境为无,Skills 可能无法正确被 Agent 运行,因为没有启用运行环境。",
"runtimeHint": "需要在配置的 “使用电脑能力” 中将运行环境设置为 “local” 或 “sandbox” 才能让 AstrBot 正常使用你提供的 Skills。",
"neoRuntimeRequired": "Neo Skills 仅在运行环境为 sandbox 且沙箱驱动为 shipyard_neo 时可用。",
"sourceLocalOnly": "本地 Skill",
"sourceSandboxOnly": "Sandbox 预置 Skill",
"sourceBoth": "本地 + Sandbox",
"sandboxDiscoveryPending": "尚未发现 Sandbox 预置 Skill。请至少启动一次 Sandbox 会话后再查看。",
"sandboxPresetReadonly": "Sandbox 预置 Skill 在此处为只读,无法在本地 Skills 页面删除或启用/禁用。"
"runtimeHint": "需要在配置的 “使用电脑能力” 中将运行环境设置为 “local” 或 “sandbox” 才能让 AstrBot 正常使用你提供的 Skills。"
},
"card": {
"actions": {
-20
View File
@@ -1,20 +0,0 @@
schema: spec-driven
# Project context (optional)
# This is shown to AI when creating artifacts.
# Add your tech stack, conventions, style guides, domain knowledge, etc.
# Example:
# context: |
# Tech stack: TypeScript, React, Node.js
# We use conventional commits
# Domain: e-commerce platform
# Per-artifact rules (optional)
# Add custom rules for specific artifacts.
# Example:
# rules:
# proposal:
# - Keep proposals under 500 words
# - Always include a "Non-goals" section
# tasks:
# - Break tasks into chunks of max 2 hours
-4
View File
@@ -61,7 +61,6 @@ dependencies = [
"xinference-client",
"tenacity>=9.1.2",
"shipyard-python-sdk>=0.2.4",
"shipyard-neo-sdk @ git+https://github.com/AstrBotDevs/shipyard-neo.git#subdirectory=shipyard-neo-sdk",
"python-socks>=2.8.0",
"packaging>=24.2",
]
@@ -111,9 +110,6 @@ reportMissingImports = false
include = ["astrbot"]
exclude = ["dashboard", "node_modules", "dist", "data", "tests"]
[tool.hatch.metadata]
allow-direct-references = true
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
-1
View File
@@ -54,5 +54,4 @@ markitdown-no-magika[docx,xls,xlsx]>=0.1.2
xinference-client
tenacity>=9.1.2
shipyard-python-sdk>=0.2.4
shipyard-neo-sdk @ git+https://github.com/AstrBotDevs/shipyard-neo.git#subdirectory=shipyard-neo-sdk
packaging>=24.2
-171
View File
@@ -1,171 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT_DIR"
PROFILE="neo"
RUN_SYNC=true
RUN_LINT=true
RUN_SMOKE=true
RUN_DASHBOARD=false
usage() {
cat <<'EOF'
Usage:
scripts/pr_test_env.sh [options]
Options:
--profile <neo|full> Test profile. Default: neo
--with-dashboard Build dashboard before finishing checks
--no-dashboard Disable dashboard build (even for full profile)
--skip-sync Skip `uv sync`
--skip-lint Skip `ruff format --check` and `ruff check`
--skip-smoke Skip startup smoke test
-h, --help Show this help message
Environment:
PYTEST_ARGS Extra args appended to pytest command
EOF
}
while (($# > 0)); do
case "$1" in
--profile)
PROFILE="${2:-}"
if [[ "$PROFILE" != "neo" && "$PROFILE" != "full" ]]; then
echo "Unsupported profile: $PROFILE" >&2
exit 1
fi
shift 2
;;
--with-dashboard)
RUN_DASHBOARD=true
shift
;;
--skip-sync)
RUN_SYNC=false
shift
;;
--skip-lint)
RUN_LINT=false
shift
;;
--skip-smoke)
RUN_SMOKE=false
shift
;;
--no-dashboard)
RUN_DASHBOARD=false
shift
;;
-h | --help)
usage
exit 0
;;
*)
echo "Unknown option: $1" >&2
usage
exit 1
;;
esac
done
if [[ "$PROFILE" == "full" && "$RUN_DASHBOARD" == false ]]; then
RUN_DASHBOARD=true
fi
echo "==> Profile: $PROFILE"
echo "==> Sync dependencies: $RUN_SYNC"
echo "==> Run lint: $RUN_LINT"
echo "==> Run smoke test: $RUN_SMOKE"
echo "==> Build dashboard: $RUN_DASHBOARD"
if [[ "$RUN_SYNC" == true ]]; then
echo "==> Syncing dependencies with uv"
uv sync --group dev
fi
echo "==> Preparing test directories"
mkdir -p data/plugins data/config data/temp data/skills
export TESTING="${TESTING:-true}"
export ZHIPU_API_KEY="${ZHIPU_API_KEY:-test-api-key}"
if [[ "$RUN_LINT" == true ]]; then
echo "==> Running Ruff format check"
uv run ruff format --check .
echo "==> Running Ruff lint check"
uv run ruff check .
fi
echo "==> Running pytest"
if [[ "$PROFILE" == "neo" ]]; then
NEO_TESTS=(
"tests/test_neo_skill_sync.py"
"tests/test_neo_skill_tools.py"
"tests/test_computer_skill_sync.py"
"tests/test_skill_manager_sandbox_cache.py"
"tests/test_dashboard.py::test_neo_skills_routes"
)
uv run pytest -q "${NEO_TESTS[@]}" ${PYTEST_ARGS:-}
else
uv run pytest --cov=. -v -o log_cli=true -o log_level=DEBUG ${PYTEST_ARGS:-}
fi
run_smoke_test() {
if ! command -v curl >/dev/null 2>&1; then
echo "curl is required for smoke test." >&2
return 1
fi
local smoke_port="6185"
local smoke_log
smoke_log="$(mktemp -t astrbot-smoke.XXXXXX.log)"
echo "==> Starting smoke test on http://localhost:${smoke_port}"
uv run main.py >"$smoke_log" 2>&1 &
local app_pid=$!
for _ in $(seq 1 60); do
if curl -sf "http://localhost:${smoke_port}" >/dev/null 2>&1; then
echo "==> Smoke test passed"
kill "$app_pid" 2>/dev/null || true
wait "$app_pid" 2>/dev/null || true
rm -f "$smoke_log"
return 0
fi
if ! kill -0 "$app_pid" 2>/dev/null; then
echo "AstrBot process exited before becoming healthy." >&2
tail -n 60 "$smoke_log" || true
rm -f "$smoke_log"
return 1
fi
sleep 1
done
echo "Smoke test failed: health endpoint did not become ready in time." >&2
tail -n 60 "$smoke_log" || true
kill "$app_pid" 2>/dev/null || true
wait "$app_pid" 2>/dev/null || true
rm -f "$smoke_log"
return 1
}
if [[ "$RUN_SMOKE" == true ]]; then
run_smoke_test
fi
if [[ "$RUN_DASHBOARD" == true ]]; then
if ! command -v pnpm >/dev/null 2>&1; then
echo "pnpm is required for dashboard build. Install it with: npm install -g pnpm" >&2
exit 1
fi
echo "==> Building dashboard"
pnpm --dir dashboard install --frozen-lockfile
pnpm --dir dashboard run build
fi
echo "==> PR checks completed successfully"
-298
View File
@@ -1,298 +0,0 @@
#!/usr/bin/env bash
# ──────────────────────────────────────────────────────────────
# start-with-neo.sh — 一键启动 Shipyard Neo Bay + AstrBot
#
# Usage:
# bash scripts/start-with-neo.sh # 默认 Bay :8114
# BAY_PORT=9000 bash scripts/start-with-neo.sh # 自定义端口
# ──────────────────────────────────────────────────────────────
set -euo pipefail
# ── 路径 ──────────────────────────────────────────────────────
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
ASTRBOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
# shipyard-neo mono-repo root is one level above AstrBot
NEO_ROOT="$(cd "$ASTRBOT_DIR/.." && pwd)"
BAY_DIR="$NEO_ROOT/pkgs/bay"
BAY_PORT="${BAY_PORT:-8114}"
BAY_HOST="0.0.0.0"
BAY_PID=""
BAY_API_KEY="" # Populated after Bay starts from credentials.json
# ── 颜色 ──────────────────────────────────────────────────────
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
NC='\033[0m' # No Color
log() { echo -e "${CYAN}[neo]${NC} $*"; }
ok() { echo -e "${GREEN}[neo]${NC} $*"; }
warn() { echo -e "${YELLOW}[neo]${NC} $*"; }
err() { echo -e "${RED}[neo]${NC} $*" >&2; }
# ── 清理函数 ──────────────────────────────────────────────────
cleanup() {
log "Shutting down..."
if [[ -n "$BAY_PID" ]] && kill -0 "$BAY_PID" 2>/dev/null; then
log "Stopping Bay (PID $BAY_PID)..."
kill "$BAY_PID" 2>/dev/null || true
wait "$BAY_PID" 2>/dev/null || true
fi
ok "All services stopped."
}
trap cleanup EXIT INT TERM
# ── 检查前置条件 ──────────────────────────────────────────────
check_prerequisites() {
log "Checking prerequisites..."
if [[ ! -d "$BAY_DIR" ]]; then
err "Bay directory not found: $BAY_DIR"
err "Expected shipyard-neo mono-repo at: $NEO_ROOT"
exit 1
fi
if ! command -v uv &>/dev/null; then
err "'uv' is not installed. Please install it first."
exit 1
fi
# Check Docker access (try without sudo first, then with sudo)
if docker info &>/dev/null 2>&1; then
ok "Docker is accessible."
elif sudo docker info &>/dev/null 2>&1; then
warn "Docker requires sudo. Bay may need socket permissions."
warn "If Bay fails to connect to Docker, run: sudo chmod 666 /var/run/docker.sock"
else
err "Docker is not accessible. Please install Docker or fix permissions."
exit 1
fi
# Check Bay venv
if [[ ! -d "$BAY_DIR/.venv" ]]; then
log "Bay venv not found. Running 'uv sync' in $BAY_DIR ..."
(cd "$BAY_DIR" && uv sync)
fi
ok "Prerequisites OK."
}
# ── 生成 Bay config.yaml(如不存在)────────────────────────────
ensure_bay_config() {
local config_file="$BAY_DIR/config.yaml"
if [[ -f "$config_file" ]]; then
ok "Bay config.yaml already exists."
return
fi
log "Generating Bay config.yaml for local development..."
cat > "$config_file" << 'BAYCONFIG'
# Bay Local Development Config (auto-generated by start-with-neo.sh)
# For full reference see config.yaml.example
server:
host: "0.0.0.0"
port: 8114
database:
url: "sqlite+aiosqlite:///./bay.db"
echo: false
driver:
type: docker
image_pull_policy: if_not_present
docker:
socket: "unix:///var/run/docker.sock"
connect_mode: host_port
host_address: "127.0.0.1"
publish_ports: true
host_port: null
network: null
cargo:
root_path: "/var/lib/bay/cargos"
default_size_limit_mb: 1024
mount_path: "/workspace"
# Security: auto-provision mode
# Bay generates sk-bay-* key on first boot → credentials.json
security:
allow_anonymous: false
profiles:
- id: python-default
description: "Standard Python sandbox"
image: "ghcr.io/astrbotdevs/shipyard-neo-ship:latest"
runtime_type: ship
runtime_port: 8123
resources:
cpus: 1.0
memory: "1g"
capabilities:
- filesystem
- shell
- python
idle_timeout: 1800
env: {}
gc:
enabled: true
run_on_startup: true
interval_seconds: 300
idle_session:
enabled: true
expired_sandbox:
enabled: true
orphan_cargo:
enabled: true
orphan_container:
enabled: false
BAYCONFIG
ok "Bay config.yaml created at $config_file"
}
# ── 拉取 Ship 镜像 ───────────────────────────────────────────
ensure_ship_image() {
local image="ghcr.io/astrbotdevs/shipyard-neo-ship:latest"
log "Checking Ship image: $image ..."
if docker image inspect "$image" &>/dev/null 2>&1 || \
sudo docker image inspect "$image" &>/dev/null 2>&1; then
ok "Ship image is available locally."
else
log "Pulling Ship image (this may take a while)..."
if docker pull "$image" 2>/dev/null || sudo docker pull "$image" 2>/dev/null; then
ok "Ship image pulled successfully."
else
warn "Failed to pull Ship image. Bay will try to pull it on first sandbox creation."
fi
fi
}
# ── 启动 Bay ──────────────────────────────────────────────────
start_bay() {
log "Starting Bay on :$BAY_PORT ..."
(cd "$BAY_DIR" && BAY_DATA_DIR="$BAY_DIR" uv run uvicorn app.main:app \
--host "$BAY_HOST" \
--port "$BAY_PORT" \
--reload \
2>&1 | sed "s/^/ ${CYAN}[bay]${NC} /") &
BAY_PID=$!
log "Bay started (PID $BAY_PID), waiting for health check..."
# Wait for Bay to become healthy
local max_wait=30
local waited=0
while [[ $waited -lt $max_wait ]]; do
if curl -sf "http://127.0.0.1:$BAY_PORT/health" &>/dev/null; then
ok "Bay is healthy at http://127.0.0.1:$BAY_PORT"
return
fi
# Check if process is still alive
if ! kill -0 "$BAY_PID" 2>/dev/null; then
err "Bay process died unexpectedly. Check the output above."
exit 1
fi
sleep 1
waited=$((waited + 1))
done
err "Bay did not become healthy within ${max_wait}s."
err "It may still be starting — check http://127.0.0.1:$BAY_PORT/health"
}
# ── 读取 Bay 自动生成的凭证 ───────────────────────────────────
read_bay_credentials() {
local cred_file="$BAY_DIR/credentials.json"
# Wait briefly for credentials.json to appear (Bay writes it during startup)
local max_wait=5
local waited=0
while [[ $waited -lt $max_wait ]]; do
if [[ -f "$cred_file" ]]; then
break
fi
sleep 1
waited=$((waited + 1))
done
if [[ -f "$cred_file" ]]; then
# Extract api_key using python (always available) — no jq dependency
BAY_API_KEY=$(python3 -c "
import json, sys
try:
d = json.load(open('$cred_file'))
print(d.get('api_key', ''))
except Exception:
print('')
" 2>/dev/null || echo "")
if [[ -n "$BAY_API_KEY" ]]; then
ok "Auto-provisioned API key loaded from credentials.json"
else
warn "credentials.json found but api_key is empty"
fi
else
warn "credentials.json not found — Bay may be using an existing key or anonymous mode"
warn "Check Bay logs above for the API key, or look at: $cred_file"
fi
}
# ── 打印 AstrBot 配置提示 ────────────────────────────────────
print_astrbot_config_hint() {
echo ""
echo -e "${GREEN}════════════════════════════════════════════════════════════${NC}"
echo -e "${GREEN} Shipyard Neo Bay is running at http://127.0.0.1:$BAY_PORT ${NC}"
echo -e "${GREEN}════════════════════════════════════════════════════════════${NC}"
echo ""
if [[ -n "$BAY_API_KEY" ]]; then
echo -e " ${CYAN}Bay API Key (auto-generated):${NC}"
echo -e " ${YELLOW}$BAY_API_KEY${NC}"
echo ""
fi
echo -e " ${CYAN}AstrBot Dashboard 配置指引:${NC}"
echo -e " 1. AI 配置 → Agent Computer Use"
echo -e " • Computer Use Runtime → ${YELLOW}沙箱${NC}"
echo -e " • 沙箱环境驱动器 → ${YELLOW}Shipyard Neo${NC}"
echo -e " • Shipyard Neo API Endpoint → ${YELLOW}http://127.0.0.1:$BAY_PORT${NC}"
if [[ -n "$BAY_API_KEY" ]]; then
echo -e " • Shipyard Neo Access Token → ${YELLOW}$BAY_API_KEY${NC}"
else
echo -e " • Shipyard Neo Access Token → ${YELLOW}(查看 Bay 日志获取 key${NC}"
fi
echo -e " • Shipyard Neo Profile → ${YELLOW}python-default${NC}"
echo ""
}
# ── 启动 AstrBot ──────────────────────────────────────────────
start_astrbot() {
log "Starting AstrBot..."
cd "$ASTRBOT_DIR"
uv run main.py
}
# ── 主流程 ────────────────────────────────────────────────────
main() {
echo ""
echo -e "${CYAN}╔══════════════════════════════════════════╗${NC}"
echo -e "${CYAN}║ Shipyard Neo + AstrBot Quick Start ║${NC}"
echo -e "${CYAN}╚══════════════════════════════════════════╝${NC}"
echo ""
check_prerequisites
ensure_bay_config
ensure_ship_image
start_bay
read_bay_credentials
print_astrbot_config_hint
start_astrbot
}
main "$@"
-325
View File
@@ -1,325 +0,0 @@
"""Tests for _discover_bay_credentials() auto-discovery and _log_computer_config_changes()."""
from __future__ import annotations
import json
import logging
from pathlib import Path
from unittest.mock import patch
import pytest
from astrbot.core.computer.computer_client import _discover_bay_credentials
from astrbot.dashboard.routes.config import _log_computer_config_changes
# ═══════════════════════════════════════════════════════════════
# _discover_bay_credentials
# ═══════════════════════════════════════════════════════════════
class TestDiscoverBayCredentials:
"""Test Bay API key auto-discovery from credentials.json."""
def _write_creds(
self,
path: Path,
api_key: str = "sk-bay-abc123",
endpoint: str = "http://127.0.0.1:8114",
) -> None:
"""Helper: write a credentials.json file."""
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
json.dumps(
{
"api_key": api_key,
"endpoint": endpoint,
"generated_at": "2026-02-17T00:00:00+00:00",
}
)
)
def test_discover_from_bay_data_dir_env(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""BAY_DATA_DIR env var takes highest priority."""
data_dir = tmp_path / "bay_data"
cred_file = data_dir / "credentials.json"
self._write_creds(cred_file, api_key="sk-bay-from-env-dir")
monkeypatch.setenv("BAY_DATA_DIR", str(data_dir))
result = _discover_bay_credentials("http://127.0.0.1:8114")
assert result == "sk-bay-from-env-dir"
def test_discover_from_cwd(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Falls back to current working directory."""
cred_file = tmp_path / "credentials.json"
self._write_creds(cred_file, api_key="sk-bay-from-cwd")
monkeypatch.chdir(tmp_path)
monkeypatch.delenv("BAY_DATA_DIR", raising=False)
result = _discover_bay_credentials("http://127.0.0.1:8114")
assert result == "sk-bay-from-cwd"
def test_returns_empty_when_no_credentials_found(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Returns empty string when no credentials.json exists anywhere."""
monkeypatch.chdir(tmp_path)
monkeypatch.delenv("BAY_DATA_DIR", raising=False)
result = _discover_bay_credentials("http://127.0.0.1:8114")
assert result == ""
def test_skips_empty_api_key(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Skips credentials.json when api_key is empty."""
cred_file = tmp_path / "credentials.json"
self._write_creds(cred_file, api_key="")
monkeypatch.chdir(tmp_path)
monkeypatch.delenv("BAY_DATA_DIR", raising=False)
result = _discover_bay_credentials("http://127.0.0.1:8114")
assert result == ""
def test_skips_malformed_json(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Handles malformed JSON gracefully."""
cred_file = tmp_path / "credentials.json"
cred_file.parent.mkdir(parents=True, exist_ok=True)
cred_file.write_text("not valid json {{{")
monkeypatch.chdir(tmp_path)
monkeypatch.delenv("BAY_DATA_DIR", raising=False)
result = _discover_bay_credentials("http://127.0.0.1:8114")
assert result == ""
@patch("astrbot.core.computer.computer_client.logger")
def test_endpoint_mismatch_still_returns_key(
self, mock_logger, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Returns key even if endpoint doesn't match, but logs a warning."""
data_dir = tmp_path / "bay_data"
cred_file = data_dir / "credentials.json"
self._write_creds(
cred_file, api_key="sk-bay-mismatch", endpoint="http://other-host:9000"
)
monkeypatch.setenv("BAY_DATA_DIR", str(data_dir))
result = _discover_bay_credentials("http://127.0.0.1:8114")
assert result == "sk-bay-mismatch"
mock_logger.warning.assert_called_once()
warning_msg = mock_logger.warning.call_args[0][0]
assert "endpoint mismatch" in warning_msg
def test_endpoint_match_no_warning(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""No warning when endpoints match."""
data_dir = tmp_path / "bay_data"
cred_file = data_dir / "credentials.json"
self._write_creds(
cred_file, api_key="sk-bay-match", endpoint="http://127.0.0.1:8114"
)
monkeypatch.setenv("BAY_DATA_DIR", str(data_dir))
with patch("astrbot.core.computer.computer_client.logger") as mock_logger:
result = _discover_bay_credentials("http://127.0.0.1:8114")
assert result == "sk-bay-match"
mock_logger.warning.assert_not_called()
def test_bay_data_dir_priority_over_cwd(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""BAY_DATA_DIR takes priority over cwd."""
env_dir = tmp_path / "env_dir"
cwd_dir = tmp_path / "cwd_dir"
self._write_creds(env_dir / "credentials.json", api_key="sk-bay-env-wins")
self._write_creds(cwd_dir / "credentials.json", api_key="sk-bay-cwd-loses")
monkeypatch.setenv("BAY_DATA_DIR", str(env_dir))
monkeypatch.chdir(cwd_dir)
result = _discover_bay_credentials("http://127.0.0.1:8114")
assert result == "sk-bay-env-wins"
def test_trailing_slash_normalization(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Trailing slashes on endpoints are normalized before comparison."""
data_dir = tmp_path / "bay_data"
cred_file = data_dir / "credentials.json"
self._write_creds(
cred_file, api_key="sk-bay-slash", endpoint="http://127.0.0.1:8114/"
)
monkeypatch.setenv("BAY_DATA_DIR", str(data_dir))
with patch("astrbot.core.computer.computer_client.logger") as mock_logger:
result = _discover_bay_credentials("http://127.0.0.1:8114")
assert result == "sk-bay-slash"
mock_logger.warning.assert_not_called()
# ═══════════════════════════════════════════════════════════════
# _log_computer_config_changes
# ═══════════════════════════════════════════════════════════════
class TestLogComputerConfigChanges:
"""Test config change detection and logging."""
@patch("astrbot.dashboard.routes.config.logger")
def test_logs_runtime_change(self, mock_logger) -> None:
"""Detects computer_use_runtime change."""
old = {"provider_settings": {"computer_use_runtime": "none"}}
new = {"provider_settings": {"computer_use_runtime": "sandbox"}}
_log_computer_config_changes(old, new)
mock_logger.info.assert_called()
call_args = [str(c) for c in mock_logger.info.call_args_list]
assert any("computer_use_runtime" in c and "none" in c and "sandbox" in c for c in call_args)
@patch("astrbot.dashboard.routes.config.logger")
def test_no_log_when_runtime_unchanged(self, mock_logger) -> None:
"""No log when runtime stays the same."""
old = {"provider_settings": {"computer_use_runtime": "sandbox"}}
new = {"provider_settings": {"computer_use_runtime": "sandbox"}}
_log_computer_config_changes(old, new)
mock_logger.info.assert_not_called()
@patch("astrbot.dashboard.routes.config.logger")
def test_logs_sandbox_key_change(self, mock_logger) -> None:
"""Detects sandbox sub-key change."""
old = {"provider_settings": {"sandbox": {"booter": "shipyard"}}}
new = {"provider_settings": {"sandbox": {"booter": "shipyard_neo"}}}
_log_computer_config_changes(old, new)
mock_logger.info.assert_called()
# logger.info("[Computer] Config changed: sandbox.%s %s -> %s", key, old, new)
found = False
for call in mock_logger.info.call_args_list:
args = call[0] # positional args: (fmt, key, old_val, new_val)
if len(args) >= 4 and args[1] == "booter":
assert args[2] == "shipyard"
assert args[3] == "shipyard_neo"
found = True
break
assert found, f"Expected booter change in log calls: {mock_logger.info.call_args_list}"
@patch("astrbot.dashboard.routes.config.logger")
def test_masks_token_values(self, mock_logger) -> None:
"""Token/secret values are masked in log output."""
old = {"provider_settings": {"sandbox": {"shipyard_neo_access_token": ""}}}
new = {
"provider_settings": {
"sandbox": {"shipyard_neo_access_token": "sk-bay-secret123"}
}
}
_log_computer_config_changes(old, new)
mock_logger.info.assert_called()
call_args_str = str(mock_logger.info.call_args_list)
assert "***" in call_args_str
assert "sk-bay-secret123" not in call_args_str
@patch("astrbot.dashboard.routes.config.logger")
def test_masks_empty_token_as_empty_label(self, mock_logger) -> None:
"""Empty token values show as '(empty)' not '***'."""
old = {
"provider_settings": {
"sandbox": {"shipyard_neo_access_token": "old-key"}
}
}
new = {"provider_settings": {"sandbox": {"shipyard_neo_access_token": ""}}}
_log_computer_config_changes(old, new)
mock_logger.info.assert_called()
call_args_str = str(mock_logger.info.call_args_list)
assert "(empty)" in call_args_str
@patch("astrbot.dashboard.routes.config.logger")
def test_no_log_when_nothing_changed(self, mock_logger) -> None:
"""No logs at all when config is identical."""
cfg = {
"provider_settings": {
"computer_use_runtime": "sandbox",
"sandbox": {
"booter": "shipyard_neo",
"shipyard_neo_endpoint": "http://127.0.0.1:8114",
},
}
}
_log_computer_config_changes(cfg, cfg)
mock_logger.info.assert_not_called()
@patch("astrbot.dashboard.routes.config.logger")
def test_handles_missing_provider_settings(self, mock_logger) -> None:
"""Gracefully handles configs without provider_settings."""
_log_computer_config_changes(
{}, {"provider_settings": {"computer_use_runtime": "sandbox"}}
)
mock_logger.info.assert_called()
call_args_str = str(mock_logger.info.call_args_list)
assert "computer_use_runtime" in call_args_str
@patch("astrbot.dashboard.routes.config.logger")
def test_detects_new_sandbox_key(self, mock_logger) -> None:
"""Detects a newly added sandbox key."""
old = {"provider_settings": {"sandbox": {}}}
new = {
"provider_settings": {
"sandbox": {"shipyard_neo_endpoint": "http://127.0.0.1:8114"}
}
}
_log_computer_config_changes(old, new)
mock_logger.info.assert_called()
call_args_str = str(mock_logger.info.call_args_list)
assert "shipyard_neo_endpoint" in call_args_str
@patch("astrbot.dashboard.routes.config.logger")
def test_detects_removed_sandbox_key(self, mock_logger) -> None:
"""Detects a removed sandbox key."""
old = {
"provider_settings": {
"sandbox": {"shipyard_neo_endpoint": "http://127.0.0.1:8114"}
}
}
new = {"provider_settings": {"sandbox": {}}}
_log_computer_config_changes(old, new)
mock_logger.info.assert_called()
call_args_str = str(mock_logger.info.call_args_list)
assert "shipyard_neo_endpoint" in call_args_str
@patch("astrbot.dashboard.routes.config.logger")
def test_secret_key_masked(self, mock_logger) -> None:
"""Any key containing 'secret' is also masked."""
old = {"provider_settings": {"sandbox": {"my_secret_key": ""}}}
new = {
"provider_settings": {"sandbox": {"my_secret_key": "very-secret-value"}}
}
_log_computer_config_changes(old, new)
mock_logger.info.assert_called()
call_args_str = str(mock_logger.info.call_args_list)
assert "***" in call_args_str
assert "very-secret-value" not in call_args_str
-123
View File
@@ -1,123 +0,0 @@
from __future__ import annotations
import asyncio
from pathlib import Path
from astrbot.core.computer import computer_client
class _FakeShell:
def __init__(self, sync_payload_json: str):
self.sync_payload_json = sync_payload_json
self.commands: list[str] = []
async def exec(self, command: str, **kwargs):
_ = kwargs
self.commands.append(command)
if "PYBIN" in command and "managed_skills" in command:
return {
"success": True,
"stdout": self.sync_payload_json,
"stderr": "",
"exit_code": 0,
}
return {"success": True, "stdout": "", "stderr": "", "exit_code": 0}
class _FakeBooter:
def __init__(self, sync_payload_json: str):
self.shell = _FakeShell(sync_payload_json)
self.uploads: list[tuple[str, str]] = []
async def upload_file(self, path: str, file_name: str) -> dict:
self.uploads.append((path, file_name))
return {"success": True}
def test_sync_skills_keeps_builtin_skills_when_local_is_empty(monkeypatch, tmp_path: Path):
skills_root = tmp_path / "skills"
temp_root = tmp_path / "temp"
skills_root.mkdir(parents=True, exist_ok=True)
temp_root.mkdir(parents=True, exist_ok=True)
captured = {"skills": None}
def _fake_set_cache(self, skills):
captured["skills"] = skills
monkeypatch.setattr(
"astrbot.core.computer.computer_client.get_astrbot_skills_path",
lambda: str(skills_root),
)
monkeypatch.setattr(
"astrbot.core.computer.computer_client.get_astrbot_temp_path",
lambda: str(temp_root),
)
monkeypatch.setattr(
"astrbot.core.computer.computer_client.SkillManager.set_sandbox_skills_cache",
_fake_set_cache,
)
booter = _FakeBooter(
'{"skills":[{"name":"python-sandbox","description":"ship","path":"skills/python-sandbox/SKILL.md"}]}'
)
asyncio.run(computer_client._sync_skills_to_sandbox(booter))
assert booter.uploads == []
assert any(cmd == "rm -f skills/skills.zip" for cmd in booter.shell.commands)
assert captured["skills"] == [
{
"name": "python-sandbox",
"description": "ship",
"path": "skills/python-sandbox/SKILL.md",
}
]
def test_sync_skills_uses_managed_strategy_instead_of_wiping_all(
monkeypatch,
tmp_path: Path,
):
skills_root = tmp_path / "skills"
temp_root = tmp_path / "temp"
skill_dir = skills_root / "custom-agent-skill"
skill_dir.mkdir(parents=True, exist_ok=True)
skill_dir.joinpath("SKILL.md").write_text("# demo", encoding="utf-8")
temp_root.mkdir(parents=True, exist_ok=True)
captured = {"skills": None}
def _fake_set_cache(self, skills):
captured["skills"] = skills
monkeypatch.setattr(
"astrbot.core.computer.computer_client.get_astrbot_skills_path",
lambda: str(skills_root),
)
monkeypatch.setattr(
"astrbot.core.computer.computer_client.get_astrbot_temp_path",
lambda: str(temp_root),
)
monkeypatch.setattr(
"astrbot.core.computer.computer_client.SkillManager.set_sandbox_skills_cache",
_fake_set_cache,
)
booter = _FakeBooter(
'{"skills":[{"name":"custom-agent-skill","description":"","path":"skills/custom-agent-skill/SKILL.md"}]}'
)
asyncio.run(computer_client._sync_skills_to_sandbox(booter))
assert len(booter.uploads) == 1
assert booter.uploads[0][1] == "skills/skills.zip"
assert not any(
"find skills -mindepth 1 -delete" in cmd for cmd in booter.shell.commands
)
assert captured["skills"] == [
{
"name": "custom-agent-skill",
"description": "",
"path": "skills/custom-agent-skill/SKILL.md",
}
]
+1 -183
View File
@@ -1,7 +1,6 @@
import asyncio
import os
import sys
from types import SimpleNamespace
from pathlib import Path
import pytest
import pytest_asyncio
@@ -312,184 +311,3 @@ async def test_do_update(
data = await response.get_json()
assert data["status"] == "ok"
assert os.path.exists(release_path)
class _FakeNeoSkills:
async def list_candidates(self, **kwargs):
_ = kwargs
return [
{
"id": "cand-1",
"skill_key": "neo.demo",
"status": "evaluated_pass",
"payload_ref": "pref-1",
}
]
async def list_releases(self, **kwargs):
_ = kwargs
return [
{
"id": "rel-1",
"skill_key": "neo.demo",
"candidate_id": "cand-1",
"stage": "stable",
"active": True,
}
]
async def get_payload(self, payload_ref: str):
return {
"payload_ref": payload_ref,
"payload": {"skill_markdown": "# Demo"},
}
async def evaluate_candidate(self, candidate_id: str, **kwargs):
return {"candidate_id": candidate_id, **kwargs}
async def promote_candidate(self, candidate_id: str, stage: str = "canary"):
return {
"id": "rel-2",
"skill_key": "neo.demo",
"candidate_id": candidate_id,
"stage": stage,
}
async def rollback_release(self, release_id: str):
return {"id": "rb-1", "rolled_back_release_id": release_id}
class _FakeNeoBayClient:
def __init__(self, endpoint_url: str, access_token: str):
self.endpoint_url = endpoint_url
self.access_token = access_token
self.skills = _FakeNeoSkills()
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
_ = exc_type, exc, tb
return False
@pytest.mark.asyncio
async def test_neo_skills_routes(
app: Quart,
authenticated_header: dict,
core_lifecycle_td: AstrBotCoreLifecycle,
monkeypatch,
):
provider_settings = core_lifecycle_td.astrbot_config.setdefault(
"provider_settings", {}
)
sandbox = provider_settings.setdefault("sandbox", {})
sandbox["shipyard_neo_endpoint"] = "http://neo.test"
sandbox["shipyard_neo_access_token"] = "neo-token"
fake_shipyard_neo_module = SimpleNamespace(BayClient=_FakeNeoBayClient)
monkeypatch.setitem(sys.modules, "shipyard_neo", fake_shipyard_neo_module)
async def _fake_sync_release(self, client, **kwargs):
_ = self, client, kwargs
return SimpleNamespace(
skill_key="neo.demo",
local_skill_name="neo_demo",
release_id="rel-2",
candidate_id="cand-1",
payload_ref="pref-1",
map_path="data/skills/neo_skill_map.json",
synced_at="2026-01-01T00:00:00Z",
)
async def _fake_sync_skills_to_active_sandboxes():
return
monkeypatch.setattr(
"astrbot.dashboard.routes.skills.NeoSkillSyncManager.sync_release",
_fake_sync_release,
)
monkeypatch.setattr(
"astrbot.dashboard.routes.skills.sync_skills_to_active_sandboxes",
_fake_sync_skills_to_active_sandboxes,
)
test_client = app.test_client()
response = await test_client.get(
"/api/skills/neo/candidates", headers=authenticated_header
)
assert response.status_code == 200
data = await response.get_json()
assert data["status"] == "ok"
assert isinstance(data["data"], list)
assert data["data"][0]["id"] == "cand-1"
response = await test_client.get(
"/api/skills/neo/releases", headers=authenticated_header
)
assert response.status_code == 200
data = await response.get_json()
assert data["status"] == "ok"
assert isinstance(data["data"], list)
assert data["data"][0]["id"] == "rel-1"
response = await test_client.get(
"/api/skills/neo/payload?payload_ref=pref-1", headers=authenticated_header
)
assert response.status_code == 200
data = await response.get_json()
assert data["status"] == "ok"
assert data["data"]["payload_ref"] == "pref-1"
response = await test_client.post(
"/api/skills/neo/evaluate",
json={"candidate_id": "cand-1", "passed": True, "score": 0.95},
headers=authenticated_header,
)
assert response.status_code == 200
data = await response.get_json()
assert data["status"] == "ok"
assert data["data"]["candidate_id"] == "cand-1"
assert data["data"]["passed"] is True
response = await test_client.post(
"/api/skills/neo/evaluate",
json={"candidate_id": "cand-1", "passed": "false", "score": 0.0},
headers=authenticated_header,
)
assert response.status_code == 200
data = await response.get_json()
assert data["status"] == "ok"
assert data["data"]["passed"] is False
response = await test_client.post(
"/api/skills/neo/promote",
json={"candidate_id": "cand-1", "stage": "stable"},
headers=authenticated_header,
)
assert response.status_code == 200
data = await response.get_json()
assert data["status"] == "ok"
assert data["data"]["release"]["id"] == "rel-2"
assert data["data"]["sync"]["local_skill_name"] == "neo_demo"
response = await test_client.post(
"/api/skills/neo/rollback",
json={"release_id": "rel-2"},
headers=authenticated_header,
)
assert response.status_code == 200
data = await response.get_json()
assert data["status"] == "ok"
assert data["data"]["rolled_back_release_id"] == "rel-2"
response = await test_client.post(
"/api/skills/neo/sync",
json={"release_id": "rel-2"},
headers=authenticated_header,
)
assert response.status_code == 200
data = await response.get_json()
assert data["status"] == "ok"
assert data["data"]["skill_key"] == "neo.demo"
+6 -21
View File
@@ -26,21 +26,6 @@ class _version_info:
return (self.major, self.minor) >= other[:2]
return (self.major, self.minor) >= (other.major, other.minor)
def __le__(self, other):
if isinstance(other, tuple):
return (self.major, self.minor) <= other[:2]
return (self.major, self.minor) <= (other.major, other.minor)
def __gt__(self, other):
if isinstance(other, tuple):
return (self.major, self.minor) > other[:2]
return (self.major, self.minor) > (other.major, other.minor)
def __lt__(self, other):
if isinstance(other, tuple):
return (self.major, self.minor) < other[:2]
return (self.major, self.minor) < (other.major, other.minor)
def test_check_env(monkeypatch):
version_info_correct = _version_info(3, 10)
@@ -48,12 +33,12 @@ def test_check_env(monkeypatch):
monkeypatch.setattr(sys, "version_info", version_info_correct)
with mock.patch("os.makedirs") as mock_makedirs:
check_env()
# check_env uses get_astrbot_*_path() which returns absolute paths,
# so just verify makedirs was called the expected number of times
assert mock_makedirs.call_count >= 4
# Verify all calls used exist_ok=True
for call_args in mock_makedirs.call_args_list:
assert call_args[1].get("exist_ok") is True
# Check that makedirs was called with paths containing expected dirs
called_paths = [call[0][0] for call in mock_makedirs.call_args_list]
# Use os.path.join for cross-platform path matching
assert any(p.rstrip(os.sep).endswith(os.path.join("data", "config")) for p in called_paths)
assert any(p.rstrip(os.sep).endswith(os.path.join("data", "plugins")) for p in called_paths)
assert any(p.rstrip(os.sep).endswith(os.path.join("data", "temp")) for p in called_paths)
monkeypatch.setattr(sys, "version_info", version_info_wrong)
with pytest.raises(SystemExit):
-130
View File
@@ -1,130 +0,0 @@
from __future__ import annotations
import asyncio
from pathlib import Path
import pytest
from astrbot.core.skills.neo_skill_sync import NeoSkillSyncManager
class _FakeSkills:
async def list_releases(self, **kwargs):
_ = kwargs
return {
"items": [
{
"id": "sr-1",
"skill_key": "etl/loader@v1",
"candidate_id": "sc-1",
"stage": "stable",
}
],
"total": 1,
}
async def get_candidate(self, candidate_id: str):
assert candidate_id == "sc-1"
return {
"id": "sc-1",
"payload_ref": "blob:blob-1",
}
async def get_payload(self, payload_ref: str):
assert payload_ref == "blob:blob-1"
return {
"payload_ref": payload_ref,
"kind": "astrbot_skill_v1",
"payload": {
"skill_markdown": "---\ndescription: test\n---\n# title\ncontent",
},
}
class _FakeClient:
def __init__(self):
self.skills = _FakeSkills()
def test_sync_release_writes_skill_and_map(monkeypatch, tmp_path: Path):
calls = {"active": [], "sandbox_sync": 0}
def _fake_set_skill_active(self, name, active):
calls["active"].append((name, active))
async def _fake_sync_sandboxes():
calls["sandbox_sync"] += 1
monkeypatch.setattr(
"astrbot.core.skills.neo_skill_sync.SkillManager.set_skill_active",
_fake_set_skill_active,
)
monkeypatch.setattr(
"astrbot.core.skills.neo_skill_sync.sync_skills_to_active_sandboxes",
_fake_sync_sandboxes,
)
skills_root = tmp_path / "skills"
map_path = skills_root / "neo_skill_map.json"
mgr = NeoSkillSyncManager(skills_root=str(skills_root), map_path=str(map_path))
result = asyncio.run(
mgr.sync_release(_FakeClient(), release_id="sr-1", require_stable=True)
)
assert result.skill_key == "etl/loader@v1"
assert result.release_id == "sr-1"
assert result.local_skill_name.startswith("neo_")
assert calls["active"] == [(result.local_skill_name, True)]
assert calls["sandbox_sync"] == 1
skill_md = skills_root / result.local_skill_name / "SKILL.md"
assert skill_md.exists()
assert "description: test" in skill_md.read_text(encoding="utf-8")
assert map_path.exists()
map_text = map_path.read_text(encoding="utf-8")
assert "etl/loader@v1" in map_text
assert result.local_skill_name in map_text
def test_sync_release_rejects_non_stable(monkeypatch, tmp_path: Path):
class _CanarySkills(_FakeSkills):
async def list_releases(self, **kwargs):
_ = kwargs
return {
"items": [
{
"id": "sr-1",
"skill_key": "etl",
"candidate_id": "sc-1",
"stage": "canary",
}
],
"total": 1,
}
class _CanaryClient:
def __init__(self):
self.skills = _CanarySkills()
async def _fake_sync_sandboxes():
return
monkeypatch.setattr(
"astrbot.core.skills.neo_skill_sync.sync_skills_to_active_sandboxes",
_fake_sync_sandboxes,
)
monkeypatch.setattr(
"astrbot.core.skills.neo_skill_sync.SkillManager.set_skill_active",
lambda self, name, active: None,
)
mgr = NeoSkillSyncManager(
skills_root=str(tmp_path / "skills"),
map_path=str(tmp_path / "skills" / "neo_skill_map.json"),
)
with pytest.raises(ValueError, match="Only stable releases"):
asyncio.run(
mgr.sync_release(_CanaryClient(), release_id="sr-1", require_stable=True)
)
-73
View File
@@ -1,73 +0,0 @@
from __future__ import annotations
import asyncio
from types import SimpleNamespace
from astrbot.core.agent.run_context import ContextWrapper
from astrbot.core.computer.tools.neo_skills import PromoteSkillCandidateTool
class _FakeSkills:
def __init__(self):
self.rollback_called_with = None
async def promote_candidate(self, candidate_id: str, stage: str = "canary"):
assert candidate_id == "cand-1"
assert stage == "stable"
return {
"id": "sr-1",
"skill_key": "k1",
"candidate_id": candidate_id,
"stage": stage,
}
async def rollback_release(self, release_id: str):
self.rollback_called_with = release_id
return {"id": "rb-1", "rollback_of": release_id}
class _FakeClient:
def __init__(self):
self.skills = _FakeSkills()
class _FakeBooter:
def __init__(self):
self.bay_client = _FakeClient()
self.sandbox = object()
def test_promote_stable_sync_failure_auto_rolls_back(monkeypatch):
async def _fake_get_booter(_ctx, _session_id):
return _FakeBooter()
async def _fake_sync_release(self, client, **kwargs):
_ = self, client, kwargs
raise ValueError("sync failed")
monkeypatch.setattr(
"astrbot.core.computer.tools.neo_skills.get_booter",
_fake_get_booter,
)
monkeypatch.setattr(
"astrbot.core.computer.tools.neo_skills.NeoSkillSyncManager.sync_release",
_fake_sync_release,
)
event = SimpleNamespace(role="admin", unified_msg_origin="session-1")
astr_ctx = SimpleNamespace(context=SimpleNamespace(), event=event)
run_ctx = ContextWrapper(context=astr_ctx)
tool = PromoteSkillCandidateTool()
result = asyncio.run(
tool.call(
run_ctx,
candidate_id="cand-1",
stage="stable",
sync_to_local=True,
)
)
assert isinstance(result, str)
assert "auto rollback succeeded" in result
assert "sync failed" in result
-287
View File
@@ -1,287 +0,0 @@
"""Tests for profile-aware sandbox selection and conditional tool registration."""
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import patch
import pytest
# ═══════════════════════════════════════════════════════════════
# ShipyardNeoBooter.capabilities
# ═══════════════════════════════════════════════════════════════
class TestShipyardNeoBooterCapabilities:
"""Test capabilities property on ShipyardNeoBooter."""
def _make_booter(self, sandbox_caps: list[str] | None = None):
from astrbot.core.computer.booters.shipyard_neo import ShipyardNeoBooter
booter = ShipyardNeoBooter(
endpoint_url="http://localhost:8114",
access_token="sk-bay-test",
)
if sandbox_caps is not None:
booter._sandbox = SimpleNamespace(capabilities=sandbox_caps)
return booter
def test_none_before_boot(self):
booter = self._make_booter()
assert booter.capabilities is None
def test_returns_tuple_after_boot(self):
booter = self._make_booter(["python", "shell", "filesystem"])
assert booter.capabilities == ("python", "shell", "filesystem")
assert isinstance(booter.capabilities, tuple)
def test_includes_browser_when_present(self):
booter = self._make_booter(["python", "shell", "filesystem", "browser"])
assert "browser" in booter.capabilities
def test_no_browser_when_absent(self):
booter = self._make_booter(["python", "shell", "filesystem"])
assert "browser" not in booter.capabilities
def test_returns_immutable(self):
"""Verify capabilities returns an immutable tuple."""
booter = self._make_booter(["python"])
caps = booter.capabilities
assert isinstance(caps, tuple)
with pytest.raises(AttributeError):
caps.append("mutated") # type: ignore[attr-defined]
# ═══════════════════════════════════════════════════════════════
# _apply_sandbox_tools — conditional browser tool registration
# ═══════════════════════════════════════════════════════════════
def _make_config(booter_type: str = "shipyard_neo"):
return SimpleNamespace(
sandbox_cfg={"booter": booter_type},
)
def _make_req():
return SimpleNamespace(func_tool=None, system_prompt="")
def _import_apply_sandbox_tools():
"""Import _apply_sandbox_tools, skipping if circular-import fails."""
try:
from astrbot.core.astr_main_agent import _apply_sandbox_tools
return _apply_sandbox_tools
except ImportError:
pytest.skip("Cannot import _apply_sandbox_tools (circular import in test env)")
class TestApplySandboxToolsConditional:
"""Verify browser tools are conditionally registered."""
def _tool_names(self, req) -> set[str]:
"""Extract tool names from a request's func_tool."""
if req.func_tool is None:
return set()
return {t.name for t in req.func_tool.tools}
def test_no_session_registers_all(self):
"""First request (no booted session) → all tools including browser."""
fn = _import_apply_sandbox_tools()
config = _make_config("shipyard_neo")
req = _make_req()
with patch(
"astrbot.core.computer.computer_client.session_booter", {}
):
fn(config, req, "session-1")
names = self._tool_names(req)
assert "astrbot_execute_browser" in names
assert "astrbot_execute_browser_batch" in names
assert "astrbot_run_browser_skill" in names
def test_with_browser_capability(self):
"""Booted session with browser capability → browser tools registered."""
fn = _import_apply_sandbox_tools()
config = _make_config("shipyard_neo")
req = _make_req()
fake_booter = SimpleNamespace(
capabilities=["python", "shell", "filesystem", "browser"]
)
with patch(
"astrbot.core.computer.computer_client.session_booter",
{"session-1": fake_booter},
):
fn(config, req, "session-1")
names = self._tool_names(req)
assert "astrbot_execute_browser" in names
def test_without_browser_capability(self):
"""Booted session WITHOUT browser capability → browser tools NOT registered."""
fn = _import_apply_sandbox_tools()
config = _make_config("shipyard_neo")
req = _make_req()
fake_booter = SimpleNamespace(
capabilities=["python", "shell", "filesystem"]
)
with patch(
"astrbot.core.computer.computer_client.session_booter",
{"session-1": fake_booter},
):
fn(config, req, "session-1")
names = self._tool_names(req)
assert "astrbot_execute_browser" not in names
assert "astrbot_execute_browser_batch" not in names
assert "astrbot_run_browser_skill" not in names
# Skill tools should still be registered
assert "astrbot_get_execution_history" in names
def test_skill_tools_always_registered(self):
"""Skill lifecycle tools are registered regardless of capabilities."""
fn = _import_apply_sandbox_tools()
config = _make_config("shipyard_neo")
req = _make_req()
fake_booter = SimpleNamespace(capabilities=["python"])
with patch(
"astrbot.core.computer.computer_client.session_booter",
{"session-1": fake_booter},
):
fn(config, req, "session-1")
names = self._tool_names(req)
assert "astrbot_create_skill_candidate" in names
assert "astrbot_promote_skill_candidate" in names
# ═══════════════════════════════════════════════════════════════
# _resolve_profile
# ═══════════════════════════════════════════════════════════════
class TestResolveProfile:
"""Test smart profile selection logic."""
def _make_booter(self, profile: str = "python-default"):
from astrbot.core.computer.booters.shipyard_neo import ShipyardNeoBooter
return ShipyardNeoBooter(
endpoint_url="http://localhost:8114",
access_token="sk-bay-test",
profile=profile,
)
@pytest.mark.asyncio
async def test_user_specified_profile_honoured(self):
"""User explicitly sets a non-default profile → use it directly."""
booter = self._make_booter(profile="browser-python")
client = SimpleNamespace() # list_profiles should NOT be called
result = await booter._resolve_profile(client)
assert result == "browser-python"
@pytest.mark.asyncio
async def test_selects_browser_profile(self):
"""When multiple profiles available, prefer one with browser."""
async def _mock_list_profiles():
return SimpleNamespace(
items=[
SimpleNamespace(
id="python-default",
capabilities=["python", "shell", "filesystem"],
),
SimpleNamespace(
id="browser-python",
capabilities=["python", "shell", "filesystem", "browser"],
),
]
)
booter = self._make_booter()
client = SimpleNamespace(list_profiles=_mock_list_profiles)
result = await booter._resolve_profile(client)
assert result == "browser-python"
@pytest.mark.asyncio
async def test_falls_back_to_default_on_api_error(self):
"""API error → graceful fallback to python-default."""
async def _failing_list_profiles():
raise ConnectionError("Bay unreachable")
booter = self._make_booter()
client = SimpleNamespace(list_profiles=_failing_list_profiles)
result = await booter._resolve_profile(client)
assert result == "python-default"
@pytest.mark.asyncio
async def test_falls_back_on_empty_profiles(self):
"""Empty profile list → python-default."""
async def _empty_list_profiles():
return SimpleNamespace(items=[])
booter = self._make_booter()
client = SimpleNamespace(list_profiles=_empty_list_profiles)
result = await booter._resolve_profile(client)
assert result == "python-default"
@pytest.mark.asyncio
async def test_single_profile_selected(self):
"""Only one profile available → use it."""
async def _single_profile():
return SimpleNamespace(
items=[
SimpleNamespace(
id="python-data",
capabilities=["python", "shell", "filesystem"],
),
]
)
booter = self._make_booter()
client = SimpleNamespace(list_profiles=_single_profile)
result = await booter._resolve_profile(client)
assert result == "python-data"
@pytest.mark.asyncio
async def test_auth_error_not_silenced(self):
"""UnauthorizedError must propagate, not be downgraded to fallback."""
from shipyard_neo.errors import UnauthorizedError
async def _unauthorized_list_profiles():
raise UnauthorizedError("bad token")
booter = self._make_booter()
client = SimpleNamespace(list_profiles=_unauthorized_list_profiles)
with pytest.raises(UnauthorizedError):
await booter._resolve_profile(client)
# ═══════════════════════════════════════════════════════════════
# ComputerBooter base class
# ═══════════════════════════════════════════════════════════════
class TestBaseComputerBooter:
"""Verify base class defaults."""
def test_capabilities_default_none(self):
from astrbot.core.computer.booters.base import ComputerBooter
booter = ComputerBooter()
assert booter.capabilities is None
def test_browser_default_none(self):
from astrbot.core.computer.booters.base import ComputerBooter
booter = ComputerBooter()
assert booter.browser is None
-104
View File
@@ -1,104 +0,0 @@
from __future__ import annotations
from pathlib import Path
from astrbot.core.skills.skill_manager import SkillManager
def _write_skill(root: Path, name: str, description: str) -> None:
skill_dir = root / name
skill_dir.mkdir(parents=True, exist_ok=True)
skill_dir.joinpath("SKILL.md").write_text(
f"---\ndescription: {description}\n---\n# {name}\n",
encoding="utf-8",
)
def test_list_skills_merges_local_and_sandbox_cache(monkeypatch, tmp_path: Path):
data_dir = tmp_path / "data"
temp_dir = tmp_path / "temp"
skills_root = tmp_path / "skills"
data_dir.mkdir(parents=True, exist_ok=True)
temp_dir.mkdir(parents=True, exist_ok=True)
skills_root.mkdir(parents=True, exist_ok=True)
monkeypatch.setattr(
"astrbot.core.skills.skill_manager.get_astrbot_data_path",
lambda: str(data_dir),
)
monkeypatch.setattr(
"astrbot.core.skills.skill_manager.get_astrbot_temp_path",
lambda: str(temp_dir),
)
mgr = SkillManager(skills_root=str(skills_root))
_write_skill(skills_root, "custom-local", "local description")
mgr.set_sandbox_skills_cache(
[
{
"name": "python-sandbox",
"description": "ship built-in",
"path": "/app/skills/python-sandbox/SKILL.md",
},
{
"name": "custom-local",
"description": "should be ignored by local override",
"path": "skills/custom-local/SKILL.md",
},
]
)
skills = mgr.list_skills(runtime="sandbox")
by_name = {item.name: item for item in skills}
assert sorted(by_name) == ["custom-local", "python-sandbox"]
assert by_name["custom-local"].description == "local description"
assert by_name["custom-local"].path == "skills/custom-local/SKILL.md"
assert by_name["python-sandbox"].description == "ship built-in"
assert by_name["python-sandbox"].path == "skills/python-sandbox/SKILL.md"
def test_sandbox_cached_skill_respects_active_and_display_path(
monkeypatch,
tmp_path: Path,
):
data_dir = tmp_path / "data"
temp_dir = tmp_path / "temp"
skills_root = tmp_path / "skills"
data_dir.mkdir(parents=True, exist_ok=True)
temp_dir.mkdir(parents=True, exist_ok=True)
skills_root.mkdir(parents=True, exist_ok=True)
monkeypatch.setattr(
"astrbot.core.skills.skill_manager.get_astrbot_data_path",
lambda: str(data_dir),
)
monkeypatch.setattr(
"astrbot.core.skills.skill_manager.get_astrbot_temp_path",
lambda: str(temp_dir),
)
mgr = SkillManager(skills_root=str(skills_root))
mgr.set_sandbox_skills_cache(
[
{
"name": "browser-automation",
"description": "gull built-in",
"path": "/app/skills/browser-automation/SKILL.md",
}
]
)
all_skills = mgr.list_skills(
runtime="sandbox",
active_only=False,
show_sandbox_path=False,
)
assert len(all_skills) == 1
assert all_skills[0].path == "/app/skills/browser-automation/SKILL.md"
mgr.set_skill_active("browser-automation", False)
active_skills = mgr.list_skills(runtime="sandbox", active_only=True)
assert active_skills == []
-203
View File
@@ -1,203 +0,0 @@
"""Tests for skill metadata: frontmatter parsing, prompt generation, absolute paths."""
from __future__ import annotations
from pathlib import Path
from astrbot.core.skills.skill_manager import (
SkillInfo,
SkillManager,
_parse_frontmatter_description,
build_skills_prompt,
)
# ---------- _parse_frontmatter_description tests ----------
def test_parse_frontmatter_description():
text = (
"---\n"
"name: screenshot-capture\n"
"description: Captures full-page screenshots of web pages. "
"Use when user asks to screenshot, take a picture of a page, "
"截图, or needs a visual snapshot of any URL.\n"
"---\n"
"# Screenshot Skill\n"
)
desc = _parse_frontmatter_description(text)
assert "Captures full-page screenshots" in desc
assert "截图" in desc
def test_parse_frontmatter_description_only():
text = "---\ndescription: legacy skill\n---\n# Title\n"
assert _parse_frontmatter_description(text) == "legacy skill"
def test_parse_frontmatter_empty():
assert _parse_frontmatter_description("no frontmatter") == ""
assert _parse_frontmatter_description("") == ""
def test_parse_frontmatter_missing_end_delimiter():
text = "---\ndescription: broken\n"
assert _parse_frontmatter_description(text) == ""
def test_parse_frontmatter_quoted_description():
text = '---\ndescription: "quoted value"\n---\n'
assert _parse_frontmatter_description(text) == "quoted value"
# ---------- build_skills_prompt tests ----------
def test_build_skills_prompt_basic_format():
skills = [
SkillInfo(
name="screenshot",
description="Take screenshots of web pages",
path="/abs/skills/screenshot/SKILL.md",
active=True,
)
]
prompt = build_skills_prompt(skills)
assert "**screenshot**" in prompt
assert "Take screenshots of web pages" in prompt
assert "`/abs/skills/screenshot/SKILL.md`" in prompt
def test_build_skills_prompt_absolute_path_in_example():
"""The mandatory grounding example should show the absolute path."""
skills = [
SkillInfo(
name="foo",
description="do foo",
path="/home/pan/AstrBot/skills/foo/SKILL.md",
active=True,
),
]
prompt = build_skills_prompt(skills)
assert "cat /home/pan/AstrBot/skills/foo/SKILL.md" in prompt
def test_build_skills_prompt_progressive_disclosure_rules():
"""The prompt should contain the key progressive disclosure rules."""
skills = [
SkillInfo(
name="test",
description="test skill",
path="/skills/test/SKILL.md",
active=True,
)
]
prompt = build_skills_prompt(skills)
# Numbered rules
assert "1." in prompt # Discovery
assert "2." in prompt # When to trigger
assert "3." in prompt # Mandatory grounding
assert "4." in prompt # Progressive disclosure
# Key concepts
assert "Mandatory grounding" in prompt
assert "Progressive disclosure" in prompt
assert "SKILL.md" in prompt
def test_build_skills_prompt_no_custom_fields():
"""Prompt should NOT contain triggers/capabilities/output labels."""
skills = [
SkillInfo(
name="test",
description="test skill",
path="/skills/test/SKILL.md",
active=True,
)
]
prompt = build_skills_prompt(skills)
assert "Triggers:" not in prompt
assert "Capabilities:" not in prompt
assert "Output:" not in prompt
# ---------- list_skills with description ----------
def test_list_skills_parses_description_from_local(monkeypatch, tmp_path: Path):
data_dir = tmp_path / "data"
temp_dir = tmp_path / "temp"
skills_root = tmp_path / "skills"
data_dir.mkdir(parents=True, exist_ok=True)
temp_dir.mkdir(parents=True, exist_ok=True)
skills_root.mkdir(parents=True, exist_ok=True)
monkeypatch.setattr(
"astrbot.core.skills.skill_manager.get_astrbot_data_path",
lambda: str(data_dir),
)
monkeypatch.setattr(
"astrbot.core.skills.skill_manager.get_astrbot_temp_path",
lambda: str(temp_dir),
)
skill_dir = skills_root / "screencap"
skill_dir.mkdir()
skill_dir.joinpath("SKILL.md").write_text(
"---\n"
"name: screencap\n"
"description: Capture screenshots of web pages. "
"Use when user asks to screenshot, 截图, or capture a page.\n"
"---\n"
"# Screenshot\n",
encoding="utf-8",
)
mgr = SkillManager(skills_root=str(skills_root))
skills = mgr.list_skills()
assert len(skills) == 1
s = skills[0]
assert "Capture screenshots" in s.description
assert "截图" in s.description
# SkillInfo should NOT have triggers/capabilities/output attributes
assert not hasattr(s, "triggers")
assert not hasattr(s, "capabilities")
assert not hasattr(s, "output")
def test_list_skills_description_from_sandbox_cache(
monkeypatch, tmp_path: Path
):
data_dir = tmp_path / "data"
temp_dir = tmp_path / "temp"
skills_root = tmp_path / "skills"
data_dir.mkdir(parents=True, exist_ok=True)
temp_dir.mkdir(parents=True, exist_ok=True)
skills_root.mkdir(parents=True, exist_ok=True)
monkeypatch.setattr(
"astrbot.core.skills.skill_manager.get_astrbot_data_path",
lambda: str(data_dir),
)
monkeypatch.setattr(
"astrbot.core.skills.skill_manager.get_astrbot_temp_path",
lambda: str(temp_dir),
)
mgr = SkillManager(skills_root=str(skills_root))
mgr.set_sandbox_skills_cache(
[
{
"name": "web-scrape",
"description": "Scrape web pages and extract structured data. "
"Use when user needs to extract content from URLs.",
"path": "/home/pan/AstrBot/skills/web-scrape/SKILL.md",
}
]
)
skills = mgr.list_skills(runtime="sandbox", show_sandbox_path=False)
assert len(skills) == 1
s = skills[0]
assert "Scrape web pages" in s.description
# Path should be the absolute path from cache
assert "/home/pan/AstrBot/skills/web-scrape/SKILL.md" in s.path