refactor: delegate tool injection to booter self-description API

- Add get_default_tools/get_tools/get_system_prompt_parts to ComputerBooter base
- Each booter subclass (ShipyardNeo, Shipyard, Boxlite) declares its own tools
- ComputerToolProvider now delegates to booter API via computer_client helpers
- Add unified query API: get_sandbox_tools, get_default_sandbox_tools, etc.
- Extract Neo prompts to dedicated computer/prompts.py module
- Add booter type constants (booters/constants.py)
- Fix subagent tool path to pass sandbox_cfg and session_id
- Fix Sourcery issues: shell injection in send_message, typo in prompts,
  internal tools bypass inactivated_llm_tools check
This commit is contained in:
zenfun
2026-03-12 02:34:47 +08:00
parent 438fc105cd
commit f16edd4fff
12 changed files with 312 additions and 135 deletions
+17 -3
View File
@@ -174,12 +174,21 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
return
@classmethod
def _get_runtime_computer_tools(cls, runtime: str) -> dict[str, FunctionTool]:
def _get_runtime_computer_tools(
cls,
runtime: str,
sandbox_cfg: dict | None = None,
session_id: str = "",
) -> dict[str, FunctionTool]:
from astrbot.core.computer.computer_tool_provider import ComputerToolProvider
from astrbot.core.tool_provider import ToolProviderContext
provider = ComputerToolProvider()
ctx = ToolProviderContext(computer_use_runtime=runtime)
ctx = ToolProviderContext(
computer_use_runtime=runtime,
sandbox_cfg=sandbox_cfg,
session_id=session_id,
)
tools = provider.get_tools(ctx)
return {tool.name: tool for tool in tools}
@@ -194,7 +203,12 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
cfg = ctx.get_config(umo=event.unified_msg_origin)
provider_settings = cfg.get("provider_settings", {})
runtime = str(provider_settings.get("computer_use_runtime", "local"))
runtime_computer_tools = cls._get_runtime_computer_tools(runtime)
sandbox_cfg = provider_settings.get("sandbox", {})
runtime_computer_tools = cls._get_runtime_computer_tools(
runtime,
sandbox_cfg=sandbox_cfg,
session_id=event.unified_msg_origin,
)
# Keep persona semantics aligned with the main agent: tools=None means
# "all tools", including runtime computer-use tools.
+5 -2
View File
@@ -1033,7 +1033,9 @@ async def build_main_agent(
sandbox_cfg=config.sandbox_cfg,
session_id=req.session_id or "",
)
# Respect WebUI tool enable/disable settings
# Respect WebUI tool enable/disable settings.
# Internal tools (source='internal') bypass this check — they are
# not user-togglable in the WebUI, so legacy entries must not block them.
_inactivated: set[str] = set(
sp.get("inactivated_llm_tools", [], scope="global", scope_id="global")
)
@@ -1043,7 +1045,8 @@ async def build_main_agent(
if req.func_tool is None:
req.func_tool = ToolSet()
for _tool in _tp_tools:
if _tool.name not in _inactivated:
is_internal = getattr(_tool, "source", "") == "internal"
if is_internal or _tool.name not in _inactivated:
req.func_tool.add_tool(_tool)
_tp_addon = _tp.get_system_prompt_addon(_provider_ctx)
if _tp_addon:
+22
View File
@@ -1,3 +1,7 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from ..olayer import (
BrowserComponent,
FileSystemComponent,
@@ -5,6 +9,9 @@ from ..olayer import (
ShellComponent,
)
if TYPE_CHECKING:
from astrbot.core.agent.tool import FunctionTool
class ComputerBooter:
@property
@@ -47,3 +54,18 @@ class ComputerBooter:
async def available(self) -> bool:
"""Check if the computer is available."""
...
@classmethod
def get_default_tools(cls) -> list[FunctionTool]:
"""Conservative full tool list (no instance needed, pre-boot)."""
return []
def get_tools(self) -> list[FunctionTool]:
"""Capability-filtered tool list (post-boot).
Defaults to get_default_tools()."""
return self.__class__.get_default_tools()
@classmethod
def get_system_prompt_parts(cls) -> list[str]:
"""Booter-specific system prompt fragments (static text, no instance needed)."""
return []
+28 -1
View File
@@ -1,6 +1,9 @@
from __future__ import annotations
import asyncio
import functools
import random
from typing import Any
from typing import TYPE_CHECKING, Any
import aiohttp
import boxlite
@@ -10,6 +13,9 @@ from shipyard.shell import ShellComponent as ShipyardShellComponent
from astrbot.api import logger
if TYPE_CHECKING:
from astrbot.core.agent.tool import FunctionTool
from ..olayer import FileSystemComponent, PythonComponent, ShellComponent
from .base import ComputerBooter
@@ -188,3 +194,24 @@ class BoxliteBooter(ComputerBooter):
async def upload_file(self, path: str, file_name: str) -> dict:
"""Upload file to sandbox"""
return await self.mocked.upload_file(path, file_name)
@classmethod
@functools.cache
def _default_tools(cls) -> tuple[FunctionTool, ...]:
from astrbot.core.computer.tools import (
ExecuteShellTool,
FileDownloadTool,
FileUploadTool,
PythonTool,
)
return (
ExecuteShellTool(),
PythonTool(),
FileUploadTool(),
FileDownloadTool(),
)
@classmethod
def get_default_tools(cls) -> list[FunctionTool]:
return list(cls._default_tools())
@@ -0,0 +1,3 @@
BOOTER_SHIPYARD = "shipyard"
BOOTER_SHIPYARD_NEO = "shipyard_neo"
BOOTER_BOXLITE = "boxlite"
+29
View File
@@ -1,12 +1,41 @@
from __future__ import annotations
import functools
from typing import TYPE_CHECKING
from shipyard import ShipyardClient, Spec
from astrbot.api import logger
if TYPE_CHECKING:
from astrbot.core.agent.tool import FunctionTool
from ..olayer import FileSystemComponent, PythonComponent, ShellComponent
from .base import ComputerBooter
class ShipyardBooter(ComputerBooter):
@classmethod
@functools.cache
def _default_tools(cls) -> tuple[FunctionTool, ...]:
from astrbot.core.computer.tools import (
ExecuteShellTool,
FileDownloadTool,
FileUploadTool,
PythonTool,
)
return (
ExecuteShellTool(),
PythonTool(),
FileUploadTool(),
FileDownloadTool(),
)
@classmethod
def get_default_tools(cls) -> list[FunctionTool]:
return list(cls._default_tools())
def __init__(
self,
endpoint_url: str,
+82 -1
View File
@@ -1,11 +1,15 @@
from __future__ import annotations
import functools
import os
import shlex
from typing import Any, cast
from typing import TYPE_CHECKING, Any, cast
from astrbot.api import logger
if TYPE_CHECKING:
from astrbot.core.agent.tool import FunctionTool
from ..olayer import (
BrowserComponent,
FileSystemComponent,
@@ -511,3 +515,80 @@ class ShipyardNeoBooter(ComputerBooter):
except Exception as e:
logger.error(f"Error checking Shipyard Neo sandbox availability: {e}")
return False
# ── Tool / prompt self-description ────────────────────────────
@classmethod
@functools.cache
def _base_tools(cls) -> tuple[FunctionTool, ...]:
"""4 base + 11 Neo lifecycle = 15 tools (all Neo profiles)."""
from astrbot.core.computer.tools import (
AnnotateExecutionTool,
CreateSkillCandidateTool,
CreateSkillPayloadTool,
EvaluateSkillCandidateTool,
ExecuteShellTool,
FileDownloadTool,
FileUploadTool,
GetExecutionHistoryTool,
GetSkillPayloadTool,
ListSkillCandidatesTool,
ListSkillReleasesTool,
PromoteSkillCandidateTool,
PythonTool,
RollbackSkillReleaseTool,
SyncSkillReleaseTool,
)
return (
ExecuteShellTool(),
PythonTool(),
FileUploadTool(),
FileDownloadTool(),
GetExecutionHistoryTool(),
AnnotateExecutionTool(),
CreateSkillPayloadTool(),
GetSkillPayloadTool(),
CreateSkillCandidateTool(),
ListSkillCandidatesTool(),
EvaluateSkillCandidateTool(),
PromoteSkillCandidateTool(),
ListSkillReleasesTool(),
RollbackSkillReleaseTool(),
SyncSkillReleaseTool(),
)
@classmethod
@functools.cache
def _browser_tools(cls) -> tuple[FunctionTool, ...]:
from astrbot.core.computer.tools import (
BrowserBatchExecTool,
BrowserExecTool,
RunBrowserSkillTool,
)
return (BrowserExecTool(), BrowserBatchExecTool(), RunBrowserSkillTool())
@classmethod
def get_default_tools(cls) -> list[FunctionTool]:
"""Pre-boot: conservative full list (including browser)."""
return list(cls._base_tools()) + list(cls._browser_tools())
def get_tools(self) -> list[FunctionTool]:
"""Post-boot: capability-filtered list."""
caps = self.capabilities
if caps is None:
return self.__class__.get_default_tools()
tools = list(self._base_tools())
if "browser" in caps:
tools.extend(self._browser_tools())
return tools
@classmethod
def get_system_prompt_parts(cls) -> list[str]:
from astrbot.core.computer.prompts import (
NEO_FILE_PATH_PROMPT,
NEO_SKILL_LIFECYCLE_PROMPT,
)
return [NEO_FILE_PATH_PROMPT, NEO_SKILL_LIFECYCLE_PROMPT]
+60
View File
@@ -1,8 +1,11 @@
from __future__ import annotations
import json
import os
import shutil
import uuid
from pathlib import Path
from typing import TYPE_CHECKING
from astrbot.api import logger
from astrbot.core.skills.skill_manager import SANDBOX_SKILLS_ROOT, SkillManager
@@ -13,8 +16,12 @@ from astrbot.core.utils.astrbot_path import (
)
from .booters.base import ComputerBooter
from .booters.constants import BOOTER_BOXLITE, BOOTER_SHIPYARD, BOOTER_SHIPYARD_NEO
from .booters.local import LocalBooter
if TYPE_CHECKING:
from astrbot.core.agent.tool import FunctionTool
session_booter: dict[str, ComputerBooter] = {}
local_booter: ComputerBooter | None = None
_MANAGED_SKILLS_FILE = ".astrbot_managed_skills.json"
@@ -511,3 +518,56 @@ def get_local_booter() -> ComputerBooter:
if local_booter is None:
local_booter = LocalBooter()
return local_booter
# ---------------------------------------------------------------------------
# Unified query API — used by ComputerToolProvider and subagent tool exec
# ---------------------------------------------------------------------------
def _get_booter_class(booter_type: str) -> type[ComputerBooter] | None:
"""Map booter_type string to class (lazy 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
logger.warning("[Computer] booter_class_lookup booter=%s found=false", booter_type)
return None
def get_sandbox_tools(session_id: str) -> list[FunctionTool]:
"""Return precise tool list from a booted session, or [] if not booted."""
booter = session_booter.get(session_id)
if booter is None:
return []
return booter.get_tools()
def get_sandbox_capabilities(session_id: str) -> tuple[str, ...] | None:
"""Return capability tuple from a booted session, or None if unavailable."""
booter = session_booter.get(session_id)
if booter is None:
return None
return getattr(booter, "capabilities", None)
def get_default_sandbox_tools(sandbox_cfg: dict) -> list[FunctionTool]:
"""Return conservative (pre-boot) tool list based on config. No instance needed."""
booter_type = sandbox_cfg.get("booter", BOOTER_SHIPYARD_NEO)
cls = _get_booter_class(booter_type)
return cls.get_default_tools() if cls else []
def get_sandbox_prompt_parts(sandbox_cfg: dict) -> list[str]:
"""Return booter-specific system prompt fragments based on config."""
booter_type = sandbox_cfg.get("booter", BOOTER_SHIPYARD_NEO)
cls = _get_booter_class(booter_type)
return cls.get_system_prompt_parts() if cls else []
+38 -126
View File
@@ -4,47 +4,31 @@ Encapsulates all sandbox / local tool injection logic previously hardcoded in
``astr_main_agent.py``. The main agent now calls
``provider.get_tools(ctx)`` / ``provider.get_system_prompt_addon(ctx)``
without knowing about specific tool classes.
Tool lists are delegated to booter subclasses via ``get_default_tools()``
and ``get_tools()`` (see ``booters/base.py``), so adding a new booter type
does not require changes here.
"""
from __future__ import annotations
import os
import platform
from functools import cache
from typing import TYPE_CHECKING
from astrbot.api import logger
from astrbot.core.agent.tool import FunctionTool
from astrbot.core.tool_provider import ToolProviderContext
if TYPE_CHECKING:
from astrbot.core.agent.tool import FunctionTool
# ---------------------------------------------------------------------------
# Lazy tool singletons — created once on first use, cached at module level.
# This mirrors the previous behaviour in astr_main_agent_resources.py
# but keeps everything co-located with the provider.
# Lazy local-mode tool cache
# ---------------------------------------------------------------------------
_SANDBOX_TOOLS_CACHE: list[FunctionTool] | None = None
_LOCAL_TOOLS_CACHE: list[FunctionTool] | None = None
_NEO_TOOLS_CACHE: list[FunctionTool] | None = None
_BROWSER_TOOLS_CACHE: list[FunctionTool] | None = None
def _get_sandbox_base_tools() -> list[FunctionTool]:
global _SANDBOX_TOOLS_CACHE
if _SANDBOX_TOOLS_CACHE is None:
from astrbot.core.computer.tools import (
ExecuteShellTool,
FileDownloadTool,
FileUploadTool,
PythonTool,
)
_SANDBOX_TOOLS_CACHE = [
ExecuteShellTool(),
PythonTool(),
FileUploadTool(),
FileDownloadTool(),
]
return list(_SANDBOX_TOOLS_CACHE)
def _get_local_tools() -> list[FunctionTool]:
@@ -59,58 +43,8 @@ def _get_local_tools() -> list[FunctionTool]:
return list(_LOCAL_TOOLS_CACHE)
def _get_neo_skill_tools() -> list[FunctionTool]:
global _NEO_TOOLS_CACHE
if _NEO_TOOLS_CACHE is None:
from astrbot.core.computer.tools import (
AnnotateExecutionTool,
CreateSkillCandidateTool,
CreateSkillPayloadTool,
EvaluateSkillCandidateTool,
GetExecutionHistoryTool,
GetSkillPayloadTool,
ListSkillCandidatesTool,
ListSkillReleasesTool,
PromoteSkillCandidateTool,
RollbackSkillReleaseTool,
SyncSkillReleaseTool,
)
_NEO_TOOLS_CACHE = [
GetExecutionHistoryTool(),
AnnotateExecutionTool(),
CreateSkillPayloadTool(),
GetSkillPayloadTool(),
CreateSkillCandidateTool(),
ListSkillCandidatesTool(),
EvaluateSkillCandidateTool(),
PromoteSkillCandidateTool(),
ListSkillReleasesTool(),
RollbackSkillReleaseTool(),
SyncSkillReleaseTool(),
]
return list(_NEO_TOOLS_CACHE)
def _get_browser_tools() -> list[FunctionTool]:
global _BROWSER_TOOLS_CACHE
if _BROWSER_TOOLS_CACHE is None:
from astrbot.core.computer.tools import (
BrowserBatchExecTool,
BrowserExecTool,
RunBrowserSkillTool,
)
_BROWSER_TOOLS_CACHE = [
BrowserExecTool(),
BrowserBatchExecTool(),
RunBrowserSkillTool(),
]
return list(_BROWSER_TOOLS_CACHE)
# ---------------------------------------------------------------------------
# System-prompt constants (moved from astr_main_agent_resources.py)
# System-prompt helpers
# ---------------------------------------------------------------------------
SANDBOX_MODE_PROMPT = (
@@ -118,25 +52,6 @@ SANDBOX_MODE_PROMPT = (
"shell commands and Python code securely."
)
_NEO_PATH_RULE_PROMPT = (
"\n[Shipyard Neo File Path Rule]\n"
"When using sandbox filesystem tools (upload/download/read/write/list/delete), "
"always pass paths relative to the sandbox workspace root. "
"Example: use `baidu_homepage.png` instead of `/workspace/baidu_homepage.png`.\n"
)
_NEO_SKILL_LIFECYCLE_PROMPT = (
"\n[Neo Skill Lifecycle Workflow]\n"
"When user asks to create/update a reusable skill in Neo mode, use lifecycle tools instead of directly writing local skill folders.\n"
"Preferred sequence:\n"
"1) Use `astrbot_create_skill_payload` to store canonical payload content and get `payload_ref`.\n"
"2) Use `astrbot_create_skill_candidate` with `skill_key` + `source_execution_ids` (and optional `payload_ref`) to create a candidate.\n"
"3) Use `astrbot_promote_skill_candidate` to release: `stage=canary` for trial; `stage=stable` for production.\n"
"For stable release, set `sync_to_local=true` to sync `payload.skill_markdown` into local `SKILL.md`.\n"
"Do not treat ad-hoc generated files as reusable Neo skills unless they are captured via payload/candidate/release.\n"
"To update an existing skill, create a new payload/candidate and promote a new release version; avoid patching old local folders directly.\n"
)
def _build_local_mode_prompt() -> str:
system_name = platform.system() or "Unknown"
@@ -159,7 +74,13 @@ def _build_local_mode_prompt() -> str:
class ComputerToolProvider:
"""Provides computer-use tools (local / sandbox) based on session context."""
"""Provides computer-use tools (local / sandbox) based on session context.
Sandbox tool lists are delegated to booter subclasses so that each booter
declares its own capabilities. ``get_tools`` prefers the precise
post-boot tool list from a running session; when the sandbox has not yet
been booted it falls back to the conservative pre-boot default.
"""
@staticmethod
def get_all_tools() -> list[FunctionTool]:
@@ -258,7 +179,18 @@ class ComputerToolProvider:
# -- sandbox helpers ----------------------------------------------------
def _sandbox_tools(self, ctx: ToolProviderContext) -> list[FunctionTool]:
"""Collect tools for sandbox mode."""
"""Collect tools for sandbox mode.
Prefers the precise post-boot tool list from the running session's
booter instance (``get_sandbox_tools``). When the sandbox has not
yet been booted, falls back to the conservative pre-boot default
declared by the booter class (``get_default_sandbox_tools``).
"""
from astrbot.core.computer.computer_client import (
get_default_sandbox_tools,
get_sandbox_tools,
)
booter_type = ctx.sandbox_cfg.get("booter", "shipyard_neo")
# Validate shipyard (non-neo) config
@@ -271,42 +203,22 @@ class ComputerToolProvider:
os.environ["SHIPYARD_ENDPOINT"] = ep
os.environ["SHIPYARD_ACCESS_TOKEN"] = at
tools = _get_sandbox_base_tools()
# Prefer precise post-boot tools from the running session
booted_tools = get_sandbox_tools(ctx.session_id)
if booted_tools:
return booted_tools
if booter_type == "shipyard_neo":
sandbox_capabilities = self._get_sandbox_capabilities(ctx.session_id)
# Browser tools if capability present (or unknown)
if sandbox_capabilities is None or "browser" in sandbox_capabilities:
tools.extend(_get_browser_tools())
# Neo skill lifecycle tools
tools.extend(_get_neo_skill_tools())
return tools
# Pre-boot: conservative default from booter class
return get_default_sandbox_tools(ctx.sandbox_cfg)
def _sandbox_prompt_addon(self, ctx: ToolProviderContext) -> str:
"""Build system-prompt addon for sandbox mode."""
parts: list[str] = []
booter_type = ctx.sandbox_cfg.get("booter", "shipyard_neo")
if booter_type == "shipyard_neo":
parts.append(_NEO_PATH_RULE_PROMPT)
parts.append(_NEO_SKILL_LIFECYCLE_PROMPT)
from astrbot.core.computer.computer_client import get_sandbox_prompt_parts
parts = get_sandbox_prompt_parts(ctx.sandbox_cfg)
parts.append(f"\n{SANDBOX_MODE_PROMPT}\n")
return "".join(parts)
@staticmethod
def _get_sandbox_capabilities(session_id: str) -> tuple[str, ...] | None:
"""Query capabilities for an already-booted sandbox session."""
from astrbot.core.computer.computer_client import session_booter
existing_booter = session_booter.get(session_id)
if existing_booter is not None:
return getattr(existing_booter, "capabilities", None)
return None
def get_all_tools() -> list[FunctionTool]:
"""Module-level entry point for ``FunctionToolManager.register_internal_tools()``.
+24
View File
@@ -0,0 +1,24 @@
"""Booter-specific system prompt fragments.
Kept separate from ``tools/prompts.py`` (which holds agent-level prompts)
so that booter subclasses can import without pulling in unrelated constants.
"""
NEO_FILE_PATH_PROMPT = (
"\n[Shipyard Neo File Path Rule]\n"
"When using sandbox filesystem tools (upload/download/read/write/list/delete), "
"always pass paths relative to the sandbox workspace root. "
"Example: use `baidu_homepage.png` instead of `/workspace/baidu_homepage.png`.\n"
)
NEO_SKILL_LIFECYCLE_PROMPT = (
"\n[Neo Skill Lifecycle Workflow]\n"
"When user asks to create/update a reusable skill in Neo mode, use lifecycle tools instead of directly writing local skill folders.\n"
"Preferred sequence:\n"
"1) Use `astrbot_create_skill_payload` to store canonical payload content and get `payload_ref`.\n"
"2) Use `astrbot_create_skill_candidate` with `skill_key` + `source_execution_ids` (and optional `payload_ref`) to create a candidate.\n"
"3) Use `astrbot_promote_skill_candidate` to release: `stage=canary` for trial; `stage=stable` for production.\n"
"For stable release, set `sync_to_local=true` to sync `payload.skill_markdown` into local `SKILL.md`.\n"
"Do not treat ad-hoc generated files as reusable Neo skills unless they are captured via payload/candidate/release.\n"
"To update an existing skill, create a new payload/candidate and promote a new release version; avoid patching old local folders directly.\n"
)
+1 -1
View File
@@ -132,7 +132,7 @@ FILE_EXTRACT_CONTEXT_TEMPLATE = (
)
CONVERSATION_HISTORY_INJECT_PREFIX = (
"\n\nBellow is you and user previous conversation history:\n"
"\n\nBelow is your and the user's previous conversation history:\n"
)
BACKGROUND_TASK_WOKE_USER_PROMPT = (
+3 -1
View File
@@ -89,7 +89,9 @@ class SendMessageToUserTool(FunctionTool[AstrAgentContext]):
context.context.event.unified_msg_origin,
)
# Use shell to check if the file exists in sandbox
result = await sb.shell.exec(f"test -f {path} && echo '_&exists_'")
import shlex
result = await sb.shell.exec(f"test -f {shlex.quote(path)} && echo '_&exists_'")
if "_&exists_" in json.dumps(result):
# Download the file from sandbox
name = os.path.basename(path)