Skip to content

feat: 接入 Shipyard Neo 自迭代 Skill 闭环与管理能力#5028

Open
w31r4 wants to merge 46 commits intoAstrBotDevs:masterfrom
w31r4:feat/neo-skill-self-iteration
Open

feat: 接入 Shipyard Neo 自迭代 Skill 闭环与管理能力#5028
w31r4 wants to merge 46 commits intoAstrBotDevs:masterfrom
w31r4:feat/neo-skill-self-iteration

Conversation

@w31r4
Copy link

@w31r4 w31r4 commented Feb 11, 2026

Modifications / 改动点

本 PR 在 AstrBot 中接入 Shipyard Neo,并落地“人工触发的 Skill 自迭代闭环”(执行取证 -> payload/candidate -> evaluate -> promote -> stable 回写本地 SKILL.md)。
主要改动如下:

  • Sandbox 运行时基线

    • 新增 ShipyardNeoBooter,支持 python/shell/filesystem/browser
    • 增加 Neo 浏览器能力层并接入 booter。
    • computer_client 增加 shipyard_neo 分支,保留旧 shipyard 兼容路径。
    • _sync_skills_to_sandbox 改为覆盖同步,避免旧 unzip -n 导致技能长期陈旧。
  • 配置与依赖

    • provider_settings.sandbox.booter 增加 shipyard_neo,并默认使用 shipyard_neo
    • 新增 Neo 配置项:
      • shipyard_neo_endpoint
      • shipyard_neo_access_token
      • shipyard_neo_profile
      • shipyard_neo_ttl
    • 新增 shipyard-neo-sdk 依赖,并同步更新 requirements.txtpyproject.toml
  • Agent 工具(仅 shipyard_neo 挂载)

    • 浏览器执行:
      • astrbot_execute_browser
      • astrbot_execute_browser_batch
      • astrbot_run_browser_skill
    • 自迭代闭环:
      • astrbot_get_execution_history
      • astrbot_annotate_execution
      • astrbot_create_skill_payload
      • astrbot_get_skill_payload
      • astrbot_create_skill_candidate
      • astrbot_list_skill_candidates
      • astrbot_evaluate_skill_candidate
      • astrbot_promote_skill_candidate
      • astrbot_list_skill_releases
      • astrbot_rollback_skill_release
      • astrbot_sync_skill_release
  • stable 回写管理(Neo 真源语义)

    • 新增 NeoSkillSyncManager
      • 读取 Neo release/candidate/payload。
      • stable release 回写本地 data/skills/<local_skill_name>/SKILL.md
      • 维护 data/skills/neo_skill_map.json
      • 回写后激活本地 skill,并最佳努力刷新活跃 sandbox。
    • promote stage=stable 回写失败时自动执行 rollback,保证 Neo 真源与本地可用状态一致。
  • Dashboard API 与 WebUI

    • 新增 Neo 路由:
      • GET /api/skills/neo/candidates
      • GET /api/skills/neo/releases
      • GET /api/skills/neo/payload
      • POST /api/skills/neo/evaluate
      • POST /api/skills/neo/promote
      • POST /api/skills/neo/rollback
      • POST /api/skills/neo/sync
    • Skills 页面新增 Local / Neo 视图切换、过滤和关键动作(evaluate/promote/rollback/sync/payload 查看)。
    • 补充中英文 i18n 文案。
  • This is NOT a breaking change. / 这不是一个破坏性变更。


Screenshots or Test Results / 运行截图或测试结果

验证步骤:

cd AstrBot
uv run pytest -q \
  tests/test_neo_skill_sync.py \
  tests/test_neo_skill_tools.py \
  tests/test_dashboard.py::test_neo_skills_routes \
  tests/test_dashboard.py::test_auth_login

运行结果:

.....                                                                    [100%]
5 passed, 5 warnings in 8.58s

Checklist / 检查清单

  • 😊 如果 PR 中有新加入的功能,已经通过 Issue / 邮件等方式和作者讨论过。
  • 👀 我的更改经过了良好的测试,并已在上方提供了“验证步骤”和“运行截图”
  • 🤓 我确保没有引入新依赖库,或者引入了新依赖库的同时将其添加到了 requirements.txtpyproject.toml 文件相应位置。
  • 😮 我的更改没有引入恶意代码。

@dosubot dosubot bot added the size:XXL This PR changes 1000+ lines, ignoring generated files. label Feb 11, 2026
@dosubot
Copy link

dosubot bot commented Feb 11, 2026

Related Documentation

Checked 1 published document(s) in 1 knowledge base(s). No updates required.

How did I do? Any feedback?  Join Discord

@w31r4 w31r4 changed the title feat: integrate Shipyard Neo self-iterating skill loop and dashboard controls feat: 接入 Shipyard Neo 自迭代 Skill 闭环与管理能力 Feb 11, 2026
Copy link
Contributor

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - 我发现了 4 个问题,并给出了一些整体反馈:

  • 你在 pyproject.toml 中添加了对 shipyard-neo-sdk 的直接引用,但没有在 requirements.txt 中添加;如果你的部署或开发流程仍依赖 requirements.txt,建议也在其中添加相同依赖,以保持两个清单文件的一致性。
给 AI Agent 的提示
Please address the comments from this code review:

## Overall Comments
- You added `shipyard-neo-sdk` as a direct reference in `pyproject.toml` but not in `requirements.txt`; if your deployment or dev workflows still rely on `requirements.txt`, consider adding the same dependency there to keep the two manifests consistent.

## Individual Comments

### Comment 1
<location> `astrbot/dashboard/routes/skills.py:304-313` </location>
<code_context>
+            sync_json: dict[str, Any] | None = None
+            rollback_json: dict[str, Any] | None = None
+            if stage == "stable" and sync_to_local:
+                sync_mgr = NeoSkillSyncManager()
+                try:
+                    sync_result = await sync_mgr.sync_release(
</code_context>

<issue_to_address>
**suggestion (performance):** Stable promotion triggers skills sync twice (inside sync manager and again after), which is redundant.

When `stage == "stable" and sync_to_local`, `NeoSkillSyncManager().sync_release(...)` already triggers `sync_skills_to_active_sandboxes()`, and this route then calls `sync_skills_to_active_sandboxes()` again in the best-effort block. That means the same change is synced twice in a row. Consider relying on just one of these (either the manager’s call or the explicit call here) to avoid redundant work.

Suggested implementation:

```python
                sync_json = None
                did_sync_to_local = False
                if stage == "stable" and sync_to_local:
                    sync_mgr = NeoSkillSyncManager()
                    try:
                        sync_result = await sync_mgr.sync_release(
                            client,
                            release_id=str(release_json.get("id", "")),
                            require_stable=True,
                        )
                        did_sync_to_local = True
                        sync_json = {
                            "skill_key": sync_result.skill_key,
                            "local_skill_name": sync_result.local_skill_name,
                            "release_id": sync_result.release_id,
                            "candidate_id": sync_result.candidate_id,
                            "payload_ref": sync_result.payload_ref,

```

To fully avoid the double sync, you should also adjust the later "best-effort" sync call in this route:

1. Locate the block that currently calls `sync_skills_to_active_sandboxes()` (likely in a `try/except` "best-effort" section after the snippet you provided).
2. Wrap that call so it only runs when `did_sync_to_local` is `False`, for example:

   ```python
   if not did_sync_to_local:
       await sync_skills_to_active_sandboxes()
   ```

This way, when `stage == "stable" and sync_to_local` and `NeoSkillSyncManager().sync_release(...)` has already performed the sync, the route will skip the redundant second call.
</issue_to_address>

### Comment 2
<location> `astrbot/core/computer/booters/base.py:16-22` </location>
<code_context>
     @property
     def shell(self) -> ShellComponent: ...

+    @property
+    def browser(self) -> BrowserComponent:
+        raise NotImplementedError(
+            f"{self.__class__.__name__} does not support browser capability."
+        )
+
</code_context>

<issue_to_address>
**issue (bug_risk):** The base `browser` property raising `NotImplementedError` conflicts with `getattr(..., "browser", None)` checks in callers.

Because callers like `_get_browser_component` use `getattr(booter, "browser", None)` for capability detection, this property will raise instead of returning `None`, so the `if browser is None` path never executes. While the outer `try/except` masks the exception, it makes feature detection brittle and hides the clearer error message. Consider either returning `None` in the base implementation and overriding it in concrete booters, or updating callers to explicitly handle `NotImplementedError` when probing for `browser` support.
</issue_to_address>

### Comment 3
<location> `astrbot/dashboard/routes/skills.py:171` </location>
<code_context>
             logger.error(traceback.format_exc())
             return Response().error(str(e)).__dict__
+
+    async def get_neo_candidates(self):
+        try:
+            endpoint, access_token = self._get_neo_client_config()
</code_context>

<issue_to_address>
**issue (complexity):** Consider extracting shared helpers for Neo client usage and sync result formatting to simplify the SkillsRoute handlers and remove duplication.

You can keep all behavior but reduce complexity/duplication inside `SkillsRoute` by factoring out the repeated “get config → open BayClient → call API → shape response” pattern and the repeated sync-result shaping.

### 1. Centralize BayClient construction and error handling

The Neo endpoints all do:

- `_get_neo_client_config`
- import `BayClient`
- `async with BayClient(...) as client: ...`
- try/except with identical logging + error response

You can move this into a small helper to remove repetition and keep each handler focused on *what* it does rather than *how* it talks to Neo:

```python
from shipyard_neo import BayClient  # move import to top-level

class SkillsRoute(Route):
    ...

    async def _with_neo_client(self, func):
        try:
            endpoint, access_token = self._get_neo_client_config()
            async with BayClient(
                endpoint_url=endpoint,
                access_token=access_token,
            ) as client:
                return await func(client)
        except Exception as e:
            logger.error(traceback.format_exc())
            return Response().error(str(e)).__dict__
```

Usage example (applies to `get_neo_candidates`, `get_neo_releases`, `get_neo_payload`, `evaluate_neo_candidate`, `promote_neo_candidate`, `rollback_neo_release`, `sync_neo_release`):

```python
async def get_neo_candidates(self):
    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,
        )
        return Response().ok(_to_jsonable(candidates)).__dict__

    return await self._with_neo_client(_do)
```

This keeps route methods short and removes boilerplate.

### 2. Factor out the sync result shaping

`promote_neo_candidate` and `sync_neo_release` both build the same dict from `NeoSkillSyncResult`:

```python
{
    "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,
}
```

You can extract a tiny helper to avoid drift and keep both endpoints consistent:

```python
def _sync_result_to_dict(self, result):
    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,
    }
```

Then:

```python
# in promote_neo_candidate
sync_json = None
if stage == "stable" and sync_to_local:
    sync_result = await sync_mgr.sync_release(...)
    sync_json = self._sync_result_to_dict(sync_result)

# in sync_neo_release
result = await sync_mgr.sync_release(...)
return Response().ok(self._sync_result_to_dict(result)).__dict__
```

These two small extractions reduce duplication and make the route methods materially simpler without changing any behavior or moving to a full-blown service layer.
</issue_to_address>

### Comment 4
<location> `astrbot/core/computer/tools/neo_skills.py:51` </location>
<code_context>
+    return browser
+
+
+@dataclass
+class BrowserExecTool(FunctionTool):
+    name: str = "astrbot_execute_browser"
</code_context>

<issue_to_address>
**issue (complexity):** Consider introducing a shared Neo tool base class and moving promote/sync/rollback orchestration into NeoSkillSyncManager to eliminate repeated admin/client/error/JSON boilerplate and centralize lifecycle logic.

You can reduce the complexity and duplication meaningfully without changing behavior by introducing a shared base and centralizing the promotion/sync/rollback orchestration.

### 1. Factor out a common Neo tool base

All tools repeat the same pattern:

- ` _ensure_admin`
- `_get_neo_context`
- try/except → call SDKJSON-ify or format error

You can keep behavior identical but move the plumbing into a base class so each tool only defines its parameters and the actual SDK call.

```python
from dataclasses import dataclass, field
from typing import Any, Awaitable, Callable

@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)}"
```

Then each tool becomes much smaller; for example, `ListSkillReleasesTool`:

```python
@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",
        )
```

You can gradually migrate each tool to this pattern; it keeps the same signatures and behavior but removes repeated admin/client/error/JSON boilerplate.

### 2. Centralize promote/sync/rollback orchestration

`PromoteSkillCandidateTool` contains orchestration logic that’s already conceptually part of `NeoSkillSyncManager`/lifecycle concerns. You can move the flow into a helper function/service and keep the tool thin.

For example, add a method to `NeoSkillSyncManager` (or a small service next to it):

```python
class NeoSkillSyncManager:
    # existing methods...

    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

        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 = {
                    "skill_key": sync_result.skill_key,
                    "local_skill_name": sync_result.local_skill_name,
                    "release_id": sync_result.release_id,
                    "candidate_id": sync_result.candidate_id,
                    "payload_ref": sync_result.payload_ref,
                    "map_path": sync_result.map_path,
                    "synced_at": sync_result.synced_at,
                }
            except Exception as sync_err:
                try:
                    rollback = await client.skills.rollback_release(
                        str(release_json.get("id", ""))
                    )
                    rollback_json = _to_jsonable(rollback)
                except Exception as rollback_err:
                    raise RuntimeError(
                        "stable release sync failed and auto rollback also failed; "
                        f"sync_error={sync_err}; rollback_error={rollback_err}"
                    ) from rollback_err
                raise RuntimeError(
                    "stable release sync failed; auto rollback succeeded; "
                    f"sync_error={sync_err}; rollback={_to_json_text(rollback_json)}"
                ) from sync_err

        return {
            "release": release_json,
            "sync": sync_json,
            "rollback": rollback_json,
        }
```

Then `PromoteSkillCandidateTool.call` becomes:

```python
@dataclass
class PromoteSkillCandidateTool(NeoSkillToolBase):
    name: str = "astrbot_promote_skill_candidate"
    description: str = "Promote one candidate to release stage (canary/stable)."
    # parameters unchanged ...

    async def call(
        self,
        context: ContextWrapper[AstrAgentContext],
        candidate_id: str,
        stage: str = "canary",
        sync_to_local: bool = True,
    ) -> ToolExecResult:
        if stage not in {"canary", "stable"}:
            return "Error promoting skill candidate: stage must be canary or stable."

        if err := _ensure_admin(context):
            return err

        try:
            client, _sandbox = await _get_neo_context(context)
            sync_mgr = NeoSkillSyncManager()
            result = await sync_mgr.promote_with_optional_sync(
                client=client,
                candidate_id=candidate_id,
                stage=stage,
                sync_to_local=sync_to_local,
            )
            return _to_json_text(result)
        except Exception as e:
            return f"Error promoting skill candidate: {str(e)}"
```

This keeps all current behavior (including rollback semantics and error messages, if you preserve them in the manager) but:

- Removes duplicated orchestration from the tool.
- Gives you a single place (`NeoSkillSyncManager`) to align behavior between tools and HTTP routes.
</issue_to_address>

Sourcery 对开源项目免费 —— 如果你觉得我们的评审有帮助,欢迎分享 ✨
帮我变得更有用!请在每条评论上点 👍 或 👎,我会根据你的反馈改进后续的评审。
Original comment in English

Hey - I've found 4 issues, and left some high level feedback:

  • You added shipyard-neo-sdk as a direct reference in pyproject.toml but not in requirements.txt; if your deployment or dev workflows still rely on requirements.txt, consider adding the same dependency there to keep the two manifests consistent.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- You added `shipyard-neo-sdk` as a direct reference in `pyproject.toml` but not in `requirements.txt`; if your deployment or dev workflows still rely on `requirements.txt`, consider adding the same dependency there to keep the two manifests consistent.

## Individual Comments

### Comment 1
<location> `astrbot/dashboard/routes/skills.py:304-313` </location>
<code_context>
+            sync_json: dict[str, Any] | None = None
+            rollback_json: dict[str, Any] | None = None
+            if stage == "stable" and sync_to_local:
+                sync_mgr = NeoSkillSyncManager()
+                try:
+                    sync_result = await sync_mgr.sync_release(
</code_context>

<issue_to_address>
**suggestion (performance):** Stable promotion triggers skills sync twice (inside sync manager and again after), which is redundant.

When `stage == "stable" and sync_to_local`, `NeoSkillSyncManager().sync_release(...)` already triggers `sync_skills_to_active_sandboxes()`, and this route then calls `sync_skills_to_active_sandboxes()` again in the best-effort block. That means the same change is synced twice in a row. Consider relying on just one of these (either the manager’s call or the explicit call here) to avoid redundant work.

Suggested implementation:

```python
                sync_json = None
                did_sync_to_local = False
                if stage == "stable" and sync_to_local:
                    sync_mgr = NeoSkillSyncManager()
                    try:
                        sync_result = await sync_mgr.sync_release(
                            client,
                            release_id=str(release_json.get("id", "")),
                            require_stable=True,
                        )
                        did_sync_to_local = True
                        sync_json = {
                            "skill_key": sync_result.skill_key,
                            "local_skill_name": sync_result.local_skill_name,
                            "release_id": sync_result.release_id,
                            "candidate_id": sync_result.candidate_id,
                            "payload_ref": sync_result.payload_ref,

```

To fully avoid the double sync, you should also adjust the later "best-effort" sync call in this route:

1. Locate the block that currently calls `sync_skills_to_active_sandboxes()` (likely in a `try/except` "best-effort" section after the snippet you provided).
2. Wrap that call so it only runs when `did_sync_to_local` is `False`, for example:

   ```python
   if not did_sync_to_local:
       await sync_skills_to_active_sandboxes()
   ```

This way, when `stage == "stable" and sync_to_local` and `NeoSkillSyncManager().sync_release(...)` has already performed the sync, the route will skip the redundant second call.
</issue_to_address>

### Comment 2
<location> `astrbot/core/computer/booters/base.py:16-22` </location>
<code_context>
     @property
     def shell(self) -> ShellComponent: ...

+    @property
+    def browser(self) -> BrowserComponent:
+        raise NotImplementedError(
+            f"{self.__class__.__name__} does not support browser capability."
+        )
+
</code_context>

<issue_to_address>
**issue (bug_risk):** The base `browser` property raising `NotImplementedError` conflicts with `getattr(..., "browser", None)` checks in callers.

Because callers like `_get_browser_component` use `getattr(booter, "browser", None)` for capability detection, this property will raise instead of returning `None`, so the `if browser is None` path never executes. While the outer `try/except` masks the exception, it makes feature detection brittle and hides the clearer error message. Consider either returning `None` in the base implementation and overriding it in concrete booters, or updating callers to explicitly handle `NotImplementedError` when probing for `browser` support.
</issue_to_address>

### Comment 3
<location> `astrbot/dashboard/routes/skills.py:171` </location>
<code_context>
             logger.error(traceback.format_exc())
             return Response().error(str(e)).__dict__
+
+    async def get_neo_candidates(self):
+        try:
+            endpoint, access_token = self._get_neo_client_config()
</code_context>

<issue_to_address>
**issue (complexity):** Consider extracting shared helpers for Neo client usage and sync result formatting to simplify the SkillsRoute handlers and remove duplication.

You can keep all behavior but reduce complexity/duplication inside `SkillsRoute` by factoring out the repeated “get config → open BayClient → call API → shape response” pattern and the repeated sync-result shaping.

### 1. Centralize BayClient construction and error handling

The Neo endpoints all do:

- `_get_neo_client_config`
- import `BayClient`
- `async with BayClient(...) as client: ...`
- try/except with identical logging + error response

You can move this into a small helper to remove repetition and keep each handler focused on *what* it does rather than *how* it talks to Neo:

```python
from shipyard_neo import BayClient  # move import to top-level

class SkillsRoute(Route):
    ...

    async def _with_neo_client(self, func):
        try:
            endpoint, access_token = self._get_neo_client_config()
            async with BayClient(
                endpoint_url=endpoint,
                access_token=access_token,
            ) as client:
                return await func(client)
        except Exception as e:
            logger.error(traceback.format_exc())
            return Response().error(str(e)).__dict__
```

Usage example (applies to `get_neo_candidates`, `get_neo_releases`, `get_neo_payload`, `evaluate_neo_candidate`, `promote_neo_candidate`, `rollback_neo_release`, `sync_neo_release`):

```python
async def get_neo_candidates(self):
    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,
        )
        return Response().ok(_to_jsonable(candidates)).__dict__

    return await self._with_neo_client(_do)
```

This keeps route methods short and removes boilerplate.

### 2. Factor out the sync result shaping

`promote_neo_candidate` and `sync_neo_release` both build the same dict from `NeoSkillSyncResult`:

```python
{
    "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,
}
```

You can extract a tiny helper to avoid drift and keep both endpoints consistent:

```python
def _sync_result_to_dict(self, result):
    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,
    }
```

Then:

```python
# in promote_neo_candidate
sync_json = None
if stage == "stable" and sync_to_local:
    sync_result = await sync_mgr.sync_release(...)
    sync_json = self._sync_result_to_dict(sync_result)

# in sync_neo_release
result = await sync_mgr.sync_release(...)
return Response().ok(self._sync_result_to_dict(result)).__dict__
```

These two small extractions reduce duplication and make the route methods materially simpler without changing any behavior or moving to a full-blown service layer.
</issue_to_address>

### Comment 4
<location> `astrbot/core/computer/tools/neo_skills.py:51` </location>
<code_context>
+    return browser
+
+
+@dataclass
+class BrowserExecTool(FunctionTool):
+    name: str = "astrbot_execute_browser"
</code_context>

<issue_to_address>
**issue (complexity):** Consider introducing a shared Neo tool base class and moving promote/sync/rollback orchestration into NeoSkillSyncManager to eliminate repeated admin/client/error/JSON boilerplate and centralize lifecycle logic.

You can reduce the complexity and duplication meaningfully without changing behavior by introducing a shared base and centralizing the promotion/sync/rollback orchestration.

### 1. Factor out a common Neo tool base

All tools repeat the same pattern:

- ` _ensure_admin`
- `_get_neo_context`
- try/except → call SDKJSON-ify or format error

You can keep behavior identical but move the plumbing into a base class so each tool only defines its parameters and the actual SDK call.

```python
from dataclasses import dataclass, field
from typing import Any, Awaitable, Callable

@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)}"
```

Then each tool becomes much smaller; for example, `ListSkillReleasesTool`:

```python
@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",
        )
```

You can gradually migrate each tool to this pattern; it keeps the same signatures and behavior but removes repeated admin/client/error/JSON boilerplate.

### 2. Centralize promote/sync/rollback orchestration

`PromoteSkillCandidateTool` contains orchestration logic that’s already conceptually part of `NeoSkillSyncManager`/lifecycle concerns. You can move the flow into a helper function/service and keep the tool thin.

For example, add a method to `NeoSkillSyncManager` (or a small service next to it):

```python
class NeoSkillSyncManager:
    # existing methods...

    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

        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 = {
                    "skill_key": sync_result.skill_key,
                    "local_skill_name": sync_result.local_skill_name,
                    "release_id": sync_result.release_id,
                    "candidate_id": sync_result.candidate_id,
                    "payload_ref": sync_result.payload_ref,
                    "map_path": sync_result.map_path,
                    "synced_at": sync_result.synced_at,
                }
            except Exception as sync_err:
                try:
                    rollback = await client.skills.rollback_release(
                        str(release_json.get("id", ""))
                    )
                    rollback_json = _to_jsonable(rollback)
                except Exception as rollback_err:
                    raise RuntimeError(
                        "stable release sync failed and auto rollback also failed; "
                        f"sync_error={sync_err}; rollback_error={rollback_err}"
                    ) from rollback_err
                raise RuntimeError(
                    "stable release sync failed; auto rollback succeeded; "
                    f"sync_error={sync_err}; rollback={_to_json_text(rollback_json)}"
                ) from sync_err

        return {
            "release": release_json,
            "sync": sync_json,
            "rollback": rollback_json,
        }
```

Then `PromoteSkillCandidateTool.call` becomes:

```python
@dataclass
class PromoteSkillCandidateTool(NeoSkillToolBase):
    name: str = "astrbot_promote_skill_candidate"
    description: str = "Promote one candidate to release stage (canary/stable)."
    # parameters unchanged ...

    async def call(
        self,
        context: ContextWrapper[AstrAgentContext],
        candidate_id: str,
        stage: str = "canary",
        sync_to_local: bool = True,
    ) -> ToolExecResult:
        if stage not in {"canary", "stable"}:
            return "Error promoting skill candidate: stage must be canary or stable."

        if err := _ensure_admin(context):
            return err

        try:
            client, _sandbox = await _get_neo_context(context)
            sync_mgr = NeoSkillSyncManager()
            result = await sync_mgr.promote_with_optional_sync(
                client=client,
                candidate_id=candidate_id,
                stage=stage,
                sync_to_local=sync_to_local,
            )
            return _to_json_text(result)
        except Exception as e:
            return f"Error promoting skill candidate: {str(e)}"
```

This keeps all current behavior (including rollback semantics and error messages, if you preserve them in the manager) but:

- Removes duplicated orchestration from the tool.
- Gives you a single place (`NeoSkillSyncManager`) to align behavior between tools and HTTP routes.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

@dosubot dosubot bot added area:core The bug / feature is about astrbot's core, backend area:webui The bug / feature is about webui(dashboard) of astrbot. labels Feb 11, 2026
@w31r4 w31r4 changed the title feat: 接入 Shipyard Neo 自迭代 Skill 闭环与管理能力 feat(skills): integrate Neo lifecycle and sandbox sync strategy Feb 12, 2026
@w31r4 w31r4 changed the title feat(skills): integrate Neo lifecycle and sandbox sync strategy feat: 接入 Shipyard Neo 自迭代 Skill 闭环与管理能力 Feb 12, 2026
@w31r4
Copy link
Author

w31r4 commented Feb 12, 2026

补充说明(仅针对最近一个提交):40c7cf39

本次修补主要解决“本地上传 skill 与 ship/gull 预置 skill 的共存与同步一致性”问题:

  1. 调整沙箱同步策略(astrbot/core/computer/computer_client.py
  • 从“整目录清空覆盖”改为“托管覆盖”。
  • 仅清理 AstrBot 自己托管的 skills,不再误删 ship/gull 预置 skills。
  • 同步后在沙箱侧扫描 skills/*/SKILL.md,回传技能元数据。
  1. 增加沙箱 skill 元数据缓存与合并展示(astrbot/core/skills/skill_manager.py
  • 新增 sandbox_skills_cache.json 缓存机制。
  • list_skills(runtime="sandbox") 合并“本地上传 skills + 沙箱预置 skills”,并保持本地同名优先。
  1. 本地 skill 变更后触发活跃沙箱同步(astrbot/dashboard/routes/skills.py
  • 上传/删除本地 skill 后,新增 best-effort sync_skills_to_active_sandboxes()
  1. 新增测试
  • tests/test_computer_skill_sync.py
  • tests/test_skill_manager_sandbox_cache.py

本提交已本地验证:

  • uv run ruff format .
  • uv run ruff check .
  • uv run pytest -q tests/test_skill_manager_sandbox_cache.py tests/test_computer_skill_sync.py tests/test_neo_skill_sync.py
  • uv run pytest -q tests/test_dashboard.py -k neo_skills_routes

w31r4 and others added 9 commits February 16, 2026 02:37
…tions

Add [Computer] prefixed INFO logs to:
- shipyard_neo.py: shutdown, upload_file, download_file, available
- shipyard.py: shutdown, upload_file, download_file, available
- boxlite.py: upload_file success path
- computer_client.py: sync_skills_to_active_sandboxes, _sync_skills_to_sandbox

Improves traceability of sandbox lifecycle events.
Add _log_computer_config_changes() to detect and log modifications to
computer_use_runtime and sandbox.* keys when saving config via Dashboard.
Sensitive fields (tokens/secrets) are masked in log output.
Add scripts/start-with-neo.sh: one-click launcher that auto-generates
Bay config.yaml (anonymous mode, host_port), pulls Ship image, starts
Bay (port 8114) with health check, then starts AstrBot in foreground.
Ctrl+C stops both services. Supports BAY_PORT env var override.
- Endpoint hint: mention default port 8114
- Access Token hint: mention sk-bay-* format and credentials.json auto-discovery
- Updated in default.py, zh-CN, and en-US i18n files
- Generated config uses allow_anonymous: false (triggers auto-provision)
- Set BAY_DATA_DIR so credentials.json writes to pkgs/bay/
- Add read_bay_credentials() to extract auto-generated key after boot
- Display API key in config hints for easy AstrBot setup
19 new tests in test_computer_config.py:
- TestDiscoverBayCredentials (9 tests): env priority, cwd fallback,
  missing file, empty key, malformed JSON, endpoint mismatch, slash normalization
- TestLogComputerConfigChanges (10 tests): runtime change, sandbox key change,
  token masking, empty token label, missing provider_settings, add/remove keys
Document make pr-test-neo and make pr-test-full commands for local
CI-equivalent verification before submitting PRs.
Also add shipyard-neo-sdk dependency for neo support
RC-CHN and others added 21 commits February 17, 2026 17:06
extract a shared `_with_neo_client` wrapper to handle neo client
setup, teardown, and error responses in one place.

reduce duplicated try/except and `BayClient` context boilerplate across
neo skills endpoints while preserving existing request validation and
response payloads.
extract shared promote/sync orchestration into `NeoSkillSyncManager` so
computer tools and dashboard routes use the same rollback and error logic

add a reusable neo tool base runner to remove duplicated admin checks and
try/catch handling across skill-related tools, keeping responses consistent

factor sync result serialization into a single helper and reuse it where
stable release sync output is returned
When shipyard_neo_access_token is not configured, _discover_bay_credentials()
searches for Bay's credentials.json in:
1. BAY_DATA_DIR env var
2. Mono-repo relative path ../pkgs/bay/
3. Current working directory

Enables zero-config dev mode when Bay runs locally alongside AstrBot.
Include default endpoint URL (http://127.0.0.1:8114) and credentials.json
auto-discovery hint in the ValueError message when config is incomplete.
separate sandbox skill syncing into distinct apply and scan steps
while keeping the legacy combined command for compatibility

improve observability by adding phase-based logs and richer shell
error details that include exit code, stderr, and stdout tail

reuse a shared python-exec command builder to reduce duplication
and keep command generation consistent
- Add _discover_bay_credentials() auto-discovery in _get_neo_client_config()
- Catch ValueError separately in _with_neo_client(), log at DEBUG instead of
  ERROR with full traceback — prevents log spam when visiting Skills page
  without Bay configured
19 tests in test_computer_config.py:
- TestDiscoverBayCredentials (9 tests): env priority, cwd fallback,
  missing file, empty key, malformed JSON, endpoint mismatch, slash normalization
- TestLogComputerConfigChanges (10 tests): runtime change, sandbox key change,
  token masking, empty token label, missing provider_settings, add/remove keys

Uses unittest.mock.patch on AstrBot custom logger for reliable assertions.
When saving config with shipyard_neo sandbox, _validate_neo_connectivity()
performs an async /health check against the Bay endpoint. If Bay is
unreachable, a ⚠️ warning is appended to the success snackbar message.
Config still saves successfully — the warning is informational only.
Add BayContainerManager to manage Bay container lifecycle via Docker
Engine API, similar to how BoxliteBooter manages Ship containers.

When ShipyardNeoBooter endpoint is empty or set to '__auto__', Bay is
automatically pulled, started, health-checked, and credentials are
read from the container.

- New bay_manager.py: ensure_running, wait_healthy, read_credentials
- Integrate auto-start into ShipyardNeoBooter boot/shutdown
- Reuse Bay container across sessions (unless-stopped policy)
- Friendly error messages for Docker and credential failures
Add capabilities property to ComputerBooter base class (returns None)
and ShipyardNeoBooter (returns immutable tuple from sandbox).

- Extract DEFAULT_PROFILE class constant to replace scattered magic string
- Use tuple[str, ...] for immutability (no defensive copy needed)
- Add _resolve_profile() for smart profile selection:
  - honour user-specified profile
  - query Bay API, prefer browser-capable profiles
  - re-raise auth errors (401/403), fallback on transient failures
- Conditionally create NeoBrowserComponent only when profile has browser
- Log resolved profile and capabilities at boot
…pabilities

_apply_sandbox_tools now checks the booted session's capabilities
before registering browser tools (BrowserExecTool, BrowserBatchExecTool,
RunBrowserSkillTool).

- If no session exists yet (first request), all tools are registered
  conservatively to avoid breaking the initial interaction
- If a session exists without browser capability, browser tools are
  omitted, preventing CapabilityNotSupportedError from Bay
- Skill lifecycle tools remain unconditionally registered
- Rewrite build_skills_prompt() with structured numbered rules and
  markdown formatting for better LLM comprehension
- Sanitize example_path with _SAFE_PATH_RE before embedding in system
  prompt to prevent prompt injection via crafted skill paths
- Add docstring to _parse_frontmatter_description()
- Remove debug print(top_dirs) from install_skill_from_zip()
- Remove stale commented-out SANDBOX_SKILLS_ROOT line
- Resolve skills root via Path.resolve() so LLM prompts always
  reference absolute paths regardless of sandbox cwd
- Use resolved path in skill metadata for reliable cat/head commands
- Add DRY cross-reference comment for frontmatter parser
- Remove dead skills_root_abs field from JSON output (no consumer)
- Remove unnecessary os import and fake resolve/abspath branch
17 tests covering:
- ShipyardNeoBooter.capabilities property (tuple, immutability, pre/post boot)
- _apply_sandbox_tools conditional browser tool registration
- _resolve_profile smart selection (user-specified, browser preference,
  API error fallback, empty profiles, auth error pass-through)
- ComputerBooter base class defaults
11 tests covering:
- _parse_frontmatter_description: standard, description-only, empty,
  missing delimiter, quoted values
- build_skills_prompt: format, absolute path in example, progressive
  disclosure rules, absence of legacy custom fields
- SkillManager.list_skills: local frontmatter parsing, sandbox cache
  description passthrough
append a Shipyard Neo-specific system prompt note for filesystem
tool calls so paths are provided relative to the workspace root.
this prevents models from prepending `/workspace` and causing tool
path resolution failures
Normalize release stage values before stability checks so enum-like
objects and mixed-case strings are handled consistently.

When stable sync fails, treat "no previous release exists" during
auto-rollback as a skipped rollback instead of raising a secondary
runtime error
Normalize SKILL.md content during sync so each file includes name and
description metadata in a frontmatter block.

Preserve existing frontmatter values when present, derive description
from markdown content when missing, and fallback to a default
description to keep metadata complete and consistent.
Add backend routes to delete neo candidates and releases with optional
reason support and demo mode protection.

Expose delete actions in the Skills dashboard for candidate and release
rows, refresh data after success, and add localized success/failure
messages in en-US and zh-CN.
RC-CHN and others added 8 commits February 26, 2026 15:24
Disable the Neo mode toggle unless runtime is sandbox with
shipyard_neo configured, and show a warning when Neo is unavailable.

Also avoid loading Neo data when the environment is not compatible and
fall back to local mode to prevent invalid requests and confusion.
Sanitize the `name` field in `SKILL.md` frontmatter to remove newlines and control characters. This prevents potential frontmatter injection vulnerabilities where a malicious skill name could introduce arbitrary YAML fields or corrupt the file structure.

- Modified `_ensure_skill_frontmatter` in `astrbot/core/skills/neo_skill_sync.py` to normalize whitespace in `name`.
- Ensured `name` is cast to string before splitting to handle non-string inputs safely.
Sanitized the name field in SKILL.md frontmatter within astrbot/core/skills/neo_skill_sync.py. This prevents potential frontmatter injection vulnerabilities by removing newlines and control characters from the skill name. Verified the fix with a reproduction script and ensured existing tests pass.
Add explicit Neo lifecycle instructions to the main agent prompt so
skill creation and updates follow payload -> candidate -> promotion
instead of direct local folder writes.

Clarify lifecycle tool descriptions and parameter semantics, including
skill_key/source_execution_ids usage and stable release sync_to_local
behavior, to reduce ambiguity and improve consistent skill publishing.
keep sandbox skill cache in sync when deleting a skill from disk.
this prevents stale entries in the UI when no sandbox session is
active to refresh runtime cache
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:core The bug / feature is about astrbot's core, backend area:webui The bug / feature is about webui(dashboard) of astrbot. size:XXL This PR changes 1000+ lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants