From 3440dcd14baca41bbace80dc3287d6ac8fea190c Mon Sep 17 00:00:00 2001 From: zenfun Date: Wed, 11 Mar 2026 02:44:29 +0800 Subject: [PATCH] test: add booter decoupling and profile-aware tool tests --- .gitignore | 1 + docs/refactor-neo-decouple.md | 560 +++++++++++++++++++++++++++++ tests/test_booter_decoupling.py | 412 +++++++++++++++++++++ tests/test_computer_config.py | 43 ++- tests/test_profile_aware_tools.py | 85 ++++- tests/unit/test_astr_main_agent.py | 56 +-- 6 files changed, 1077 insertions(+), 80 deletions(-) create mode 100644 docs/refactor-neo-decouple.md create mode 100644 tests/test_booter_decoupling.py diff --git a/.gitignore b/.gitignore index 486b9628d..953723dff 100644 --- a/.gitignore +++ b/.gitignore @@ -61,3 +61,4 @@ GenieData/ .codex/ .opencode/ .kilocode/ +.serena \ No newline at end of file diff --git a/docs/refactor-neo-decouple.md b/docs/refactor-neo-decouple.md new file mode 100644 index 000000000..68f88c842 --- /dev/null +++ b/docs/refactor-neo-decouple.md @@ -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 是**切换调用路径**,需要一起提交并完整回归测试。 diff --git a/tests/test_booter_decoupling.py b/tests/test_booter_decoupling.py new file mode 100644 index 000000000..473c04a05 --- /dev/null +++ b/tests/test_booter_decoupling.py @@ -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 diff --git a/tests/test_computer_config.py b/tests/test_computer_config.py index 26f72991c..bce95bc6b 100644 --- a/tests/test_computer_config.py +++ b/tests/test_computer_config.py @@ -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) diff --git a/tests/test_profile_aware_tools.py b/tests/test_profile_aware_tools.py index e8c295438..455bc731d 100644 --- a/tests/test_profile_aware_tools.py +++ b/tests/test_profile_aware_tools.py @@ -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") diff --git a/tests/unit/test_astr_main_agent.py b/tests/unit/test_astr_main_agent.py index e0682ae06..5d9de693c 100644 --- a/tests/unit/test_astr_main_agent.py +++ b/tests/unit/test_astr_main_agent.py @@ -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."""