test: add booter decoupling and profile-aware tool tests
This commit is contained in:
@@ -61,3 +61,4 @@ GenieData/
|
||||
.codex/
|
||||
.opencode/
|
||||
.kilocode/
|
||||
.serena
|
||||
@@ -0,0 +1,560 @@
|
||||
# Neo 工具解耦重构规划
|
||||
|
||||
## 问题总结
|
||||
|
||||
`_apply_sandbox_tools()` 把三件事混在一起:Booter 环境初始化、工具注册、Prompt 注入。
|
||||
导致两个直接后果:
|
||||
|
||||
1. **Subagent 拿不到 Neo 工具** — `_get_runtime_computer_tools()` 硬编码只返回 4 个基础工具,Neo 的 14 个工具不在其中
|
||||
2. **拆不动** — Neo 工具注册与 agent 请求构建流程绑死,无法独立使用
|
||||
|
||||
## 约束条件
|
||||
|
||||
- `shipyard`(旧版)和 `shipyard_neo` 必须并行共存
|
||||
- Neo 的 `capabilities` 是 **boot 后才知道的**(取决于 Bay profile 是否包含 `browser`),但工具注册发生在 boot 之前
|
||||
- 不改变任何用户可见的功能行为
|
||||
|
||||
## 设计目标
|
||||
|
||||
- `_apply_sandbox_tools()` 缩回到和 `_apply_local_env_tools()` 一样简洁
|
||||
- 主 Agent 和 Subagent 从同一个源获取工具,消除两条路径不一致的问题
|
||||
- Neo 工具可独立加载、独立卸载,不影响非 Neo 用户
|
||||
|
||||
## 核心思路
|
||||
|
||||
参考现有插件工具模式(路径 1):**Booter 自己声明它提供哪些工具和 prompt,agent 只负责取用**。
|
||||
|
||||
对于"boot 前不知道 capabilities"的问题,采用**两级策略**:
|
||||
- `@classmethod get_default_tools()` — 不需要实例,根据 booter **类型**返回保守的全量工具列表(包含 browser 工具)
|
||||
- `get_tools()` — 实例方法,boot 后根据**真实 capabilities** 返回精确列表(可能不含 browser)
|
||||
|
||||
首次请求用 default,后续请求用精确列表。行为与当前完全一致(当前代码在 capabilities 未知时也是保守注册全部工具)。
|
||||
|
||||
---
|
||||
|
||||
## 第一步:定义 Booter 类型常量
|
||||
|
||||
**新建文件**: `astrbot/core/computer/booters/constants.py`
|
||||
|
||||
```python
|
||||
BOOTER_SHIPYARD = "shipyard"
|
||||
BOOTER_SHIPYARD_NEO = "shipyard_neo"
|
||||
BOOTER_BOXLITE = "boxlite"
|
||||
```
|
||||
|
||||
全局替换所有硬编码字符串:
|
||||
|
||||
| 文件 | Before | After |
|
||||
|---|---|---|
|
||||
| `computer_client.py` | `"shipyard_neo"` | `BOOTER_SHIPYARD_NEO` |
|
||||
| `config/default.py` | `"shipyard_neo"` | `BOOTER_SHIPYARD_NEO` |
|
||||
| `dashboard/routes/config.py` | `"shipyard_neo"` | `BOOTER_SHIPYARD_NEO` |
|
||||
| `dashboard/routes/skills.py` | 如有 | `BOOTER_SHIPYARD_NEO` |
|
||||
| `astr_main_agent.py` | 后续步骤中删除 | — |
|
||||
|
||||
前端 `SkillsSection.vue` 中的字符串因跨语言无法用常量,保留字符串但加注释标记。
|
||||
|
||||
---
|
||||
|
||||
## 第二步:提取 Neo prompt 常量到 resources
|
||||
|
||||
**改动文件**: `astrbot/core/astr_main_agent_resources.py`
|
||||
|
||||
把 `_apply_sandbox_tools()` 中内联的两段 prompt 搬到 resources:
|
||||
|
||||
```python
|
||||
NEO_FILE_PATH_PROMPT = (
|
||||
"[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`."
|
||||
)
|
||||
|
||||
NEO_SKILL_LIFECYCLE_PROMPT = (
|
||||
"[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."
|
||||
)
|
||||
```
|
||||
|
||||
同时**删除** `astr_main_agent_resources.py` 中的 14 个 Neo 工具模块级单例:
|
||||
|
||||
```python
|
||||
# 删除这些行:
|
||||
# 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()
|
||||
```
|
||||
|
||||
以及对应的 import 行。非 Neo 用户不再因 import resources 而拉起整个 Neo 依赖树。
|
||||
|
||||
---
|
||||
|
||||
## 第三步:在 `ComputerBooter` 基类上声明工具提供能力
|
||||
|
||||
**改动文件**: `astrbot/core/computer/booters/base.py`
|
||||
|
||||
```python
|
||||
from __future__ import annotations
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from astrbot.core.agent.tool import FunctionTool
|
||||
|
||||
|
||||
class ComputerBooter:
|
||||
# ... 现有属性不变 (fs, python, shell, capabilities, browser, boot, shutdown, ...) ...
|
||||
|
||||
@classmethod
|
||||
def get_default_tools(cls) -> list[FunctionTool]:
|
||||
"""返回此 booter 类型的默认工具列表(不需要实例,不需要 boot)。
|
||||
|
||||
用于首次请求时 booter 尚未 boot 的场景。
|
||||
应返回保守的全量列表(宁多勿少)。
|
||||
子类必须覆写。
|
||||
"""
|
||||
return []
|
||||
|
||||
@classmethod
|
||||
def get_default_prompts(cls) -> list[str]:
|
||||
"""返回此 booter 类型的默认 system prompt 片段(不需要实例)。
|
||||
|
||||
子类必须覆写。
|
||||
"""
|
||||
return []
|
||||
|
||||
def get_tools(self) -> list[FunctionTool]:
|
||||
"""返回基于当前实例真实状态的工具列表。
|
||||
|
||||
boot 后调用,可根据 capabilities 等运行时信息精确过滤。
|
||||
默认实现委托给 get_default_tools(),子类按需覆写。
|
||||
"""
|
||||
return self.__class__.get_default_tools()
|
||||
|
||||
def get_system_prompt_parts(self) -> list[str]:
|
||||
"""返回基于当前实例状态的 prompt 片段。
|
||||
|
||||
默认实现委托给 get_default_prompts()。
|
||||
"""
|
||||
return self.__class__.get_default_prompts()
|
||||
```
|
||||
|
||||
设计要点:
|
||||
- `@classmethod get_default_tools()` — **不需要实例**,纯根据 booter 类型返回,解决"boot 前也需要注册工具"
|
||||
- `get_tools()` 实例方法 — boot 后调用,可利用 `self.capabilities` 精确过滤
|
||||
- 默认实现委托到 classmethod,子类只需要覆写需要的
|
||||
|
||||
---
|
||||
|
||||
## 第四步:各 Booter 子类实现
|
||||
|
||||
### ShipyardBooter(旧版)
|
||||
|
||||
**改动文件**: `astrbot/core/computer/booters/shipyard.py`
|
||||
|
||||
```python
|
||||
class ShipyardBooter(ComputerBooter):
|
||||
# ... 现有代码完全不变 ...
|
||||
|
||||
@classmethod
|
||||
def get_default_tools(cls) -> list[FunctionTool]:
|
||||
from astrbot.core.computer.tools.shell import ExecuteShellTool
|
||||
from astrbot.core.computer.tools.python import PythonTool
|
||||
from astrbot.core.computer.tools.fs import FileUploadTool, FileDownloadTool
|
||||
return [ExecuteShellTool(), PythonTool(), FileUploadTool(), FileDownloadTool()]
|
||||
|
||||
@classmethod
|
||||
def get_default_prompts(cls) -> list[str]:
|
||||
from astrbot.core.astr_main_agent_resources import SANDBOX_MODE_PROMPT
|
||||
return [SANDBOX_MODE_PROMPT]
|
||||
|
||||
# get_tools() 和 get_system_prompt_parts() 不需要覆写
|
||||
# 因为 shipyard 没有运行时 capabilities 变化,default 就是精确列表
|
||||
```
|
||||
|
||||
### ShipyardNeoBooter
|
||||
|
||||
**改动文件**: `astrbot/core/computer/booters/shipyard_neo.py`
|
||||
|
||||
```python
|
||||
class ShipyardNeoBooter(ComputerBooter):
|
||||
# ... 现有代码完全不变 ...
|
||||
|
||||
@classmethod
|
||||
def _base_tools(cls) -> list[FunctionTool]:
|
||||
"""4 个基础工具 + 11 个 Neo 生命周期工具(所有 Neo profile 都有)"""
|
||||
from astrbot.core.computer.tools.shell import ExecuteShellTool
|
||||
from astrbot.core.computer.tools.python import PythonTool
|
||||
from astrbot.core.computer.tools.fs import FileUploadTool, FileDownloadTool
|
||||
from astrbot.core.computer.tools.neo_skills import (
|
||||
GetExecutionHistoryTool, AnnotateExecutionTool,
|
||||
CreateSkillPayloadTool, GetSkillPayloadTool,
|
||||
CreateSkillCandidateTool, ListSkillCandidatesTool,
|
||||
EvaluateSkillCandidateTool, PromoteSkillCandidateTool,
|
||||
ListSkillReleasesTool, RollbackSkillReleaseTool,
|
||||
SyncSkillReleaseTool,
|
||||
)
|
||||
return [
|
||||
ExecuteShellTool(), PythonTool(),
|
||||
FileUploadTool(), FileDownloadTool(),
|
||||
GetExecutionHistoryTool(), AnnotateExecutionTool(),
|
||||
CreateSkillPayloadTool(), GetSkillPayloadTool(),
|
||||
CreateSkillCandidateTool(), ListSkillCandidatesTool(),
|
||||
EvaluateSkillCandidateTool(), PromoteSkillCandidateTool(),
|
||||
ListSkillReleasesTool(), RollbackSkillReleaseTool(),
|
||||
SyncSkillReleaseTool(),
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def _browser_tools(cls) -> list[FunctionTool]:
|
||||
"""3 个浏览器工具(仅 browser profile 有)"""
|
||||
from astrbot.core.computer.tools.browser import (
|
||||
BrowserExecTool, BrowserBatchExecTool, RunBrowserSkillTool,
|
||||
)
|
||||
return [BrowserExecTool(), BrowserBatchExecTool(), RunBrowserSkillTool()]
|
||||
|
||||
@classmethod
|
||||
def get_default_tools(cls) -> list[FunctionTool]:
|
||||
"""未 boot 时:保守返回全量(含 browser),与当前行为一致。"""
|
||||
return cls._base_tools() + cls._browser_tools()
|
||||
|
||||
@classmethod
|
||||
def get_default_prompts(cls) -> list[str]:
|
||||
from astrbot.core.astr_main_agent_resources import (
|
||||
SANDBOX_MODE_PROMPT, NEO_FILE_PATH_PROMPT, NEO_SKILL_LIFECYCLE_PROMPT,
|
||||
)
|
||||
return [NEO_FILE_PATH_PROMPT, NEO_SKILL_LIFECYCLE_PROMPT, SANDBOX_MODE_PROMPT]
|
||||
|
||||
def get_tools(self) -> list[FunctionTool]:
|
||||
"""boot 后:根据真实 capabilities 精确返回。"""
|
||||
caps = self.capabilities
|
||||
if caps is None:
|
||||
# 还没 boot 或 capabilities 不可用,走保守路径
|
||||
return self.__class__.get_default_tools()
|
||||
tools = self._base_tools()
|
||||
if "browser" in caps:
|
||||
tools.extend(self._browser_tools())
|
||||
return tools
|
||||
|
||||
# get_system_prompt_parts() 不需要覆写,prompt 不依赖 capabilities
|
||||
```
|
||||
|
||||
两级策略对照表:
|
||||
|
||||
| 场景 | 调用 | browser 工具 | 行为 |
|
||||
|---|---|---|---|
|
||||
| 首次请求,未 boot | `get_default_tools()` | **包含**(保守) | 与当前代码 `if caps is None` 分支一致 |
|
||||
| 后续请求,已 boot,profile 有 browser | `get_tools()` | **包含** | 精确 |
|
||||
| 后续请求,已 boot,profile 无 browser | `get_tools()` | **不包含** | 精确 |
|
||||
| ShipyardBooter(无 capabilities 概念) | `get_default_tools()` | 无 | 始终 4 个基础工具 |
|
||||
|
||||
---
|
||||
|
||||
## 第五步:`computer_client.py` 暴露统一的工具查询 API
|
||||
|
||||
**改动文件**: `astrbot/core/computer/computer_client.py`
|
||||
|
||||
```python
|
||||
from .booters.constants import BOOTER_SHIPYARD, BOOTER_SHIPYARD_NEO, BOOTER_BOXLITE
|
||||
|
||||
|
||||
# --- Booter 类型 → Booter 类 的映射(延迟 import) ---
|
||||
|
||||
def _get_booter_class(booter_type: str) -> type[ComputerBooter] | None:
|
||||
"""根据 booter 类型字符串返回对应的类(延迟 import)。"""
|
||||
if booter_type == BOOTER_SHIPYARD:
|
||||
from .booters.shipyard import ShipyardBooter
|
||||
return ShipyardBooter
|
||||
elif booter_type == BOOTER_SHIPYARD_NEO:
|
||||
from .booters.shipyard_neo import ShipyardNeoBooter
|
||||
return ShipyardNeoBooter
|
||||
elif booter_type == BOOTER_BOXLITE:
|
||||
from .booters.boxlite import BoxliteBooter
|
||||
return BoxliteBooter
|
||||
return None
|
||||
|
||||
|
||||
# --- 公共 API ---
|
||||
|
||||
def get_sandbox_tools(session_id: str) -> list[FunctionTool]:
|
||||
"""获取已 boot session 的精确工具列表。未 boot 返回空列表。"""
|
||||
booter = session_booter.get(session_id)
|
||||
if booter is None:
|
||||
return []
|
||||
return booter.get_tools()
|
||||
|
||||
|
||||
def get_sandbox_prompts(session_id: str) -> list[str]:
|
||||
"""获取已 boot session 的 prompt 片段。未 boot 返回空列表。"""
|
||||
booter = session_booter.get(session_id)
|
||||
if booter is None:
|
||||
return []
|
||||
return booter.get_system_prompt_parts()
|
||||
|
||||
|
||||
def get_default_sandbox_tools(sandbox_cfg: dict) -> list[FunctionTool]:
|
||||
"""根据配置中的 booter 类型返回默认工具列表。不需要实例,不需要 boot。
|
||||
|
||||
用于首次请求或 subagent 场景,booter 尚未 boot 时的保守注册。
|
||||
"""
|
||||
booter_type = sandbox_cfg.get("booter", BOOTER_SHIPYARD_NEO)
|
||||
cls = _get_booter_class(booter_type)
|
||||
if cls is None:
|
||||
return []
|
||||
return cls.get_default_tools()
|
||||
|
||||
|
||||
def get_default_sandbox_prompts(sandbox_cfg: dict) -> list[str]:
|
||||
"""根据配置中的 booter 类型返回默认 prompt 片段。不需要实例。"""
|
||||
booter_type = sandbox_cfg.get("booter", BOOTER_SHIPYARD_NEO)
|
||||
cls = _get_booter_class(booter_type)
|
||||
if cls is None:
|
||||
return []
|
||||
return cls.get_default_prompts()
|
||||
```
|
||||
|
||||
同时将 `_discover_bay_credentials` 重命名为 `discover_bay_credentials`(去掉下划线前缀),
|
||||
更新 `dashboard/routes/config.py` 和 `dashboard/routes/skills.py` 中的 import。
|
||||
|
||||
设计要点:
|
||||
- `get_sandbox_tools(session_id)` — 已 boot 时用,走 `booter.get_tools()` 实例方法
|
||||
- `get_default_sandbox_tools(cfg)` — 未 boot 时用,走 `BooterClass.get_default_tools()` 类方法
|
||||
- `_get_booter_class()` 集中了 booter_type → class 的映射,同时也可复用于 `get_booter()` 中的实例创建
|
||||
|
||||
---
|
||||
|
||||
## 第六步:简化 `_apply_sandbox_tools()`
|
||||
|
||||
**改动文件**: `astrbot/core/astr_main_agent.py`
|
||||
|
||||
**Before**(70+ 行):
|
||||
```python
|
||||
def _apply_sandbox_tools(config, req, session_id):
|
||||
booter = config.sandbox_cfg.get("booter", "shipyard_neo")
|
||||
if booter == "shipyard":
|
||||
os.environ["SHIPYARD_ENDPOINT"] = ...
|
||||
os.environ["SHIPYARD_ACCESS_TOKEN"] = ...
|
||||
req.func_tool.add_tool(EXECUTE_SHELL_TOOL)
|
||||
req.func_tool.add_tool(PYTHON_TOOL)
|
||||
# ... 14 个 Neo 工具逐个 add ...
|
||||
# ... 40 行内联 prompt ...
|
||||
# ... session_booter 全局字典偷读 ...
|
||||
```
|
||||
|
||||
**After**(~15 行):
|
||||
```python
|
||||
from astrbot.core.computer.computer_client import (
|
||||
get_sandbox_tools,
|
||||
get_sandbox_prompts,
|
||||
get_default_sandbox_tools,
|
||||
get_default_sandbox_prompts,
|
||||
)
|
||||
|
||||
def _apply_sandbox_tools(
|
||||
config: MainAgentBuildConfig, req: ProviderRequest, session_id: str
|
||||
) -> None:
|
||||
if req.func_tool is None:
|
||||
req.func_tool = ToolSet()
|
||||
if req.system_prompt is None:
|
||||
req.system_prompt = ""
|
||||
|
||||
# 已 boot → 精确列表;未 boot → 按 booter 类型取默认列表
|
||||
tools = get_sandbox_tools(session_id) or get_default_sandbox_tools(config.sandbox_cfg)
|
||||
for tool in tools:
|
||||
req.func_tool.add_tool(tool)
|
||||
|
||||
prompts = get_sandbox_prompts(session_id) or get_default_sandbox_prompts(config.sandbox_cfg)
|
||||
for prompt in prompts:
|
||||
req.system_prompt += f"\n{prompt}\n"
|
||||
```
|
||||
|
||||
**注意**:旧版 `ShipyardBooter` 路径中的 `os.environ["SHIPYARD_ENDPOINT"]` 设置需要保留。
|
||||
这个操作属于 infra 初始化,应该移到 `ShipyardBooter.boot()` 或 `get_booter()` 中处理:
|
||||
|
||||
```python
|
||||
# computer_client.py get_booter() 中,创建 ShipyardBooter 时:
|
||||
if booter_type == BOOTER_SHIPYARD:
|
||||
ep = sandbox_cfg.get("shipyard_endpoint", "")
|
||||
at = sandbox_cfg.get("shipyard_access_token", "")
|
||||
if not ep or not at:
|
||||
logger.error("Shipyard sandbox configuration is incomplete.")
|
||||
raise ValueError(...)
|
||||
os.environ["SHIPYARD_ENDPOINT"] = ep
|
||||
os.environ["SHIPYARD_ACCESS_TOKEN"] = at
|
||||
# ... 创建 ShipyardBooter ...
|
||||
```
|
||||
|
||||
这样 `_apply_sandbox_tools()` 彻底不再关心 booter 类型。
|
||||
|
||||
**同时删除**:
|
||||
- `astr_main_agent.py` 中所有 Neo 工具 import
|
||||
- `from astrbot.core.computer.computer_client import session_booter`
|
||||
- `if booter == "shipyard_neo":` 分支
|
||||
- 内联的 Neo prompt 文本
|
||||
|
||||
---
|
||||
|
||||
## 第七步:修复 Subagent 的工具获取路径
|
||||
|
||||
**改动文件**: `astrbot/core/astr_agent_tool_exec.py`
|
||||
|
||||
**Before**:
|
||||
```python
|
||||
@classmethod
|
||||
def _get_runtime_computer_tools(cls, runtime: str) -> dict[str, FunctionTool]:
|
||||
if runtime == "sandbox":
|
||||
return {
|
||||
EXECUTE_SHELL_TOOL.name: EXECUTE_SHELL_TOOL,
|
||||
PYTHON_TOOL.name: PYTHON_TOOL,
|
||||
FILE_UPLOAD_TOOL.name: FILE_UPLOAD_TOOL,
|
||||
FILE_DOWNLOAD_TOOL.name: FILE_DOWNLOAD_TOOL,
|
||||
}
|
||||
# ... 只有 4 个基础工具,Neo 全丢
|
||||
```
|
||||
|
||||
**After**:
|
||||
```python
|
||||
@classmethod
|
||||
def _get_runtime_computer_tools(
|
||||
cls,
|
||||
runtime: str,
|
||||
session_id: str | None = None,
|
||||
sandbox_cfg: dict | None = None,
|
||||
) -> dict[str, FunctionTool]:
|
||||
if runtime == "sandbox":
|
||||
from astrbot.core.computer.computer_client import (
|
||||
get_sandbox_tools,
|
||||
get_default_sandbox_tools,
|
||||
)
|
||||
# 与 _apply_sandbox_tools() 走同一条路径
|
||||
tools = (get_sandbox_tools(session_id) if session_id else []) \
|
||||
or (get_default_sandbox_tools(sandbox_cfg) if sandbox_cfg else [])
|
||||
return {t.name: t for t in tools} if tools else {}
|
||||
if runtime == "local":
|
||||
return {
|
||||
LOCAL_EXECUTE_SHELL_TOOL.name: LOCAL_EXECUTE_SHELL_TOOL,
|
||||
LOCAL_PYTHON_TOOL.name: LOCAL_PYTHON_TOOL,
|
||||
}
|
||||
return {}
|
||||
```
|
||||
|
||||
同步更新 `_build_handoff_toolset()` 传递 `session_id` 和 `sandbox_cfg`:
|
||||
|
||||
```python
|
||||
@classmethod
|
||||
def _build_handoff_toolset(cls, run_context, tools):
|
||||
ctx = run_context.context.context
|
||||
event = run_context.context.event
|
||||
cfg = ctx.get_config(umo=event.unified_msg_origin)
|
||||
provider_settings = cfg.get("provider_settings", {})
|
||||
runtime = str(provider_settings.get("computer_use_runtime", "local"))
|
||||
sandbox_cfg = provider_settings.get("sandbox", {})
|
||||
|
||||
runtime_computer_tools = cls._get_runtime_computer_tools(
|
||||
runtime,
|
||||
session_id=event.unified_msg_origin,
|
||||
sandbox_cfg=sandbox_cfg,
|
||||
)
|
||||
# ... 后续逻辑不变 ...
|
||||
```
|
||||
|
||||
**这是最关键的一步**:主 agent 和 subagent 从同一个源获取工具。
|
||||
|
||||
---
|
||||
|
||||
## 变更矩阵
|
||||
|
||||
| 文件 | 改动类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `booters/constants.py` | **新建** | booter 类型常量 |
|
||||
| `booters/base.py` | 新增 | `get_default_tools()`, `get_default_prompts()`, `get_tools()`, `get_system_prompt_parts()` |
|
||||
| `booters/shipyard.py` | 新增 | 实现 `get_default_tools()`, `get_default_prompts()` |
|
||||
| `booters/shipyard_neo.py` | 新增 | 实现两级工具声明 (`_base_tools` + `_browser_tools` + 覆写 `get_tools`) |
|
||||
| `computer_client.py` | 新增+重构 | 4 个公共 API + `_get_booter_class()` + 环境变量设置下沉 + 重命名 `discover_bay_credentials` |
|
||||
| `astr_main_agent.py` | **大幅简化** | `_apply_sandbox_tools()` 从 70+ 行变 ~15 行 |
|
||||
| `astr_main_agent_resources.py` | 增+删 | 新增 2 个 prompt 常量;删除 14 个 Neo 工具全局单例及 import |
|
||||
| `astr_agent_tool_exec.py` | 修改 | `_get_runtime_computer_tools()` + `_build_handoff_toolset()` 走统一 API |
|
||||
| `config/default.py` | 微调 | 常量替换 |
|
||||
| `dashboard/routes/config.py` | 微调 | import 路径 + 常量替换 |
|
||||
| `dashboard/routes/skills.py` | 微调 | import 路径 + 常量替换 |
|
||||
|
||||
---
|
||||
|
||||
## 重构前后对比
|
||||
|
||||
### Before: 两条断裂路径 + booter 类型判断散落各处
|
||||
|
||||
```
|
||||
主 Agent Subagent
|
||||
build_main_agent() _execute_handoff()
|
||||
└── _apply_sandbox_tools() └── _build_handoff_toolset()
|
||||
├── if booter == "shipyard": ... └── _get_runtime_computer_tools()
|
||||
├── 4 基础工具 ✓ └── 硬编码 4 个基础工具 ✗
|
||||
├── if booter == "shipyard_neo": (Neo 工具全丢)
|
||||
│ ├── session_booter 偷读
|
||||
│ ├── 3 浏览器工具 ✓
|
||||
│ ├── 11 Neo 工具 ✓
|
||||
│ └── 40 行内联 prompt ✓
|
||||
└── SANDBOX_MODE_PROMPT
|
||||
```
|
||||
|
||||
### After: 统一的工具获取源,booter 自描述
|
||||
|
||||
```
|
||||
ComputerBooter
|
||||
├── get_default_tools() ← @classmethod, 不需要实例
|
||||
└── get_tools() ← 实例方法, boot 后精确过滤
|
||||
│
|
||||
┌────────────────────┼────────────────────┐
|
||||
│ │ │
|
||||
ShipyardBooter ShipyardNeoBooter (未来 Booter)
|
||||
4 基础工具 15 基础 + 3 browser 自定义工具
|
||||
SANDBOX_MODE + NEO prompts 自定义 prompt
|
||||
|
||||
computer_client.py
|
||||
┌──── get_sandbox_tools(session_id) 已 boot → 精确
|
||||
│ get_sandbox_prompts(session_id)
|
||||
│
|
||||
└──── get_default_sandbox_tools(cfg) 未 boot → 保守全量
|
||||
get_default_sandbox_prompts(cfg)
|
||||
│
|
||||
┌────────────┴────────────┐
|
||||
│ │
|
||||
_apply_sandbox_tools() _get_runtime_computer_tools()
|
||||
(主 Agent) (Subagent handoff)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 执行顺序
|
||||
|
||||
1. **第一步**(定义常量)— 最小改动,零风险,可先合并
|
||||
2. **第二步**(提取 prompt 常量)— 纯搬移,不改逻辑
|
||||
3. **第三步 + 第四步**(基类接口 + 子类实现)— 核心抽象,新增方法不影响现有调用
|
||||
4. **第五步**(computer_client API)— 新增公共函数,不影响现有调用
|
||||
5. **第六步 + 第七步**(简化 agent + 修复 handoff)— 最终切换,一起改一起测
|
||||
|
||||
步骤 1-4 都是**纯新增**,不修改任何现有调用路径,可以安全地逐步合并。
|
||||
步骤 5-6 是**切换调用路径**,需要一起提交并完整回归测试。
|
||||
@@ -0,0 +1,412 @@
|
||||
"""TDD tests for booter decoupling refactoring.
|
||||
|
||||
Tests written BEFORE implementation — all should initially FAIL (red).
|
||||
After each implementation step, the corresponding tests should turn green.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
# ═══════════════════════ Step 1: 常量 ═══════════════════════
|
||||
|
||||
|
||||
class TestBooterConstants:
|
||||
def test_constants_exist(self):
|
||||
from astrbot.core.computer.booters.constants import (
|
||||
BOOTER_BOXLITE,
|
||||
BOOTER_SHIPYARD,
|
||||
BOOTER_SHIPYARD_NEO,
|
||||
)
|
||||
|
||||
assert BOOTER_SHIPYARD == "shipyard"
|
||||
assert BOOTER_SHIPYARD_NEO == "shipyard_neo"
|
||||
assert BOOTER_BOXLITE == "boxlite"
|
||||
|
||||
|
||||
# ═══════════════════════ Step 2: Prompt 常量 ═══════════════════════
|
||||
|
||||
|
||||
class TestNeoPromptConstants:
|
||||
def test_neo_file_path_prompt_exists(self):
|
||||
from astrbot.core.computer.prompts import NEO_FILE_PATH_PROMPT
|
||||
|
||||
assert "relative" in NEO_FILE_PATH_PROMPT.lower()
|
||||
assert "workspace" in NEO_FILE_PATH_PROMPT.lower()
|
||||
|
||||
def test_neo_skill_lifecycle_prompt_exists(self):
|
||||
from astrbot.core.computer.prompts import NEO_SKILL_LIFECYCLE_PROMPT
|
||||
|
||||
assert "astrbot_create_skill_payload" in NEO_SKILL_LIFECYCLE_PROMPT
|
||||
assert "astrbot_promote_skill_candidate" in NEO_SKILL_LIFECYCLE_PROMPT
|
||||
|
||||
|
||||
# ═══════════════════════ Step 3: 基类接口 ═══════════════════════
|
||||
|
||||
|
||||
class TestComputerBooterBaseInterface:
|
||||
def test_get_default_tools_returns_empty(self):
|
||||
from astrbot.core.computer.booters.base import ComputerBooter
|
||||
|
||||
assert ComputerBooter.get_default_tools() == []
|
||||
|
||||
def test_get_tools_delegates_to_class(self):
|
||||
from astrbot.core.computer.booters.base import ComputerBooter
|
||||
|
||||
booter = ComputerBooter()
|
||||
assert booter.get_tools() == []
|
||||
|
||||
def test_get_system_prompt_parts_returns_empty(self):
|
||||
from astrbot.core.computer.booters.base import ComputerBooter
|
||||
|
||||
assert ComputerBooter.get_system_prompt_parts() == []
|
||||
|
||||
|
||||
# ═══════════════════════ Step 4: Booter 子类工具声明 ═══════════════════════
|
||||
|
||||
|
||||
class TestShipyardBooterTools:
|
||||
def test_get_default_tools_returns_4(self):
|
||||
from astrbot.core.computer.booters.shipyard import ShipyardBooter
|
||||
|
||||
tools = ShipyardBooter.get_default_tools()
|
||||
assert len(tools) == 4
|
||||
names = {t.name for t in tools}
|
||||
assert "astrbot_execute_shell" in names
|
||||
assert "astrbot_execute_ipython" in names
|
||||
assert "astrbot_upload_file" in names
|
||||
assert "astrbot_download_file" in names
|
||||
|
||||
def test_get_system_prompt_parts_empty(self):
|
||||
from astrbot.core.computer.booters.shipyard import ShipyardBooter
|
||||
|
||||
assert ShipyardBooter.get_system_prompt_parts() == []
|
||||
|
||||
|
||||
class TestShipyardNeoBooterTools:
|
||||
def _make_booter(self, caps=None):
|
||||
from astrbot.core.computer.booters.shipyard_neo import ShipyardNeoBooter
|
||||
|
||||
booter = ShipyardNeoBooter(
|
||||
endpoint_url="http://localhost:8114",
|
||||
access_token="sk-bay-test",
|
||||
)
|
||||
if caps is not None:
|
||||
booter._sandbox = SimpleNamespace(capabilities=caps)
|
||||
return booter
|
||||
|
||||
def test_get_default_tools_returns_18(self):
|
||||
from astrbot.core.computer.booters.shipyard_neo import ShipyardNeoBooter
|
||||
|
||||
tools = ShipyardNeoBooter.get_default_tools()
|
||||
assert len(tools) == 18 # 4 base + 11 Neo + 3 browser
|
||||
names = {t.name for t in tools}
|
||||
assert "astrbot_execute_browser" in names
|
||||
assert "astrbot_create_skill_candidate" in names
|
||||
assert "astrbot_execute_shell" in names
|
||||
|
||||
def test_get_tools_no_boot_returns_default(self):
|
||||
booter = self._make_booter()
|
||||
tools = booter.get_tools()
|
||||
assert len(tools) == 18
|
||||
|
||||
def test_get_tools_with_browser(self):
|
||||
booter = self._make_booter(caps=["python", "shell", "filesystem", "browser"])
|
||||
tools = booter.get_tools()
|
||||
assert len(tools) == 18
|
||||
names = {t.name for t in tools}
|
||||
assert "astrbot_execute_browser" in names
|
||||
|
||||
def test_get_tools_without_browser(self):
|
||||
booter = self._make_booter(caps=["python", "shell", "filesystem"])
|
||||
tools = booter.get_tools()
|
||||
assert len(tools) == 15 # no browser
|
||||
names = {t.name for t in tools}
|
||||
assert "astrbot_execute_browser" not in names
|
||||
assert "astrbot_create_skill_candidate" in names
|
||||
|
||||
def test_get_system_prompt_parts_has_neo_prompts(self):
|
||||
from astrbot.core.computer.booters.shipyard_neo import ShipyardNeoBooter
|
||||
|
||||
parts = ShipyardNeoBooter.get_system_prompt_parts()
|
||||
assert len(parts) == 2
|
||||
combined = "".join(parts)
|
||||
assert "relative" in combined.lower()
|
||||
assert "astrbot_create_skill_payload" in combined
|
||||
|
||||
|
||||
class TestBoxliteBooterTools:
|
||||
def test_get_default_tools_returns_4(self):
|
||||
pytest.importorskip("boxlite")
|
||||
from astrbot.core.computer.booters.boxlite import BoxliteBooter
|
||||
|
||||
tools = BoxliteBooter.get_default_tools()
|
||||
assert len(tools) == 4
|
||||
names = {t.name for t in tools}
|
||||
assert "astrbot_execute_shell" in names
|
||||
|
||||
def test_get_system_prompt_parts_empty(self):
|
||||
pytest.importorskip("boxlite")
|
||||
from astrbot.core.computer.booters.boxlite import BoxliteBooter
|
||||
|
||||
assert BoxliteBooter.get_system_prompt_parts() == []
|
||||
|
||||
|
||||
# ═══════════════════════ Step 5: computer_client API ═══════════════════════
|
||||
|
||||
|
||||
class TestComputerClientAPI:
|
||||
def test_get_sandbox_tools_unknown_session(self):
|
||||
from astrbot.core.computer.computer_client import get_sandbox_tools
|
||||
|
||||
with patch("astrbot.core.computer.computer_client.session_booter", {}):
|
||||
assert get_sandbox_tools("unknown") == []
|
||||
|
||||
def test_get_sandbox_tools_with_booted_session(self):
|
||||
from astrbot.core.computer.computer_client import get_sandbox_tools
|
||||
|
||||
fake_booter = SimpleNamespace(
|
||||
get_tools=lambda: ["tool1", "tool2"],
|
||||
)
|
||||
with patch(
|
||||
"astrbot.core.computer.computer_client.session_booter",
|
||||
{"s1": fake_booter},
|
||||
):
|
||||
assert get_sandbox_tools("s1") == ["tool1", "tool2"]
|
||||
|
||||
def test_get_default_sandbox_tools_neo(self):
|
||||
from astrbot.core.computer.computer_client import get_default_sandbox_tools
|
||||
|
||||
tools = get_default_sandbox_tools({"booter": "shipyard_neo"})
|
||||
assert len(tools) == 18
|
||||
|
||||
def test_get_default_sandbox_tools_shipyard(self):
|
||||
from astrbot.core.computer.computer_client import get_default_sandbox_tools
|
||||
|
||||
tools = get_default_sandbox_tools({"booter": "shipyard"})
|
||||
assert len(tools) == 4
|
||||
|
||||
def test_get_default_sandbox_tools_boxlite(self):
|
||||
pytest.importorskip("boxlite")
|
||||
from astrbot.core.computer.computer_client import get_default_sandbox_tools
|
||||
|
||||
tools = get_default_sandbox_tools({"booter": "boxlite"})
|
||||
assert len(tools) == 4
|
||||
|
||||
def test_get_default_sandbox_tools_unknown_type(self):
|
||||
from astrbot.core.computer.computer_client import get_default_sandbox_tools
|
||||
|
||||
tools = get_default_sandbox_tools({"booter": "nonexistent"})
|
||||
assert tools == []
|
||||
|
||||
def test_get_sandbox_prompt_parts_neo(self):
|
||||
from astrbot.core.computer.computer_client import get_sandbox_prompt_parts
|
||||
|
||||
parts = get_sandbox_prompt_parts({"booter": "shipyard_neo"})
|
||||
assert len(parts) == 2
|
||||
|
||||
def test_get_sandbox_prompt_parts_shipyard(self):
|
||||
from astrbot.core.computer.computer_client import get_sandbox_prompt_parts
|
||||
|
||||
parts = get_sandbox_prompt_parts({"booter": "shipyard"})
|
||||
assert parts == []
|
||||
|
||||
|
||||
# ═══════════════════════ Step 6+7: 集成测试 ═══════════════════════
|
||||
|
||||
|
||||
class TestApplySandboxToolsRefactored:
|
||||
"""After refactoring, _apply_sandbox_tools uses unified API."""
|
||||
|
||||
def _tool_names(self, req) -> set[str]:
|
||||
if req.func_tool is None:
|
||||
return set()
|
||||
return {t.name for t in req.func_tool.tools}
|
||||
|
||||
def _neo_default_tools(self):
|
||||
from astrbot.core.computer.booters.shipyard_neo import ShipyardNeoBooter
|
||||
|
||||
return ShipyardNeoBooter.get_default_tools()
|
||||
|
||||
def _shipyard_default_tools(self):
|
||||
from astrbot.core.computer.booters.shipyard import ShipyardBooter
|
||||
|
||||
return ShipyardBooter.get_default_tools()
|
||||
|
||||
def test_neo_tools_registered_via_unified_api(self):
|
||||
try:
|
||||
from astrbot.core.astr_main_agent import _apply_sandbox_tools
|
||||
except ImportError:
|
||||
pytest.skip("circular import")
|
||||
config = SimpleNamespace(sandbox_cfg={"booter": "shipyard_neo"})
|
||||
req = SimpleNamespace(func_tool=None, system_prompt="")
|
||||
with (
|
||||
patch(
|
||||
"astrbot.core.computer.computer_client.get_sandbox_tools",
|
||||
return_value=[],
|
||||
),
|
||||
patch(
|
||||
"astrbot.core.computer.computer_client.get_default_sandbox_tools",
|
||||
return_value=self._neo_default_tools(),
|
||||
),
|
||||
patch(
|
||||
"astrbot.core.computer.computer_client.get_sandbox_prompt_parts",
|
||||
return_value=[],
|
||||
),
|
||||
):
|
||||
_apply_sandbox_tools(config, req, "session-1")
|
||||
names = self._tool_names(req)
|
||||
assert "astrbot_create_skill_candidate" in names
|
||||
assert "astrbot_execute_browser" in names
|
||||
assert len(names) == 18
|
||||
|
||||
def test_neo_prompt_injected(self):
|
||||
try:
|
||||
from astrbot.core.astr_main_agent import _apply_sandbox_tools
|
||||
except ImportError:
|
||||
pytest.skip("circular import")
|
||||
config = SimpleNamespace(sandbox_cfg={"booter": "shipyard_neo"})
|
||||
req = SimpleNamespace(func_tool=None, system_prompt="")
|
||||
with (
|
||||
patch(
|
||||
"astrbot.core.computer.computer_client.get_sandbox_tools",
|
||||
return_value=[],
|
||||
),
|
||||
patch(
|
||||
"astrbot.core.computer.computer_client.get_default_sandbox_tools",
|
||||
return_value=[],
|
||||
),
|
||||
patch(
|
||||
"astrbot.core.computer.computer_client.get_sandbox_prompt_parts",
|
||||
return_value=[
|
||||
"\n[Shipyard Neo File Path Rule]\nrelative workspace path\n",
|
||||
"\n[Neo Skill Lifecycle Workflow]\nastrbot_create_skill_payload\n",
|
||||
],
|
||||
),
|
||||
):
|
||||
_apply_sandbox_tools(config, req, "session-1")
|
||||
assert "relative" in req.system_prompt.lower()
|
||||
assert "astrbot_create_skill_payload" in req.system_prompt
|
||||
|
||||
def test_shipyard_no_neo_prompt(self):
|
||||
try:
|
||||
from astrbot.core.astr_main_agent import _apply_sandbox_tools
|
||||
except ImportError:
|
||||
pytest.skip("circular import")
|
||||
config = SimpleNamespace(sandbox_cfg={"booter": "shipyard"})
|
||||
req = SimpleNamespace(func_tool=None, system_prompt="")
|
||||
with (
|
||||
patch(
|
||||
"astrbot.core.computer.computer_client.get_sandbox_tools",
|
||||
return_value=[],
|
||||
),
|
||||
patch(
|
||||
"astrbot.core.computer.computer_client.get_default_sandbox_tools",
|
||||
return_value=self._shipyard_default_tools(),
|
||||
),
|
||||
patch(
|
||||
"astrbot.core.computer.computer_client.get_sandbox_prompt_parts",
|
||||
return_value=[],
|
||||
),
|
||||
):
|
||||
_apply_sandbox_tools(config, req, "session-1")
|
||||
names = self._tool_names(req)
|
||||
assert len(names) == 4
|
||||
assert "Neo Skill Lifecycle" not in req.system_prompt
|
||||
|
||||
def test_booted_session_without_browser(self):
|
||||
"""Booted session without browser capability → no browser tools."""
|
||||
try:
|
||||
from astrbot.core.astr_main_agent import _apply_sandbox_tools
|
||||
except ImportError:
|
||||
pytest.skip("circular import")
|
||||
from astrbot.core.computer.booters.shipyard_neo import ShipyardNeoBooter
|
||||
|
||||
fake_booter = ShipyardNeoBooter(
|
||||
endpoint_url="http://localhost:8114",
|
||||
access_token="sk-bay-test",
|
||||
)
|
||||
fake_booter._sandbox = SimpleNamespace(
|
||||
capabilities=["python", "shell", "filesystem"]
|
||||
)
|
||||
config = SimpleNamespace(sandbox_cfg={"booter": "shipyard_neo"})
|
||||
req = SimpleNamespace(func_tool=None, system_prompt="")
|
||||
with (
|
||||
patch(
|
||||
"astrbot.core.computer.computer_client.get_sandbox_tools",
|
||||
return_value=fake_booter.get_tools(),
|
||||
),
|
||||
patch(
|
||||
"astrbot.core.computer.computer_client.get_default_sandbox_tools",
|
||||
return_value=[],
|
||||
),
|
||||
patch(
|
||||
"astrbot.core.computer.computer_client.get_sandbox_prompt_parts",
|
||||
return_value=[],
|
||||
),
|
||||
):
|
||||
_apply_sandbox_tools(config, req, "session-1")
|
||||
names = self._tool_names(req)
|
||||
assert "astrbot_execute_browser" not in names
|
||||
assert "astrbot_create_skill_candidate" in names
|
||||
assert len(names) == 15
|
||||
|
||||
|
||||
class TestSubagentHandoffTools:
|
||||
"""Subagent should get same tools as main agent."""
|
||||
|
||||
def test_sandbox_runtime_gets_neo_tools(self):
|
||||
try:
|
||||
from astrbot.core.astr_agent_tool_exec import FunctionToolExecutor
|
||||
except ImportError:
|
||||
pytest.skip("circular import")
|
||||
with patch("astrbot.core.computer.computer_client.session_booter", {}):
|
||||
tools = FunctionToolExecutor._get_runtime_computer_tools(
|
||||
"sandbox",
|
||||
session_id=None,
|
||||
sandbox_cfg={"booter": "shipyard_neo"},
|
||||
)
|
||||
assert "astrbot_create_skill_candidate" in tools
|
||||
assert len(tools) == 18
|
||||
|
||||
def test_sandbox_runtime_shipyard_only_4(self):
|
||||
try:
|
||||
from astrbot.core.astr_agent_tool_exec import FunctionToolExecutor
|
||||
except ImportError:
|
||||
pytest.skip("circular import")
|
||||
with patch("astrbot.core.computer.computer_client.session_booter", {}):
|
||||
tools = FunctionToolExecutor._get_runtime_computer_tools(
|
||||
"sandbox",
|
||||
session_id=None,
|
||||
sandbox_cfg={"booter": "shipyard"},
|
||||
)
|
||||
assert len(tools) == 4
|
||||
assert "astrbot_create_skill_candidate" not in tools
|
||||
|
||||
def test_sandbox_runtime_empty_config_still_gets_default_tools(self):
|
||||
try:
|
||||
from astrbot.core.astr_agent_tool_exec import FunctionToolExecutor
|
||||
except ImportError:
|
||||
pytest.skip("circular import")
|
||||
tools = FunctionToolExecutor._get_runtime_computer_tools(
|
||||
"sandbox",
|
||||
session_id=None,
|
||||
sandbox_cfg={},
|
||||
)
|
||||
assert "astrbot_create_skill_candidate" in tools
|
||||
assert len(tools) == 18
|
||||
|
||||
def test_local_runtime_unchanged(self):
|
||||
try:
|
||||
from astrbot.core.astr_agent_tool_exec import FunctionToolExecutor
|
||||
except ImportError:
|
||||
pytest.skip("circular import")
|
||||
tools = FunctionToolExecutor._get_runtime_computer_tools(
|
||||
"local",
|
||||
session_id=None,
|
||||
sandbox_cfg={},
|
||||
)
|
||||
assert len(tools) == 2
|
||||
@@ -1,20 +1,18 @@
|
||||
"""Tests for _discover_bay_credentials() auto-discovery and _log_computer_config_changes()."""
|
||||
"""Tests for discover_bay_credentials() auto-discovery and config logging."""
|
||||
|
||||
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.core.computer.computer_client import discover_bay_credentials
|
||||
from astrbot.dashboard.routes.config import _log_computer_config_changes
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# _discover_bay_credentials
|
||||
# discover_bay_credentials
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
@@ -48,7 +46,7 @@ class TestDiscoverBayCredentials:
|
||||
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")
|
||||
result = discover_bay_credentials("http://127.0.0.1:8114")
|
||||
assert result == "sk-bay-from-env-dir"
|
||||
|
||||
def test_discover_from_cwd(
|
||||
@@ -60,7 +58,7 @@ class TestDiscoverBayCredentials:
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.delenv("BAY_DATA_DIR", raising=False)
|
||||
|
||||
result = _discover_bay_credentials("http://127.0.0.1:8114")
|
||||
result = discover_bay_credentials("http://127.0.0.1:8114")
|
||||
assert result == "sk-bay-from-cwd"
|
||||
|
||||
def test_returns_empty_when_no_credentials_found(
|
||||
@@ -70,7 +68,7 @@ class TestDiscoverBayCredentials:
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.delenv("BAY_DATA_DIR", raising=False)
|
||||
|
||||
result = _discover_bay_credentials("http://127.0.0.1:8114")
|
||||
result = discover_bay_credentials("http://127.0.0.1:8114")
|
||||
assert result == ""
|
||||
|
||||
def test_skips_empty_api_key(
|
||||
@@ -82,7 +80,7 @@ class TestDiscoverBayCredentials:
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.delenv("BAY_DATA_DIR", raising=False)
|
||||
|
||||
result = _discover_bay_credentials("http://127.0.0.1:8114")
|
||||
result = discover_bay_credentials("http://127.0.0.1:8114")
|
||||
assert result == ""
|
||||
|
||||
def test_skips_malformed_json(
|
||||
@@ -95,7 +93,7 @@ class TestDiscoverBayCredentials:
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.delenv("BAY_DATA_DIR", raising=False)
|
||||
|
||||
result = _discover_bay_credentials("http://127.0.0.1:8114")
|
||||
result = discover_bay_credentials("http://127.0.0.1:8114")
|
||||
assert result == ""
|
||||
|
||||
@patch("astrbot.core.computer.computer_client.logger")
|
||||
@@ -110,7 +108,7 @@ class TestDiscoverBayCredentials:
|
||||
)
|
||||
monkeypatch.setenv("BAY_DATA_DIR", str(data_dir))
|
||||
|
||||
result = _discover_bay_credentials("http://127.0.0.1:8114")
|
||||
result = discover_bay_credentials("http://127.0.0.1:8114")
|
||||
|
||||
assert result == "sk-bay-mismatch"
|
||||
mock_logger.warning.assert_called_once()
|
||||
@@ -129,7 +127,7 @@ class TestDiscoverBayCredentials:
|
||||
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")
|
||||
result = discover_bay_credentials("http://127.0.0.1:8114")
|
||||
|
||||
assert result == "sk-bay-match"
|
||||
mock_logger.warning.assert_not_called()
|
||||
@@ -145,7 +143,7 @@ class TestDiscoverBayCredentials:
|
||||
monkeypatch.setenv("BAY_DATA_DIR", str(env_dir))
|
||||
monkeypatch.chdir(cwd_dir)
|
||||
|
||||
result = _discover_bay_credentials("http://127.0.0.1:8114")
|
||||
result = discover_bay_credentials("http://127.0.0.1:8114")
|
||||
assert result == "sk-bay-env-wins"
|
||||
|
||||
def test_trailing_slash_normalization(
|
||||
@@ -160,7 +158,7 @@ class TestDiscoverBayCredentials:
|
||||
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")
|
||||
result = discover_bay_credentials("http://127.0.0.1:8114")
|
||||
|
||||
assert result == "sk-bay-slash"
|
||||
mock_logger.warning.assert_not_called()
|
||||
@@ -184,7 +182,10 @@ class TestLogComputerConfigChanges:
|
||||
|
||||
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)
|
||||
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:
|
||||
@@ -214,7 +215,9 @@ class TestLogComputerConfigChanges:
|
||||
assert args[3] == "shipyard_neo"
|
||||
found = True
|
||||
break
|
||||
assert found, f"Expected booter change in log calls: {mock_logger.info.call_args_list}"
|
||||
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:
|
||||
@@ -237,9 +240,7 @@ class TestLogComputerConfigChanges:
|
||||
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"}
|
||||
}
|
||||
"provider_settings": {"sandbox": {"shipyard_neo_access_token": "old-key"}}
|
||||
}
|
||||
new = {"provider_settings": {"sandbox": {"shipyard_neo_access_token": ""}}}
|
||||
|
||||
@@ -313,9 +314,7 @@ class TestLogComputerConfigChanges:
|
||||
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"}}
|
||||
}
|
||||
new = {"provider_settings": {"sandbox": {"my_secret_key": "very-secret-value"}}}
|
||||
|
||||
_log_computer_config_changes(old, new)
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# ShipyardNeoBooter.capabilities
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
@@ -93,8 +92,19 @@ class TestApplySandboxToolsConditional:
|
||||
config = _make_config("shipyard_neo")
|
||||
req = _make_req()
|
||||
|
||||
with patch(
|
||||
"astrbot.core.computer.computer_client.session_booter", {}
|
||||
with (
|
||||
patch(
|
||||
"astrbot.core.computer.computer_client.get_sandbox_tools",
|
||||
return_value=[],
|
||||
),
|
||||
patch(
|
||||
"astrbot.core.computer.computer_client.get_default_sandbox_tools",
|
||||
return_value=self._make_neo_booter().get_default_tools(),
|
||||
),
|
||||
patch(
|
||||
"astrbot.core.computer.computer_client.get_sandbox_prompt_parts",
|
||||
return_value=[],
|
||||
),
|
||||
):
|
||||
fn(config, req, "session-1")
|
||||
|
||||
@@ -103,18 +113,39 @@ class TestApplySandboxToolsConditional:
|
||||
assert "astrbot_execute_browser_batch" in names
|
||||
assert "astrbot_run_browser_skill" in names
|
||||
|
||||
def _make_neo_booter(self, caps=None):
|
||||
from astrbot.core.computer.booters.shipyard_neo import ShipyardNeoBooter
|
||||
|
||||
booter = ShipyardNeoBooter(
|
||||
endpoint_url="http://localhost:8114",
|
||||
access_token="sk-bay-test",
|
||||
)
|
||||
if caps is not None:
|
||||
booter._sandbox = SimpleNamespace(capabilities=caps)
|
||||
return booter
|
||||
|
||||
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"]
|
||||
fake_booter = self._make_neo_booter(
|
||||
caps=["python", "shell", "filesystem", "browser"]
|
||||
)
|
||||
|
||||
with patch(
|
||||
"astrbot.core.computer.computer_client.session_booter",
|
||||
{"session-1": fake_booter},
|
||||
with (
|
||||
patch(
|
||||
"astrbot.core.computer.computer_client.get_sandbox_tools",
|
||||
return_value=fake_booter.get_tools(),
|
||||
),
|
||||
patch(
|
||||
"astrbot.core.computer.computer_client.get_default_sandbox_tools",
|
||||
return_value=[],
|
||||
),
|
||||
patch(
|
||||
"astrbot.core.computer.computer_client.get_sandbox_prompt_parts",
|
||||
return_value=[],
|
||||
),
|
||||
):
|
||||
fn(config, req, "session-1")
|
||||
|
||||
@@ -126,13 +157,21 @@ class TestApplySandboxToolsConditional:
|
||||
fn = _import_apply_sandbox_tools()
|
||||
config = _make_config("shipyard_neo")
|
||||
req = _make_req()
|
||||
fake_booter = SimpleNamespace(
|
||||
capabilities=["python", "shell", "filesystem"]
|
||||
)
|
||||
fake_booter = self._make_neo_booter(caps=["python", "shell", "filesystem"])
|
||||
|
||||
with patch(
|
||||
"astrbot.core.computer.computer_client.session_booter",
|
||||
{"session-1": fake_booter},
|
||||
with (
|
||||
patch(
|
||||
"astrbot.core.computer.computer_client.get_sandbox_tools",
|
||||
return_value=fake_booter.get_tools(),
|
||||
),
|
||||
patch(
|
||||
"astrbot.core.computer.computer_client.get_default_sandbox_tools",
|
||||
return_value=[],
|
||||
),
|
||||
patch(
|
||||
"astrbot.core.computer.computer_client.get_sandbox_prompt_parts",
|
||||
return_value=[],
|
||||
),
|
||||
):
|
||||
fn(config, req, "session-1")
|
||||
|
||||
@@ -148,11 +187,21 @@ class TestApplySandboxToolsConditional:
|
||||
fn = _import_apply_sandbox_tools()
|
||||
config = _make_config("shipyard_neo")
|
||||
req = _make_req()
|
||||
fake_booter = SimpleNamespace(capabilities=["python"])
|
||||
fake_booter = self._make_neo_booter(caps=["python"])
|
||||
|
||||
with patch(
|
||||
"astrbot.core.computer.computer_client.session_booter",
|
||||
{"session-1": fake_booter},
|
||||
with (
|
||||
patch(
|
||||
"astrbot.core.computer.computer_client.get_sandbox_tools",
|
||||
return_value=fake_booter.get_tools(),
|
||||
),
|
||||
patch(
|
||||
"astrbot.core.computer.computer_client.get_default_sandbox_tools",
|
||||
return_value=[],
|
||||
),
|
||||
patch(
|
||||
"astrbot.core.computer.computer_client.get_sandbox_prompt_parts",
|
||||
return_value=[],
|
||||
),
|
||||
):
|
||||
fn(config, req, "session-1")
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""Tests for astr_main_agent module."""
|
||||
|
||||
import os
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
@@ -1399,8 +1398,8 @@ class TestApplySandboxTools:
|
||||
|
||||
assert "sandboxed environment" in req.system_prompt
|
||||
|
||||
def test_apply_sandbox_tools_with_shipyard_booter(self, monkeypatch):
|
||||
"""Test sandbox tools with shipyard booter configuration."""
|
||||
def test_apply_sandbox_tools_with_shipyard_booter(self):
|
||||
"""Test sandbox tools with shipyard booter registers 4 basic tools."""
|
||||
module = ama
|
||||
config = module.MainAgentBuildConfig(
|
||||
tool_call_timeout=60,
|
||||
@@ -1413,55 +1412,32 @@ class TestApplySandboxTools:
|
||||
)
|
||||
req = ProviderRequest(prompt="Test", func_tool=None)
|
||||
|
||||
monkeypatch.delenv("SHIPYARD_ENDPOINT", raising=False)
|
||||
monkeypatch.delenv("SHIPYARD_ACCESS_TOKEN", raising=False)
|
||||
|
||||
module._apply_sandbox_tools(config, req, "session-123")
|
||||
|
||||
assert os.environ.get("SHIPYARD_ENDPOINT") == "https://shipyard.example.com"
|
||||
assert os.environ.get("SHIPYARD_ACCESS_TOKEN") == "test-token"
|
||||
names = req.func_tool.names()
|
||||
assert "astrbot_execute_shell" in names
|
||||
assert len(names) == 4
|
||||
|
||||
def test_apply_sandbox_tools_shipyard_missing_endpoint(self):
|
||||
"""Test that shipyard config is skipped when endpoint is missing."""
|
||||
def test_apply_sandbox_tools_neo_booter_registers_18_tools(self):
|
||||
"""Test sandbox tools with Neo booter registers all 18 tools."""
|
||||
module = ama
|
||||
config = module.MainAgentBuildConfig(
|
||||
tool_call_timeout=60,
|
||||
computer_use_runtime="sandbox",
|
||||
sandbox_cfg={
|
||||
"booter": "shipyard",
|
||||
"shipyard_endpoint": "",
|
||||
"shipyard_access_token": "test-token",
|
||||
},
|
||||
sandbox_cfg={"booter": "shipyard_neo"},
|
||||
)
|
||||
req = ProviderRequest(prompt="Test", func_tool=None)
|
||||
|
||||
with patch("astrbot.core.astr_main_agent.logger") as mock_logger:
|
||||
with patch(
|
||||
"astrbot.core.computer.computer_client.get_sandbox_tools",
|
||||
return_value=[],
|
||||
):
|
||||
module._apply_sandbox_tools(config, req, "session-123")
|
||||
|
||||
mock_logger.error.assert_called_once()
|
||||
assert (
|
||||
"Shipyard sandbox configuration is incomplete"
|
||||
in mock_logger.error.call_args[0][0]
|
||||
)
|
||||
|
||||
def test_apply_sandbox_tools_shipyard_missing_access_token(self):
|
||||
"""Test that shipyard config is skipped when access token is missing."""
|
||||
module = ama
|
||||
config = module.MainAgentBuildConfig(
|
||||
tool_call_timeout=60,
|
||||
computer_use_runtime="sandbox",
|
||||
sandbox_cfg={
|
||||
"booter": "shipyard",
|
||||
"shipyard_endpoint": "https://shipyard.example.com",
|
||||
"shipyard_access_token": "",
|
||||
},
|
||||
)
|
||||
req = ProviderRequest(prompt="Test", func_tool=None)
|
||||
|
||||
with patch("astrbot.core.astr_main_agent.logger") as mock_logger:
|
||||
module._apply_sandbox_tools(config, req, "session-123")
|
||||
|
||||
mock_logger.error.assert_called_once()
|
||||
names = req.func_tool.names()
|
||||
assert "astrbot_create_skill_candidate" in names
|
||||
assert "astrbot_execute_browser" in names
|
||||
assert len(names) == 18
|
||||
|
||||
def test_apply_sandbox_tools_preserves_existing_toolset(self):
|
||||
"""Test that existing tools are preserved when adding sandbox tools."""
|
||||
|
||||
Reference in New Issue
Block a user