Files
AstrBot/astrbot/core/computer/computer_tool_provider.py
T

318 lines
11 KiB
Python

"""ComputerToolProvider — decoupled tool injection for computer-use runtimes.
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.
"""
from __future__ import annotations
import os
import platform
from astrbot.api import logger
from astrbot.core.agent.tool import FunctionTool
from astrbot.core.tool_provider import ToolProviderContext
# ---------------------------------------------------------------------------
# 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.
# ---------------------------------------------------------------------------
_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]:
global _LOCAL_TOOLS_CACHE
if _LOCAL_TOOLS_CACHE is None:
from astrbot.core.computer.tools import ExecuteShellTool, LocalPythonTool
_LOCAL_TOOLS_CACHE = [
ExecuteShellTool(is_local=True),
LocalPythonTool(),
]
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)
# ---------------------------------------------------------------------------
SANDBOX_MODE_PROMPT = (
"You have access to a sandboxed environment and can execute "
"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"
shell_hint = (
"The runtime shell is Windows Command Prompt (cmd.exe). "
"Use cmd-compatible commands and do not assume Unix commands like cat/ls/grep are available."
if system_name.lower() == "windows"
else "The runtime shell is Unix-like. Use POSIX-compatible shell commands."
)
return (
"You have access to the host local environment and can execute shell commands and Python code. "
f"Current operating system: {system_name}. "
f"{shell_hint}"
)
# ---------------------------------------------------------------------------
# ComputerToolProvider
# ---------------------------------------------------------------------------
class ComputerToolProvider:
"""Provides computer-use tools (local / sandbox) based on session context."""
@staticmethod
def get_all_tools() -> list[FunctionTool]:
"""Return ALL computer-use tools across all runtimes for registration.
Creates **fresh instances** separate from the runtime caches so that
setting ``active=False`` on them does not affect runtime behaviour.
These registration-only instances let the WebUI display and assign
tools without injecting them into actual LLM requests.
At request time, ``get_tools(ctx)`` provides the real, active
instances filtered by runtime.
"""
from astrbot.core.computer.tools import (
AnnotateExecutionTool,
BrowserBatchExecTool,
BrowserExecTool,
CreateSkillCandidateTool,
CreateSkillPayloadTool,
EvaluateSkillCandidateTool,
ExecuteShellTool,
FileDownloadTool,
FileUploadTool,
GetExecutionHistoryTool,
GetSkillPayloadTool,
ListSkillCandidatesTool,
ListSkillReleasesTool,
LocalPythonTool,
PromoteSkillCandidateTool,
PythonTool,
RollbackSkillReleaseTool,
RunBrowserSkillTool,
SyncSkillReleaseTool,
)
all_tools: list[FunctionTool] = [
ExecuteShellTool(),
PythonTool(),
FileUploadTool(),
FileDownloadTool(),
LocalPythonTool(),
BrowserExecTool(),
BrowserBatchExecTool(),
RunBrowserSkillTool(),
GetExecutionHistoryTool(),
AnnotateExecutionTool(),
CreateSkillPayloadTool(),
GetSkillPayloadTool(),
CreateSkillCandidateTool(),
ListSkillCandidatesTool(),
EvaluateSkillCandidateTool(),
PromoteSkillCandidateTool(),
ListSkillReleasesTool(),
RollbackSkillReleaseTool(),
SyncSkillReleaseTool(),
]
# De-duplicate by name and mark inactive so they are visible
# in WebUI but never sent to the LLM via func_list.
seen: set[str] = set()
result: list[FunctionTool] = []
for tool in all_tools:
if tool.name not in seen:
tool.active = False
result.append(tool)
seen.add(tool.name)
return result
def get_tools(self, ctx: ToolProviderContext) -> list[FunctionTool]:
runtime = ctx.computer_use_runtime
if runtime == "none":
return []
if runtime == "local":
return _get_local_tools()
if runtime == "sandbox":
return self._sandbox_tools(ctx)
logger.warning("[ComputerToolProvider] Unknown runtime: %s", runtime)
return []
def get_system_prompt_addon(self, ctx: ToolProviderContext) -> str:
runtime = ctx.computer_use_runtime
if runtime == "none":
return ""
if runtime == "local":
return f"\n{_build_local_mode_prompt()}\n"
if runtime == "sandbox":
return self._sandbox_prompt_addon(ctx)
return ""
# -- sandbox helpers ----------------------------------------------------
def _sandbox_tools(self, ctx: ToolProviderContext) -> list[FunctionTool]:
"""Collect tools for sandbox mode."""
booter_type = ctx.sandbox_cfg.get("booter", "shipyard_neo")
# Validate shipyard (non-neo) config
if booter_type == "shipyard":
ep = ctx.sandbox_cfg.get("shipyard_endpoint", "")
at = ctx.sandbox_cfg.get("shipyard_access_token", "")
if not ep or not at:
logger.error("Shipyard sandbox configuration is incomplete.")
return []
os.environ["SHIPYARD_ENDPOINT"] = ep
os.environ["SHIPYARD_ACCESS_TOKEN"] = at
tools = _get_sandbox_base_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
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)
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()``.
Delegates to ``ComputerToolProvider.get_all_tools()`` which collects
tools from all runtimes (local, sandbox, browser, neo).
"""
return ComputerToolProvider.get_all_tools()