From f60ffdb62a6d8254f3be331f0c111998667d0563 Mon Sep 17 00:00:00 2001 From: LIghtJUNction Date: Mon, 23 Mar 2026 20:38:43 +0800 Subject: [PATCH] refactor(dashboard): remove unused sha256 utility The sha256.ts module is no longer used by any frontend code. --- astrbot/_internal/agents/__init__.py | 12 - .../agents/openai_agents/__init__.py | 21 - .../_internal/agents/openai_agents/runner.py | 180 --- .../agents/openai_agents/tool_adapter.py | 59 - astrbot/_internal/mcp/__init__.py | 63 - astrbot/_internal/mcp/client.py | 411 ------- astrbot/_internal/mcp/config.py | 55 - astrbot/_internal/mcp/tool.py | 40 - astrbot/_internal/skills/__init__.py | 37 - astrbot/_internal/skills/loader.py | 79 -- astrbot/_internal/skills/manager.py | 617 ---------- astrbot/_internal/skills/parser.py | 81 -- astrbot/_internal/skills/prompt_builder.py | 158 --- astrbot/_internal/skills/to_tool.py | 79 -- astrbot/_internal/tools/__init__.py | 9 - astrbot/_internal/tools/base.py | 442 ------- astrbot/_internal/tools/builtin/__init__.py | 52 - astrbot/_internal/tools/builtin/cron.py | 201 ---- astrbot/_internal/tools/builtin/kb_query.py | 139 --- .../_internal/tools/builtin/send_message.py | 226 ---- astrbot/_internal/tools/providers/__init__.py | 25 - astrbot/_internal/tools/providers/computer.py | 45 - astrbot/_internal/tools/providers/internal.py | 73 -- astrbot/_internal/tools/providers/plugin.py | 73 -- astrbot/_internal/tools/registry.py | 821 ------------- astrbot/api/__init__.py | 8 +- astrbot/api/mcp.py | 14 +- astrbot/api/skills.py | 8 +- astrbot/api/tools.py | 18 +- astrbot/cli/__main__.py | 3 +- astrbot/cli/commands/__init__.py | 3 +- astrbot/core/agent/mcp_client.py | 22 +- .../agent/runners/tool_loop_agent_runner.py | 4 +- astrbot/core/agent/tool.py | 2 +- astrbot/core/astr_main_agent_resources.py | 2 +- astrbot/core/computer/booters/boxlite.py | 13 +- astrbot/core/db/po.py | 4 +- astrbot/core/db/sqlite.py | 2 +- .../db/vec_db/faiss_impl/document_storage.py | 14 +- astrbot/core/knowledge_base/models.py | 6 +- astrbot/core/persona_mgr.py | 2 +- .../method/agent_sub_stages/third_party.py | 2 +- .../sources/misskey/misskey_adapter.py | 4 +- .../qqofficial/qqofficial_message_event.py | 6 +- .../qqofficial/qqofficial_platform_adapter.py | 18 +- .../platform/sources/satori/satori_adapter.py | 2 +- .../sources/wecom_ai_bot/WXBizJsonMsgCrypt.py | 6 +- astrbot/core/provider/func_tool_manager.py | 1016 +---------------- astrbot/core/provider/sources/genie_tts.py | 2 +- astrbot/core/utils/file_extract.py | 2 +- astrbot/core/utils/shared_preferences.py | 22 +- .../MessagePartsRenderer.vue | 19 +- .../components/extension/MarketPluginCard.vue | 7 +- .../components/extension/PinnedPluginItem.vue | 98 +- .../src/components/folder/BaseFolderCard.vue | 129 ++- .../folder/BaseFolderItemSelector.vue | 83 +- .../src/components/shared/ExtensionCard.vue | 67 +- .../src/components/shared/QrCodeViewer.vue | 7 +- .../components/shared/StorageCleanupPanel.vue | 218 ++-- dashboard/src/utils/sha256.ts | 135 --- dashboard/src/views/ExtensionPage.vue | 1 - dashboard/src/views/PlatformPage.vue | 48 +- dashboard/src/views/Settings.vue | 517 +++++---- dashboard/src/views/WelcomePage.vue | 64 +- .../views/authentication/auth/LoginPage.vue | 55 +- .../authentication/authForms/AuthLogin.vue | 5 +- .../views/extension/InstalledPluginsTab.vue | 687 ++++++----- .../src/views/extension/MarketPluginsTab.vue | 243 ++-- .../src/views/persona/FolderBreadcrumb.vue | 16 +- dashboard/src/views/persona/FolderCard.vue | 34 +- dashboard/src/views/persona/FolderTree.vue | 52 +- pyproject.toml | 5 +- 72 files changed, 1565 insertions(+), 6128 deletions(-) delete mode 100644 astrbot/_internal/agents/__init__.py delete mode 100644 astrbot/_internal/agents/openai_agents/__init__.py delete mode 100644 astrbot/_internal/agents/openai_agents/runner.py delete mode 100644 astrbot/_internal/agents/openai_agents/tool_adapter.py delete mode 100644 astrbot/_internal/mcp/__init__.py delete mode 100644 astrbot/_internal/mcp/client.py delete mode 100644 astrbot/_internal/mcp/config.py delete mode 100644 astrbot/_internal/mcp/tool.py delete mode 100644 astrbot/_internal/skills/__init__.py delete mode 100644 astrbot/_internal/skills/loader.py delete mode 100644 astrbot/_internal/skills/manager.py delete mode 100644 astrbot/_internal/skills/parser.py delete mode 100644 astrbot/_internal/skills/prompt_builder.py delete mode 100644 astrbot/_internal/skills/to_tool.py delete mode 100644 astrbot/_internal/tools/__init__.py delete mode 100644 astrbot/_internal/tools/base.py delete mode 100644 astrbot/_internal/tools/builtin/__init__.py delete mode 100644 astrbot/_internal/tools/builtin/cron.py delete mode 100644 astrbot/_internal/tools/builtin/kb_query.py delete mode 100644 astrbot/_internal/tools/builtin/send_message.py delete mode 100644 astrbot/_internal/tools/providers/__init__.py delete mode 100644 astrbot/_internal/tools/providers/computer.py delete mode 100644 astrbot/_internal/tools/providers/internal.py delete mode 100644 astrbot/_internal/tools/providers/plugin.py delete mode 100644 astrbot/_internal/tools/registry.py delete mode 100644 dashboard/src/utils/sha256.ts diff --git a/astrbot/_internal/agents/__init__.py b/astrbot/_internal/agents/__init__.py deleted file mode 100644 index 8891441a3..000000000 --- a/astrbot/_internal/agents/__init__.py +++ /dev/null @@ -1,12 +0,0 @@ -"""AstrBot internal agents module. - -This module contains internal agent implementations. -For public API, use astrbot.api. -""" - -from .openai_agents import OpenAIAgentsRunner, astrbot_tool_to_agents_tool - -__all__ = [ - "OpenAIAgentsRunner", - "astrbot_tool_to_agents_tool", -] diff --git a/astrbot/_internal/agents/openai_agents/__init__.py b/astrbot/_internal/agents/openai_agents/__init__.py deleted file mode 100644 index 25c6918bb..000000000 --- a/astrbot/_internal/agents/openai_agents/__init__.py +++ /dev/null @@ -1,21 +0,0 @@ -"""OpenAI Agents SDK integration for AstrBot. - -This module provides integration with the openai-agents library, -allowing AstrBot to leverage the openai-agents Agent implementation -while using its existing tool and provider infrastructure. - -Usage: - from astrbot._internal.agents import OpenAIAgentsRunner - - runner = OpenAIAgentsRunner(agent_config) - async for response in runner.run(): - print(response) -""" - -from .runner import OpenAIAgentsRunner -from .tool_adapter import astrbot_tool_to_agents_tool - -__all__ = [ - "OpenAIAgentsRunner", - "astrbot_tool_to_agents_tool", -] diff --git a/astrbot/_internal/agents/openai_agents/runner.py b/astrbot/_internal/agents/openai_agents/runner.py deleted file mode 100644 index 973e914c3..000000000 --- a/astrbot/_internal/agents/openai_agents/runner.py +++ /dev/null @@ -1,180 +0,0 @@ -"""OpenAI Agents SDK Runner for AstrBot. - -This module provides an integration layer between AstrBot's agent system -and the openai-agents library from OpenAI. -""" - -from __future__ import annotations - -import sys -from collections.abc import AsyncGenerator -from typing import Any - -if sys.version_info >= (3, 12): - from typing import override -else: - from typing_extensions import override - -from agents import Agent as OpenAIAgent -from agents import Model, Runner, RunResult -from agents.run_config import RunConfig - -from astrbot.core.agent.hooks import BaseAgentRunHooks -from astrbot.core.agent.response import AgentResponse, AgentResponseData -from astrbot.core.agent.run_context import ContextWrapper, TContext -from astrbot.core.agent.runners.base import AgentState, BaseAgentRunner -from astrbot.core.agent.tool_executor import BaseFunctionToolExecutor -from astrbot.core.agent.tool import FunctionTool -from astrbot.core.message.message_event_result import MessageChain -from astrbot.core.provider.entities import LLMResponse, ProviderRequest -from astrbot.core.provider.func_tool_manager import FunctionToolManager -from astrbot.core.provider.provider import Provider - -from .tool_adapter import astrbot_tool_to_agents_tool - - -class OpenAIAgentsRunner(BaseAgentRunner[TContext]): - """An agent runner that uses the openai-agents library. - - This runner wraps the openai-agents Agent and integrates it with - AstrBot's existing infrastructure including tools, hooks, and context. - """ - - def __init__( - self, - model: Model | str, - instructions: str | None = None, - tools: list[FunctionTool] | None = None, - tool_manager: FunctionToolManager | None = None, - agent_name: str = "AstrBotAgent", - **openai_agents_kwargs: Any, - ) -> None: - """Initialize the openai-agents runner. - - Args: - model: The model to use (either a Model instance or a model name string) - instructions: System prompt/instructions for the agent - tools: List of AstrBot FunctionTool instances - tool_manager: Optional FunctionToolManager for tool resolution - agent_name: Name of the agent - **openai_agents_kwargs: Additional arguments passed to openai-agents Agent - """ - super().__init__() - self._model = model - self._instructions = instructions - self._tools = tools or [] - self._tool_manager = tool_manager - self._agent_name = agent_name - self._openai_agents_kwargs = openai_agents_kwargs - - self._agent: OpenAIAgent[Any] | None = None - self._run_result: RunResult | None = None - self._run_context: ContextWrapper[TContext] | None = None - self._agent_hooks: BaseAgentRunHooks[TContext] | None = None - self._state = AgentState.IDLE - - def _create_agents_tools(self) -> list[Any]: - """Convert AstrBot tools to openai-agents tools.""" - agents_tools = [] - for tool in self._tools: - handler = self._get_tool_handler(tool.name) - agents_tool = astrbot_tool_to_agents_tool(tool, handler) - agents_tools.append(agents_tool) - return agents_tools - - def _get_tool_handler(self, tool_name: str) -> Any: - """Get the handler function for a tool by name.""" - if self._tool_manager: - tool = self._tool_manager.get_func(tool_name) - if tool and tool.handler: - return tool.handler - for tool in self._tools: - if tool.name == tool_name and hasattr(tool, "handler"): - return tool.handler - raise ValueError(f"Tool handler not found for: {tool_name}") - - @override - async def reset( - self, - provider: Provider, - request: ProviderRequest, - run_context: ContextWrapper[TContext], - tool_executor: BaseFunctionToolExecutor[TContext], - agent_hooks: BaseAgentRunHooks[TContext], - streaming: bool = False, - enforce_max_turns: int = -1, - llm_compress_instruction: str | None = None, - llm_compress_keep_recent: int = 0, - llm_compress_provider: Provider | None = None, - truncate_turns: int = 1, - custom_token_counter: Any = None, - custom_compressor: Any = None, - tool_schema_mode: str | None = "full", - fallback_providers: list[Provider] | None = None, - provider_config: dict | None = None, - **kwargs: Any, - ) -> None: - """Reset the agent to its initial state.""" - self._run_context = run_context - self._agent_hooks = agent_hooks - self._state = AgentState.IDLE - self._run_result = None - - agents_tools = self._create_agents_tools() - - self._agent = OpenAIAgent( - name=self._agent_name, - instructions=self._instructions, - tools=agents_tools, - **self._openai_agents_kwargs, - ) - - @override - async def step(self) -> AsyncGenerator[AgentResponse, None]: - """Process a single step of the agent (not directly supported).""" - raise NotImplementedError( - "step() is not directly supported. Use step_until_done() instead." - ) - - @override - async def step_until_done( - self, max_step: int - ) -> AsyncGenerator[AgentResponse, None]: - """Run the agent until completion or max steps.""" - if not self._agent or not self._run_context: - raise RuntimeError("Agent not initialized. Call reset() first.") - - self._state = AgentState.RUNNING - - run_config = RunConfig(max_turns=max_step) - - try: - self._run_result = await Runner.run( - self._agent, - input=self._run_context.messages, - run_config=run_config, - ) - self._state = AgentState.DONE - - if self._run_result: - chain = MessageChain().message(self._run_result.final_output) - yield AgentResponse( - type="llm_result", - data=AgentResponseData(chain=chain), - ) - except Exception: - self._state = AgentState.ERROR - raise - - def done(self) -> bool: - """Check if the agent has completed its task.""" - return self._state == AgentState.DONE - - def get_final_llm_resp(self) -> LLMResponse | None: - """Get the final LLM response from the agent run.""" - if not self._run_result: - return None - return LLMResponse( - role="assistant", - completion_text=self._run_result.final_output, - ) diff --git a/astrbot/_internal/agents/openai_agents/tool_adapter.py b/astrbot/_internal/agents/openai_agents/tool_adapter.py deleted file mode 100644 index 3d9ad927a..000000000 --- a/astrbot/_internal/agents/openai_agents/tool_adapter.py +++ /dev/null @@ -1,59 +0,0 @@ -"""Adapter to convert AstrBot FunctionTool to openai-agents FunctionTool. - -This module provides utilities to convert between AstrBot's tool representation -and the openai-agents library's FunctionTool format. -""" - -from __future__ import annotations - -from collections.abc import Awaitable, Callable -from typing import Any - -from agents import FunctionTool as AgentsFunctionTool -from agents.tool import ToolContext - -from astrbot.core.agent.tool import FunctionTool - - -def create_agents_tool( - name: str, - description: str, - params_json_schema: dict[str, Any], - on_invoke_tool: Callable[[ToolContext[Any], str], Awaitable[Any]], -) -> AgentsFunctionTool: - """Create an openai-agents FunctionTool from a handler function. - - This is a helper to create tools that integrate with the openai-agents SDK. - """ - return AgentsFunctionTool( - name=name, - description=description, - params_json_schema=params_json_schema, - on_invoke_tool=on_invoke_tool, - ) - - -def astrbot_tool_to_agents_tool( - tool: FunctionTool, - handler: Callable[..., Awaitable[Any]], -) -> AgentsFunctionTool: - """Convert an AstrBot FunctionTool to an openai-agents FunctionTool. - - Args: - tool: The AstrBot FunctionTool to convert - handler: The async function to call when the tool is invoked. - Should have the signature: async def handler(tool_context, tool_name) -> Any - - Returns: - An openai-agents FunctionTool that wraps the AstrBot tool - """ - - async def wrapper(tool_context: ToolContext[Any], tool_name: str) -> Any: - return await handler(tool.context, tool.name) - - return AgentsFunctionTool( - name=tool.name, - description=tool.desc, - params_json_schema=tool.parameters, - on_invoke_tool=wrapper, - ) diff --git a/astrbot/_internal/mcp/__init__.py b/astrbot/_internal/mcp/__init__.py deleted file mode 100644 index a496c57e1..000000000 --- a/astrbot/_internal/mcp/__init__.py +++ /dev/null @@ -1,63 +0,0 @@ -"""MCP module - Model Context Protocol client and tool implementations. - -This module provides MCP client functionality and MCP tool wrappers. -""" - -import asyncio -from dataclasses import dataclass - -from .client import MCPClient -from .config import ( - DEFAULT_MCP_CONFIG, - get_mcp_config_path, - load_mcp_config, - save_mcp_config, -) -from .tool import MCPTool - - -# Exceptions -class MCPInitError(Exception): - """Base exception for MCP initialization failures.""" - - -class MCPInitTimeoutError(asyncio.TimeoutError, MCPInitError): - """Raised when MCP client initialization exceeds the configured timeout.""" - - -class MCPAllServicesFailedError(MCPInitError): - """Raised when all configured MCP services fail to initialize.""" - - -class MCPShutdownTimeoutError(asyncio.TimeoutError): - """Raised when MCP shutdown exceeds the configured timeout.""" - - def __init__(self, names: list[str], timeout: float) -> None: - self.names = names - self.timeout = timeout - message = f"MCP 服务关闭超时({timeout:g} 秒):{', '.join(names)}" - super().__init__(message) - - -@dataclass -class MCPInitSummary: - """Summary of MCP initialization results.""" - - total: int - success: int - failed: list[str] - - -__all__ = [ - "DEFAULT_MCP_CONFIG", - "MCPAllServicesFailedError", - "MCPClient", - "MCPInitError", - "MCPInitSummary", - "MCPInitTimeoutError", - "MCPShutdownTimeoutError", - "MCPTool", - "get_mcp_config_path", - "load_mcp_config", - "save_mcp_config", -] diff --git a/astrbot/_internal/mcp/client.py b/astrbot/_internal/mcp/client.py deleted file mode 100644 index d4a2db7b2..000000000 --- a/astrbot/_internal/mcp/client.py +++ /dev/null @@ -1,411 +0,0 @@ -"""MCP client implementation.""" - -import asyncio -import logging -import os -import sys -from contextlib import AsyncExitStack -from datetime import timedelta - -from tenacity import ( - before_sleep_log, - retry, - retry_if_exception_type, - stop_after_attempt, - wait_exponential, -) - -from astrbot import logger -from astrbot.core.utils.log_pipe import LogPipe - -try: - import anyio - import mcp - from mcp.client.sse import sse_client -except (ModuleNotFoundError, ImportError): - logger.warning( - "Warning: Missing 'mcp' dependency, MCP services will be unavailable." - ) - -try: - from mcp.client.streamable_http import streamablehttp_client -except (ModuleNotFoundError, ImportError): - logger.warning( - "Warning: Missing 'mcp' dependency or MCP library version too old, Streamable HTTP connection unavailable.", - ) - - -def _prepare_config(config: dict) -> dict: - """Prepare configuration, handle nested format.""" - if config.get("mcpServers"): - first_key = next(iter(config["mcpServers"])) - config = config["mcpServers"][first_key] - config.pop("active", None) - return config - - -def _prepare_stdio_env(config: dict) -> dict: - """Preserve Windows executable resolution for stdio subprocesses.""" - if sys.platform != "win32": - return config - - pathext = os.environ.get("PATHEXT") - if not pathext: - return config - - prepared = config.copy() - env = dict(prepared.get("env") or {}) - env.setdefault("PATHEXT", pathext) - prepared["env"] = env - return prepared - - -async def _quick_test_mcp_connection(config: dict) -> tuple[bool, str]: - """Quick test MCP server connectivity.""" - import aiohttp - - cfg = _prepare_config(config.copy()) - - url = cfg["url"] - headers = cfg.get("headers", {}) - timeout = cfg.get("timeout", 10) - - try: - if "transport" in cfg: - transport_type = cfg["transport"] - elif "type" in cfg: - transport_type = cfg["type"] - else: - raise Exception("MCP connection config missing transport or type field") - - async with aiohttp.ClientSession() as session: - if transport_type == "streamable_http": - test_payload = { - "jsonrpc": "2.0", - "method": "initialize", - "id": 0, - "params": { - "protocolVersion": "2024-11-05", - "capabilities": {}, - "clientInfo": {"name": "test-client", "version": "1.2.3"}, - }, - } - async with session.post( - url, - headers={ - **headers, - "Content-Type": "application/json", - "Accept": "application/json, text/event-stream", - }, - json=test_payload, - timeout=aiohttp.ClientTimeout(total=timeout), - ) as response: - if response.status == 200: - return True, "" - return False, f"HTTP {response.status}: {response.reason}" - else: - async with session.get( - url, - headers={ - **headers, - "Accept": "application/json, text/event-stream", - }, - timeout=aiohttp.ClientTimeout(total=timeout), - ) as response: - if response.status == 200: - return True, "" - return False, f"HTTP {response.status}: {response.reason}" - - except asyncio.TimeoutError: - return False, f"Connection timeout: {timeout} seconds" - except Exception as e: - return False, f"{e!s}" - - -class MCPClient: - def __init__(self) -> None: - # Initialize session and client objects - self.session: mcp.ClientSession | None = None - self.exit_stack = AsyncExitStack() - self._old_exit_stacks: list[AsyncExitStack] = [] # Track old stacks for cleanup - - self.name: str | None = None - self.active: bool = True - self.tools: list[mcp.Tool] = [] - self.server_errlogs: list[str] = [] - self.running_event = asyncio.Event() - self.process_pid: int | None = None - - # Store connection config for reconnection - self._mcp_server_config: dict | None = None - self._server_name: str | None = None - self._reconnect_lock = asyncio.Lock() # Lock for thread-safe reconnection - self._reconnecting: bool = False # For logging and debugging - - @staticmethod - def _extract_stdio_process_pid(streams_context: object) -> int | None: - """Best-effort extraction for stdio subprocess PID used by lease cleanup. - - TODO(refactor): replace this async-generator frame introspection with a - stable MCP library hook once the upstream transport exposes process PID. - """ - generator = getattr(streams_context, "gen", None) - frame = getattr(generator, "ag_frame", None) - if frame is None: - return None - process = frame.f_locals.get("process") - pid = getattr(process, "pid", None) - try: - return int(pid) if pid is not None else None - except (TypeError, ValueError): - return None - - async def connect_to_server(self, mcp_server_config: dict, name: str) -> None: - """Connect to MCP server - - If `url` parameter exists: - 1. When transport is specified as `streamable_http`, use Streamable HTTP connection. - 2. When transport is specified as `sse`, use SSE connection. - 3. If not specified, default to SSE connection to MCP service. - - Args: - mcp_server_config (dict): Configuration for the MCP server. See https://modelcontextprotocol.io/quickstart/server - - """ - # Store config for reconnection - self._mcp_server_config = mcp_server_config - self._server_name = name - self.process_pid = None - - cfg = _prepare_config(mcp_server_config.copy()) - - def logging_callback( - msg: str | mcp.types.LoggingMessageNotificationParams, - ) -> None: - # Handle MCP service error logs - if isinstance(msg, mcp.types.LoggingMessageNotificationParams): - if msg.level in ("warning", "error", "critical", "alert", "emergency"): - log_msg = f"[{msg.level.upper()}] {msg.data!s}" - self.server_errlogs.append(log_msg) - - if "url" in cfg: - success, error_msg = await _quick_test_mcp_connection(cfg) - if not success: - raise Exception(error_msg) - - if "transport" in cfg: - transport_type = cfg["transport"] - elif "type" in cfg: - transport_type = cfg["type"] - else: - raise Exception("MCP connection config missing transport or type field") - - if transport_type != "streamable_http": - # SSE transport method - self._streams_context = sse_client( - url=cfg["url"], - headers=cfg.get("headers", {}), - timeout=cfg.get("timeout", 5), - sse_read_timeout=cfg.get("sse_read_timeout", 60 * 5), - ) - streams = await self.exit_stack.enter_async_context( - self._streams_context, - ) - - # Create a new client session - read_timeout = timedelta(seconds=cfg.get("session_read_timeout", 60)) - self.session = await self.exit_stack.enter_async_context( - mcp.ClientSession( - *streams, - read_timeout_seconds=read_timeout, - logging_callback=logging_callback, # type: ignore - ), - ) - else: - timeout = timedelta(seconds=cfg.get("timeout", 30)) - sse_read_timeout = timedelta( - seconds=cfg.get("sse_read_timeout", 60 * 5), - ) - self._streams_context = streamablehttp_client( - url=cfg["url"], - headers=cfg.get("headers", {}), - timeout=timeout, - sse_read_timeout=sse_read_timeout, - terminate_on_close=cfg.get("terminate_on_close", True), - ) - read_s, write_s, _ = await self.exit_stack.enter_async_context( - self._streams_context, - ) - - # Create a new client session - read_timeout = timedelta(seconds=cfg.get("session_read_timeout", 60)) - self.session = await self.exit_stack.enter_async_context( - mcp.ClientSession( - read_stream=read_s, - write_stream=write_s, - read_timeout_seconds=read_timeout, - logging_callback=logging_callback, # type: ignore - ), - ) - - else: - cfg = _prepare_stdio_env(cfg) - server_params = mcp.StdioServerParameters( - **cfg, - ) - - def callback(msg: str | mcp.types.LoggingMessageNotificationParams) -> None: - # Handle MCP service error logs - if isinstance(msg, mcp.types.LoggingMessageNotificationParams): - if msg.level in ( - "warning", - "error", - "critical", - "alert", - "emergency", - ): - log_msg = f"[{msg.level.upper()}] {msg.data!s}" - self.server_errlogs.append(log_msg) - - stdio_transport = await self.exit_stack.enter_async_context( - mcp.stdio_client( - server_params, - errlog=LogPipe( - level=logging.INFO, - logger=logger, - identifier=f"MCPServer-{name}", - callback=callback, - ), # type: ignore - ), - ) - self.process_pid = self._extract_stdio_process_pid(self._streams_context) - - # Create a new client session - self.session = await self.exit_stack.enter_async_context( - mcp.ClientSession(*stdio_transport), - ) - await self.session.initialize() - - async def list_tools_and_save(self) -> mcp.ListToolsResult: - """List all tools from the server and save them to self.tools""" - if not self.session: - raise Exception("MCP Client is not initialized") - response = await self.session.list_tools() - self.tools = response.tools - return response - - async def _reconnect(self) -> None: - """Reconnect to the MCP server using the stored configuration. - - Uses asyncio.Lock to ensure thread-safe reconnection in concurrent environments. - - Raises: - Exception: raised when reconnection fails - """ - async with self._reconnect_lock: - # Check if already reconnecting (useful for logging) - if self._reconnecting: - logger.debug( - f"MCP Client {self._server_name} is already reconnecting, skipping" - ) - return - - if not self._mcp_server_config or not self._server_name: - raise Exception("Cannot reconnect: missing connection configuration") - - self._reconnecting = True - try: - logger.info( - f"Attempting to reconnect to MCP server {self._server_name}..." - ) - - # Save old exit_stack for later cleanup (don't close it now to avoid cancel scope issues) - if self.exit_stack: - self._old_exit_stacks.append(self.exit_stack) - - # Mark old session as invalid - self.session = None - - # Create new exit stack for new connection - self.exit_stack = AsyncExitStack() - - # Reconnect using stored config - await self.connect_to_server(self._mcp_server_config, self._server_name) - await self.list_tools_and_save() - - logger.info( - f"Successfully reconnected to MCP server {self._server_name}" - ) - except Exception as e: - logger.error( - f"Failed to reconnect to MCP server {self._server_name}: {e}" - ) - raise - finally: - self._reconnecting = False - - async def call_tool_with_reconnect( - self, - tool_name: str, - arguments: dict, - read_timeout_seconds: timedelta, - ) -> mcp.types.CallToolResult: - """Call MCP tool with automatic reconnection on failure, max 2 retries. - - Args: - tool_name: tool name - arguments: tool arguments - read_timeout_seconds: read timeout - - Returns: - MCP tool call result - - Raises: - ValueError: MCP session is not available - anyio.ClosedResourceError: raised after reconnection failure - """ - - @retry( - retry=retry_if_exception_type(anyio.ClosedResourceError), - stop=stop_after_attempt(2), - wait=wait_exponential(multiplier=1, min=1, max=3), - before_sleep=before_sleep_log(logger, logging.WARNING), - reraise=True, - ) - async def _call_with_retry(): - if not self.session: - raise ValueError("MCP session is not available for MCP function tools.") - - try: - return await self.session.call_tool( - name=tool_name, - arguments=arguments, - read_timeout_seconds=read_timeout_seconds, - ) - except anyio.ClosedResourceError: - logger.warning( - f"MCP tool {tool_name} call failed (ClosedResourceError), attempting to reconnect..." - ) - # Attempt to reconnect - await self._reconnect() - # Reraise the exception to trigger tenacity retry - raise - - return await _call_with_retry() - - async def cleanup(self) -> None: - """Clean up resources including old exit stacks from reconnections""" - # Close current exit stack - try: - await self.exit_stack.aclose() - except Exception as e: - logger.debug(f"Error closing current exit stack: {e}") - - # Don't close old exit stacks as they may be in different task contexts - # They will be garbage collected naturally - # Just clear the list to release references - self._old_exit_stacks.clear() - - # Set running_event first to unblock any waiting tasks - self.running_event.set() - self.process_pid = None diff --git a/astrbot/_internal/mcp/config.py b/astrbot/_internal/mcp/config.py deleted file mode 100644 index 0ea528948..000000000 --- a/astrbot/_internal/mcp/config.py +++ /dev/null @@ -1,55 +0,0 @@ -"""MCP configuration management.""" - -import json -import os - -from astrbot.core.utils.astrbot_path import get_astrbot_data_path - -DEFAULT_MCP_CONFIG = {"mcpServers": {}} - - -def get_mcp_config_path() -> str: - """Get the path to the MCP configuration file.""" - data_dir = get_astrbot_data_path() - return os.path.join(data_dir, "mcp_server.json") - - -def load_mcp_config() -> dict: - """Load MCP configuration from file. - - Returns: - MCP configuration dict. If file doesn't exist, returns default config. - - """ - config_path = get_mcp_config_path() - if not os.path.exists(config_path): - # Create default config if not exists - os.makedirs(os.path.dirname(config_path), exist_ok=True) - with open(config_path, "w", encoding="utf-8") as f: - json.dump(DEFAULT_MCP_CONFIG, f, ensure_ascii=False, indent=4) - return DEFAULT_MCP_CONFIG - - try: - with open(config_path, encoding="utf-8") as f: - return json.load(f) - except Exception: - return DEFAULT_MCP_CONFIG - - -def save_mcp_config(config: dict) -> bool: - """Save MCP configuration to file. - - Args: - config: MCP configuration dict to save. - - Returns: - True if successful, False otherwise. - - """ - config_path = get_mcp_config_path() - try: - with open(config_path, "w", encoding="utf-8") as f: - json.dump(config, f, ensure_ascii=False, indent=4) - return True - except Exception: - return False diff --git a/astrbot/_internal/mcp/tool.py b/astrbot/_internal/mcp/tool.py deleted file mode 100644 index 573784da9..000000000 --- a/astrbot/_internal/mcp/tool.py +++ /dev/null @@ -1,40 +0,0 @@ -"""MCP tool wrapper.""" - -from datetime import timedelta -from typing import Generic - -from astrbot._internal.tools.base import FunctionTool -from astrbot.core.agent.run_context import ContextWrapper, TContext - -from .client import MCPClient - -try: - import mcp -except (ModuleNotFoundError, ImportError): - mcp = None # type: ignore - - -class MCPTool(FunctionTool, Generic[TContext]): - """A function tool that calls an MCP service.""" - - def __init__( - self, mcp_tool: mcp.Tool, mcp_client: MCPClient, mcp_server_name: str, **kwargs - ) -> None: - super().__init__( - name=mcp_tool.name, - description=mcp_tool.description or "", - parameters=mcp_tool.inputSchema, - ) - self.mcp_tool = mcp_tool - self.mcp_client = mcp_client - self.mcp_server_name = mcp_server_name - self.source = "mcp" - - async def call( - self, context: ContextWrapper[TContext], **kwargs - ) -> mcp.types.CallToolResult: - return await self.mcp_client.call_tool_with_reconnect( - tool_name=self.mcp_tool.name, - arguments=kwargs, - read_timeout_seconds=timedelta(seconds=context.tool_call_timeout), - ) diff --git a/astrbot/_internal/skills/__init__.py b/astrbot/_internal/skills/__init__.py deleted file mode 100644 index 78c77a109..000000000 --- a/astrbot/_internal/skills/__init__.py +++ /dev/null @@ -1,37 +0,0 @@ -"""AstrBot internal skills module. - -This module provides the skill management system for AstrBot, including: -- SkillManager: Manages skill lifecycle (install, activate, delete, etc.) -- SkillInfo: Dataclass representing skill metadata -- build_skills_prompt: Builds the system prompt section for skills -- SkillToToolConverter: Converts skills with input_schema to FunctionTool -""" - -from .manager import ( - DEFAULT_SKILLS_CONFIG, - SANDBOX_SKILLS_CACHE_FILENAME, - SANDBOX_SKILLS_ROOT, - SANDBOX_WORKSPACE_ROOT, - SKILLS_CONFIG_FILENAME, - SkillInfo, - SkillManager, - build_skills_prompt, -) -from .parser import parse_frontmatter, parse_skill_markdown -from .to_tool import SkillToToolConverter - -__all__ = [ - # Constants - "DEFAULT_SKILLS_CONFIG", - "SANDBOX_SKILLS_CACHE_FILENAME", - "SANDBOX_SKILLS_ROOT", - "SANDBOX_WORKSPACE_ROOT", - "SKILLS_CONFIG_FILENAME", - "SkillInfo", - "SkillManager", - "SkillToToolConverter", - "build_skills_prompt", - # Parser - "parse_frontmatter", - "parse_skill_markdown", -] diff --git a/astrbot/_internal/skills/loader.py b/astrbot/_internal/skills/loader.py deleted file mode 100644 index ae44d4665..000000000 --- a/astrbot/_internal/skills/loader.py +++ /dev/null @@ -1,79 +0,0 @@ -"""Skill loader - loads skills from filesystem.""" - -from __future__ import annotations - -from pathlib import Path - -from .manager import SkillInfo, _normalize_skill_markdown_path, _parse_frontmatter - - -def load_skill(skill_name: str, skills_root: Path) -> SkillInfo | None: - """Load a single skill by name from the skills root directory. - - Args: - skill_name: The name of the skill to load - skills_root: Path to the skills root directory - - Returns: - SkillInfo if the skill exists and has a valid SKILL.md, None otherwise - """ - skill_dir = skills_root / skill_name - if not skill_dir.is_dir(): - return None - - skill_md = _normalize_skill_markdown_path(skill_dir) - if skill_md is None: - return None - - try: - content = skill_md.read_text(encoding="utf-8") - meta = _parse_frontmatter(content) - except Exception: - return None - - description = meta.get("description", "") - if not isinstance(description, str): - description = "" - description = description.strip() - - input_schema = meta.get("input_schema") - output_schema = meta.get("output_schema") - - return SkillInfo( - name=skill_name, - description=description, - path=str(skill_md).replace("\\", "/"), - active=True, - source_type="local_only", - source_label="local", - local_exists=True, - sandbox_exists=False, - input_schema=input_schema, - output_schema=output_schema, - ) - - -def load_all_skills(skills_root: Path) -> list[SkillInfo]: - """Load all skills from the skills root directory. - - Args: - skills_root: Path to the skills root directory - - Returns: - List of SkillInfo objects for all valid skills - """ - skills: list[SkillInfo] = [] - - if not skills_root.is_dir(): - return skills - - for entry in sorted(skills_root.iterdir()): - if not entry.is_dir(): - continue - - skill_name = entry.name - skill_info = load_skill(skill_name, skills_root) - if skill_info is not None: - skills.append(skill_info) - - return skills diff --git a/astrbot/_internal/skills/manager.py b/astrbot/_internal/skills/manager.py deleted file mode 100644 index 9e54ced39..000000000 --- a/astrbot/_internal/skills/manager.py +++ /dev/null @@ -1,617 +0,0 @@ -"""Skill manager - manages skill lifecycle (install, activate, delete, etc.).""" - -from __future__ import annotations - -import json -import os -import re -import shlex -import shutil -import tempfile -import uuid -import zipfile -from dataclasses import dataclass -from datetime import datetime, timezone -from pathlib import Path, PurePosixPath - -import yaml - -from astrbot.core.utils.astrbot_path import AstrbotPaths, astrbot_paths - -SKILLS_CONFIG_FILENAME = "skills.json" -SANDBOX_SKILLS_CACHE_FILENAME = "sandbox_skills_cache.json" -DEFAULT_SKILLS_CONFIG: dict[str, dict] = {"skills": {}} -SANDBOX_SKILLS_ROOT = "skills" -SANDBOX_WORKSPACE_ROOT = "/workspace" -_SANDBOX_SKILLS_CACHE_VERSION = 1 - -_SKILL_NAME_RE = re.compile(r"^[A-Za-z0-9._-]+$") - - -def _default_sandbox_skill_path(name: str) -> str: - return f"{SANDBOX_WORKSPACE_ROOT}/{SANDBOX_SKILLS_ROOT}/{name}/SKILL.md" - - -def _normalize_cached_sandbox_skill_path(name: str, path: str) -> str: - normalized = str(path or "").strip().replace("\\", "/") - if not normalized: - return _default_sandbox_skill_path(name) - - pure_path = PurePosixPath(normalized) - if ".." in pure_path.parts: - return _default_sandbox_skill_path(name) - - if pure_path.name != "SKILL.md": - return _default_sandbox_skill_path(name) - - if pure_path.parent.name != name: - return _default_sandbox_skill_path(name) - - return str(pure_path) - - -def _is_ignored_zip_entry(name: str) -> bool: - parts = PurePosixPath(name).parts - if not parts: - return True - return parts[0] == "__MACOSX" - - -def _normalize_skill_markdown_path(skill_dir: Path) -> Path | None: - """Return the canonical `SKILL.md` path for a skill directory. - - If only legacy `skill.md` exists, it is renamed to `SKILL.md` in-place. - """ - canonical = skill_dir / "SKILL.md" - entries = set() - if skill_dir.exists(): - entries = {entry.name for entry in skill_dir.iterdir()} - if "SKILL.md" in entries: - return canonical - legacy = skill_dir / "skill.md" - if "skill.md" not in entries: - return None - try: - tmp = skill_dir / f".{uuid.uuid4().hex}.tmp_skill_md" - legacy.rename(tmp) - tmp.rename(canonical) - except OSError: - return legacy - return canonical - - -@dataclass -class SkillInfo: - name: str - description: str - path: str - active: bool - source_type: str = "local_only" - source_label: str = "local" - local_exists: bool = True - sandbox_exists: bool = False - input_schema: dict | None = None - output_schema: dict | None = None - - -def _parse_frontmatter(text: str) -> dict: - """Extract metadata from YAML frontmatter. - - Expects the standard SKILL.md format used by OpenAI Codex CLI and - Anthropic Claude Skills:: - - --- - name: my-skill - description: What this skill does and when to use it. - input_schema: ... - output_schema: ... - --- - """ - if not text.startswith("---"): - return {} - lines = text.splitlines() - if not lines or lines[0].strip() != "---": - return {} - end_idx = None - for i in range(1, len(lines)): - if lines[i].strip() == "---": - end_idx = i - break - if end_idx is None: - return {} - - frontmatter = "\n".join(lines[1:end_idx]) - try: - payload = yaml.safe_load(frontmatter) or {} - except yaml.YAMLError: - return {} - if not isinstance(payload, dict): - return {} - - return payload - - -# Regex for sanitizing paths used in prompt examples — only allow -# safe path characters to prevent prompt injection via crafted skill paths. -_SAFE_PATH_RE = re.compile(r"[^\w./ ,()'\-]", re.UNICODE) -_WINDOWS_DRIVE_PATH_RE = re.compile(r"^[A-Za-z]:(?:/|\\)") -_WINDOWS_UNC_PATH_RE = re.compile(r"^(//|\\\\)[^/\\]+[/\\][^/\\]+") -_CONTROL_CHARS_RE = re.compile(r"[\x00-\x1F\x7F]") - - -def _is_windows_prompt_path(path: str) -> bool: - if os.name != "nt": - return False - return bool(_WINDOWS_DRIVE_PATH_RE.match(path) or _WINDOWS_UNC_PATH_RE.match(path)) - - -def _sanitize_prompt_path_for_prompt(path: str) -> str: - if not path: - return "" - - if _WINDOWS_DRIVE_PATH_RE.match(path) or _WINDOWS_UNC_PATH_RE.match(path): - path = path.replace("\\", "/") - - drive_prefix = "" - if _WINDOWS_DRIVE_PATH_RE.match(path): - drive_prefix = path[:2] - path = path[2:] - - path = path.replace("`", "") - path = _CONTROL_CHARS_RE.sub("", path) - sanitized = _SAFE_PATH_RE.sub("", path) - return f"{drive_prefix}{sanitized}" - - -def _sanitize_prompt_description(description: str) -> str: - description = description.replace("`", "") - description = _CONTROL_CHARS_RE.sub(" ", description) - description = " ".join(description.split()) - return description - - -def _sanitize_skill_display_name(name: str) -> str: - if _SKILL_NAME_RE.fullmatch(name): - return name - return "" - - -def _build_skill_read_command_example(path: str) -> str: - if path == "//SKILL.md": - return f"cat {path}" - if _is_windows_prompt_path(path): - command = "type" - path_arg = f'"{os.path.normpath(path)}"' - else: - command = "cat" - path_arg = shlex.quote(path) - return f"{command} {path_arg}" - - -def build_skills_prompt(skills: list[SkillInfo]) -> str: - """Build the skills section of the system prompt. - - Generates a markdown-formatted skill inventory for the LLM. Only - ``name`` and ``description`` are shown upfront; the LLM must read - the full ``SKILL.md`` before execution (progressive disclosure). - """ - skills_lines: list[str] = [] - example_path = "" - for skill in skills: - display_name = _sanitize_skill_display_name(skill.name) - - description = skill.description or "No description" - if skill.source_type == "sandbox_only": - description = _sanitize_prompt_description(description) - if not description: - description = "Read SKILL.md for details." - - if skill.source_type == "sandbox_only": - # Prefer the actual path from sandbox cache if available - rendered_path = _sanitize_prompt_path_for_prompt(skill.path) - if not rendered_path: - rendered_path = _default_sandbox_skill_path(skill.name) - else: - rendered_path = _sanitize_prompt_path_for_prompt(skill.path) - if not rendered_path: - rendered_path = "//SKILL.md" - - entry = f"- **{display_name}**: {description}\n File: `{rendered_path}`" - if skill.input_schema: - entry += f"\n Input Schema: {json.dumps(skill.input_schema, ensure_ascii=False)}" - if skill.output_schema: - entry += f"\n Output Schema: {json.dumps(skill.output_schema, ensure_ascii=False)}" - skills_lines.append(entry) - if not example_path: - example_path = rendered_path - skills_block = "\n".join(skills_lines) - # Sanitize example_path — it may originate from sandbox cache (untrusted) - if example_path == "//SKILL.md": - example_path = "//SKILL.md" - else: - example_path = _sanitize_prompt_path_for_prompt(example_path) - example_path = example_path or "//SKILL.md" - example_command = _build_skill_read_command_example(example_path) - - return ( - "## Skills\n\n" - "You have specialized skills — reusable instruction bundles stored " - "in `SKILL.md` files. Each skill has a **name** and a **description** " - "that tells you what it does and when to use it.\n\n" - "### Available skills\n\n" - f"{skills_block}\n\n" - "### Skill rules\n\n" - "1. **Discovery** — The list above is the complete skill inventory " - "for this session. Full instructions are in the referenced " - "`SKILL.md` file.\n" - "2. **When to trigger** — Use a skill if the user names it " - "explicitly, or if the task clearly matches the skill's description. " - "*Never silently skip a matching skill* — either use it or briefly " - "explain why you chose not to.\n" - "3. **Mandatory grounding** — Before executing any skill you MUST " - "first read its `SKILL.md` by running a shell command compatible " - "with the current runtime shell and using the **absolute path** " - f"shown above (e.g. `{example_command}`). " - "Never rely on memory or assumptions about a skill's content.\n" - "4. **Progressive disclosure** — Load only what is directly " - "referenced from `SKILL.md`:\n" - " - If `scripts/` exist, prefer running or patching them over " - "rewriting code from scratch.\n" - " - If `assets/` or templates exist, reuse them.\n" - " - Do NOT bulk-load every file in the skill directory.\n" - "5. **Coordination** — When multiple skills apply, pick the minimal " - "set needed. Announce which skill(s) you are using and why " - "(one short line). Prefer `astrbot_*` tools when running skill " - "scripts.\n" - "6. **Context hygiene** — Avoid deep reference chasing; open only " - "files that are directly linked from `SKILL.md`.\n" - "7. **Failure handling** — If a skill cannot be applied, state the " - "issue clearly and continue with the best alternative.\n" - ) - - -class SkillManager: - def __init__( - self, - skills_root: str | None = None, - astrbot_paths: AstrbotPaths = astrbot_paths, - ) -> None: - self.astrbot_paths = astrbot_paths - self.skills_root = skills_root or str(self.astrbot_paths.skills) - self.config_path = str(self.astrbot_paths.config / SKILLS_CONFIG_FILENAME) - self.sandbox_skills_cache_path = str( - self.astrbot_paths.data / SANDBOX_SKILLS_CACHE_FILENAME - ) - os.makedirs(self.skills_root, exist_ok=True) - - def _load_config(self) -> dict: - if not os.path.exists(self.config_path): - self._save_config(DEFAULT_SKILLS_CONFIG.copy()) - return DEFAULT_SKILLS_CONFIG.copy() - with open(self.config_path, encoding="utf-8") as f: - data = json.load(f) - if not isinstance(data, dict) or "skills" not in data: - return DEFAULT_SKILLS_CONFIG.copy() - return data - - def _save_config(self, config: dict) -> None: - os.makedirs(os.path.dirname(self.config_path), exist_ok=True) - with open(self.config_path, "w", encoding="utf-8") as f: - json.dump(config, f, ensure_ascii=False, indent=4) - - def _load_sandbox_skills_cache(self) -> dict: - if not os.path.exists(self.sandbox_skills_cache_path): - return {"version": _SANDBOX_SKILLS_CACHE_VERSION, "skills": []} - try: - with open(self.sandbox_skills_cache_path, encoding="utf-8") as f: - data = json.load(f) - if not isinstance(data, dict): - return {"version": _SANDBOX_SKILLS_CACHE_VERSION, "skills": []} - skills = data.get("skills", []) - if not isinstance(skills, list): - skills = [] - return { - "version": int(data.get("version", _SANDBOX_SKILLS_CACHE_VERSION)), - "skills": skills, - "updated_at": data.get("updated_at"), - } - except Exception: - return {"version": _SANDBOX_SKILLS_CACHE_VERSION, "skills": []} - - def _save_sandbox_skills_cache(self, cache: dict) -> None: - cache["version"] = _SANDBOX_SKILLS_CACHE_VERSION - cache["updated_at"] = datetime.now(timezone.utc).isoformat() - os.makedirs(os.path.dirname(self.sandbox_skills_cache_path), exist_ok=True) - with open(self.sandbox_skills_cache_path, "w", encoding="utf-8") as f: - json.dump(cache, f, ensure_ascii=False, indent=2) - - def set_sandbox_skills_cache(self, skills: list[dict]) -> None: - """Persist sandbox skill metadata discovered from runtime side.""" - deduped: dict[str, dict[str, str]] = {} - for item in skills: - if not isinstance(item, dict): - continue - name = str(item.get("name", "")).strip() - if not name or not _SKILL_NAME_RE.match(name): - continue - description = str(item.get("description", "") or "") - path = _normalize_cached_sandbox_skill_path( - name, str(item.get("path", "") or "") - ) - deduped[name] = { - "name": name, - "description": description, - "path": path, - } - cache = { - "version": _SANDBOX_SKILLS_CACHE_VERSION, - "skills": [deduped[name] for name in sorted(deduped)], - } - self._save_sandbox_skills_cache(cache) - - def get_sandbox_skills_cache_status(self) -> dict[str, object]: - cache = self._load_sandbox_skills_cache() - skills = cache.get("skills", []) - count = len(skills) if isinstance(skills, list) else 0 - return { - "exists": os.path.exists(self.sandbox_skills_cache_path), - "ready": count > 0, - "count": count, - "updated_at": cache.get("updated_at"), - } - - def list_skills( - self, - *, - active_only: bool = False, - runtime: str = "local", - show_sandbox_path: bool = True, - ) -> list[SkillInfo]: - """List all skills. - - show_sandbox_path: If True and runtime is "sandbox", - return the path as it would appear in the sandbox environment, - otherwise return the local filesystem path. - """ - config = self._load_config() - skill_configs = config.get("skills", {}) - modified = False - skills_by_name: dict[str, SkillInfo] = {} - - sandbox_cached_paths: dict[str, str] = {} - sandbox_cached_descriptions: dict[str, str] = {} - cache_for_paths = self._load_sandbox_skills_cache() - for item in cache_for_paths.get("skills", []): - if not isinstance(item, dict): - continue - name = str(item.get("name", "") or "").strip() - path = _normalize_cached_sandbox_skill_path( - name, str(item.get("path", "") or "") - ) - if not name or not _SKILL_NAME_RE.match(name): - continue - sandbox_cached_descriptions[name] = str(item.get("description", "") or "") - sandbox_cached_paths[name] = path - - for entry in sorted(Path(self.skills_root).iterdir()): - if not entry.is_dir(): - continue - skill_name = entry.name - skill_md = _normalize_skill_markdown_path(entry) - if skill_md is None: - continue - active = skill_configs.get(skill_name, {}).get("active", True) - if skill_name not in skill_configs: - skill_configs[skill_name] = {"active": active} - modified = True - if active_only and not active: - continue - description = "" - input_schema = None - output_schema = None - try: - content = skill_md.read_text(encoding="utf-8") - meta = _parse_frontmatter(content) - description = meta.get("description", "") - if not isinstance(description, str): - description = "" - description = description.strip() - input_schema = meta.get("input_schema") - output_schema = meta.get("output_schema") - except Exception: - description = "" - sandbox_exists = ( - runtime == "sandbox" and skill_name in sandbox_cached_descriptions - ) - source_type = "both" if sandbox_exists else "local_only" - source_label = "synced" if sandbox_exists else "local" - if runtime == "sandbox" and show_sandbox_path: - path_str = sandbox_cached_paths.get( - skill_name - ) or _default_sandbox_skill_path(skill_name) - else: - path_str = str(skill_md) - path_str = path_str.replace("\\", "/") - skills_by_name[skill_name] = SkillInfo( - name=skill_name, - description=description, - path=path_str, - active=active, - source_type=source_type, - source_label=source_label, - local_exists=True, - sandbox_exists=sandbox_exists, - input_schema=input_schema, - output_schema=output_schema, - ) - - if runtime == "sandbox": - cache = self._load_sandbox_skills_cache() - for item in cache.get("skills", []): - if not isinstance(item, dict): - continue - skill_name = str(item.get("name", "")).strip() - if ( - not skill_name - or skill_name in skills_by_name - or not _SKILL_NAME_RE.match(skill_name) - ): - continue - active = skill_configs.get(skill_name, {}).get("active", True) - if skill_name not in skill_configs: - skill_configs[skill_name] = {"active": active} - modified = True - if active_only and not active: - continue - description = sandbox_cached_descriptions.get(skill_name, "") - # For sandbox_only skills, show_sandbox_path is implicitly True - # since there is no local path to show. Always prefer the - # actual path from sandbox cache. - path_str = sandbox_cached_paths.get( - skill_name - ) or _default_sandbox_skill_path(skill_name) - skills_by_name[skill_name] = SkillInfo( - name=skill_name, - description=description, - path=path_str.replace("\\", "/"), - active=active, - source_type="sandbox_only", - source_label="sandbox_preset", - local_exists=False, - sandbox_exists=True, - ) - - if modified: - config["skills"] = skill_configs - self._save_config(config) - - return [skills_by_name[name] for name in sorted(skills_by_name)] - - def is_sandbox_only_skill(self, name: str) -> bool: - skill_dir = Path(self.skills_root) / name - skill_md_exists = _normalize_skill_markdown_path(skill_dir) is not None - if skill_md_exists: - return False - cache = self._load_sandbox_skills_cache() - skills = cache.get("skills", []) - if not isinstance(skills, list): - return False - for item in skills: - if not isinstance(item, dict): - continue - if str(item.get("name", "")).strip() == name: - return True - return False - - def set_skill_active(self, name: str, active: bool) -> None: - if self.is_sandbox_only_skill(name): - raise PermissionError( - "Sandbox preset skill cannot be enabled/disabled from local skill management." - ) - config = self._load_config() - config.setdefault("skills", {}) - config["skills"][name] = {"active": bool(active)} - self._save_config(config) - - def _remove_skill_from_sandbox_cache(self, name: str) -> None: - cache = self._load_sandbox_skills_cache() - skills = cache.get("skills", []) - if not isinstance(skills, list): - return - - filtered = [ - item - for item in skills - if not ( - isinstance(item, dict) and str(item.get("name", "")).strip() == name - ) - ] - - if len(filtered) != len(skills): - cache["skills"] = filtered - self._save_sandbox_skills_cache(cache) - - def delete_skill(self, name: str) -> None: - if self.is_sandbox_only_skill(name): - raise PermissionError( - "Sandbox preset skill cannot be deleted from local skill management." - ) - - skill_dir = Path(self.skills_root) / name - if skill_dir.exists(): - shutil.rmtree(skill_dir) - - # Ensure UI consistency even when there is no active sandbox session - # to refresh cache from runtime side. - self._remove_skill_from_sandbox_cache(name) - - config = self._load_config() - if name in config.get("skills", {}): - config["skills"].pop(name, None) - self._save_config(config) - - def install_skill_from_zip(self, zip_path: str, *, overwrite: bool = True) -> str: - zip_path_obj = Path(zip_path) - if not zip_path_obj.exists(): - raise FileNotFoundError(f"Zip file not found: {zip_path}") - if not zipfile.is_zipfile(zip_path): - raise ValueError("Uploaded file is not a valid zip archive.") - - with zipfile.ZipFile(zip_path) as zf: - names = [ - name - for name in (entry.replace("\\", "/") for entry in zf.namelist()) - if name and not _is_ignored_zip_entry(name) - ] - file_names = [name for name in names if name and not name.endswith("/")] - if not file_names: - raise ValueError("Zip archive is empty.") - - top_dirs = { - PurePosixPath(name).parts[0] for name in file_names if name.strip() - } - - if len(top_dirs) != 1: - raise ValueError("Zip archive must contain a single top-level folder.") - skill_name = next(iter(top_dirs)) - if skill_name in {".", "..", ""} or not _SKILL_NAME_RE.match(skill_name): - raise ValueError("Invalid skill folder name.") - - for name in names: - if not name: - continue - if name.startswith("/") or re.match(r"^[A-Za-z]:", name): - raise ValueError("Zip archive contains absolute paths.") - parts = PurePosixPath(name).parts - if ".." in parts: - raise ValueError("Zip archive contains invalid relative paths.") - if parts and parts[0] != skill_name: - raise ValueError( - "Zip archive contains unexpected top-level entries." - ) - - if ( - f"{skill_name}/SKILL.md" not in file_names - and f"{skill_name}/skill.md" not in file_names - ): - raise ValueError("SKILL.md not found in the skill folder.") - - with tempfile.TemporaryDirectory(dir=str(astrbot_paths.temp)) as tmp_dir: - for member in zf.infolist(): - member_name = member.filename.replace("\\", "/") - if not member_name or _is_ignored_zip_entry(member_name): - continue - zf.extract(member, tmp_dir) - src_dir = Path(tmp_dir) / skill_name - _normalize_skill_markdown_path(src_dir) - if not src_dir.exists(): - raise ValueError("Skill folder not found after extraction.") - dest_dir = Path(self.skills_root) / skill_name - if dest_dir.exists(): - if not overwrite: - raise FileExistsError("Skill already exists.") - shutil.rmtree(dest_dir) - shutil.move(str(src_dir), str(dest_dir)) - - self.set_skill_active(skill_name, True) - return skill_name diff --git a/astrbot/_internal/skills/parser.py b/astrbot/_internal/skills/parser.py deleted file mode 100644 index 30c4134f8..000000000 --- a/astrbot/_internal/skills/parser.py +++ /dev/null @@ -1,81 +0,0 @@ -"""SKILL.md parser for extracting frontmatter and content.""" - -from __future__ import annotations - -from pathlib import Path - -import yaml - - -def parse_frontmatter(text: str) -> dict: - """Extract metadata from YAML frontmatter. - - Expects the standard SKILL.md format used by OpenAI Codex CLI and - Anthropic Claude Skills:: - - --- - name: my-skill - description: What this skill does and when to use it. - input_schema: ... - output_schema: ... - --- - """ - if not text.startswith("---"): - return {} - lines = text.splitlines() - if not lines or lines[0].strip() != "---": - return {} - end_idx = None - for i in range(1, len(lines)): - if lines[i].strip() == "---": - end_idx = i - break - if end_idx is None: - return {} - - frontmatter = "\n".join(lines[1:end_idx]) - try: - payload = yaml.safe_load(frontmatter) or {} - except yaml.YAMLError: - return {} - if not isinstance(payload, dict): - return {} - - return payload - - -def parse_skill_markdown(path: Path) -> dict: - """Parse a SKILL.md file and return frontmatter + content. - - Args: - path: Path to the SKILL.md file - - Returns: - dict with keys: frontmatter (dict), content (str) - """ - try: - text = path.read_text(encoding="utf-8") - except Exception: - return {"frontmatter": {}, "content": ""} - - frontmatter = parse_frontmatter(text) - - # Extract content after frontmatter - if text.startswith("---"): - lines = text.splitlines() - end_idx = None - for i in range(1, len(lines)): - if lines[i].strip() == "---": - end_idx = i - break - if end_idx is not None: - content = "\n".join(lines[end_idx + 1 :]).strip() - else: - content = "" - else: - content = text - - return { - "frontmatter": frontmatter, - "content": content, - } diff --git a/astrbot/_internal/skills/prompt_builder.py b/astrbot/_internal/skills/prompt_builder.py deleted file mode 100644 index 940db1c22..000000000 --- a/astrbot/_internal/skills/prompt_builder.py +++ /dev/null @@ -1,158 +0,0 @@ -"""Skills prompt builder - builds the system prompt section for skills.""" - -from __future__ import annotations - -import json -import os -import re -import shlex - -from .manager import SkillInfo - -# Regex for sanitizing paths used in prompt examples — only allow -# safe path characters to prevent prompt injection via crafted skill paths. -_SAFE_PATH_RE = re.compile(r"[^\w./ ,()'\-]", re.UNICODE) -_WINDOWS_DRIVE_PATH_RE = re.compile(r"^[A-Za-z]:(?:/|\\)") -_WINDOWS_UNC_PATH_RE = re.compile(r"^(//|\\\\)[^/\\]+[/\\][^/\\]+") -_CONTROL_CHARS_RE = re.compile(r"[\x00-\x1F\x7F]") - -SANDBOX_WORKSPACE_ROOT = "/workspace" -SANDBOX_SKILLS_ROOT = "skills" - - -def _is_windows_prompt_path(path: str) -> bool: - if os.name != "nt": - return False - return bool(_WINDOWS_DRIVE_PATH_RE.match(path) or _WINDOWS_UNC_PATH_RE.match(path)) - - -def _sanitize_prompt_path_for_prompt(path: str) -> str: - if not path: - return "" - - if _WINDOWS_DRIVE_PATH_RE.match(path) or _WINDOWS_UNC_PATH_RE.match(path): - path = path.replace("\\", "/") - - drive_prefix = "" - if _WINDOWS_DRIVE_PATH_RE.match(path): - drive_prefix = path[:2] - path = path[2:] - - path = path.replace("`", "") - path = _CONTROL_CHARS_RE.sub("", path) - sanitized = _SAFE_PATH_RE.sub("", path) - return f"{drive_prefix}{sanitized}" - - -def _sanitize_prompt_description(description: str) -> str: - description = description.replace("`", "") - description = _CONTROL_CHARS_RE.sub(" ", description) - description = " ".join(description.split()) - return description - - -_SKILL_NAME_RE = re.compile(r"^[A-Za-z0-9._-]+$") - - -def _sanitize_skill_display_name(name: str) -> str: - if _SKILL_NAME_RE.fullmatch(name): - return name - return "" - - -def _default_sandbox_skill_path(name: str) -> str: - return f"{SANDBOX_WORKSPACE_ROOT}/{SANDBOX_SKILLS_ROOT}/{name}/SKILL.md" - - -def _build_skill_read_command_example(path: str) -> str: - if path == "//SKILL.md": - return f"cat {path}" - if _is_windows_prompt_path(path): - command = "type" - path_arg = f'"{os.path.normpath(path)}"' - else: - command = "cat" - path_arg = shlex.quote(path) - return f"{command} {path_arg}" - - -def build_skills_prompt(skills: list[SkillInfo]) -> str: - """Build the skills section of the system prompt. - - Generates a markdown-formatted skill inventory for the LLM. Only - ``name`` and ``description`` are shown upfront; the LLM must read - the full ``SKILL.md`` before execution (progressive disclosure). - """ - skills_lines: list[str] = [] - example_path = "" - for skill in skills: - display_name = _sanitize_skill_display_name(skill.name) - - description = skill.description or "No description" - if skill.source_type == "sandbox_only": - description = _sanitize_prompt_description(description) - if not description: - description = "Read SKILL.md for details." - - if skill.source_type == "sandbox_only": - # Prefer the actual path from sandbox cache if available - rendered_path = _sanitize_prompt_path_for_prompt(skill.path) - if not rendered_path: - rendered_path = _default_sandbox_skill_path(skill.name) - else: - rendered_path = _sanitize_prompt_path_for_prompt(skill.path) - if not rendered_path: - rendered_path = "//SKILL.md" - - entry = f"- **{display_name}**: {description}\n File: `{rendered_path}`" - if skill.input_schema: - entry += f"\n Input Schema: {json.dumps(skill.input_schema, ensure_ascii=False)}" - if skill.output_schema: - entry += f"\n Output Schema: {json.dumps(skill.output_schema, ensure_ascii=False)}" - skills_lines.append(entry) - if not example_path: - example_path = rendered_path - skills_block = "\n".join(skills_lines) - # Sanitize example_path — it may originate from sandbox cache (untrusted) - if example_path == "//SKILL.md": - example_path = "//SKILL.md" - else: - example_path = _sanitize_prompt_path_for_prompt(example_path) - example_path = example_path or "//SKILL.md" - example_command = _build_skill_read_command_example(example_path) - - return ( - "## Skills\n\n" - "You have specialized skills — reusable instruction bundles stored " - "in `SKILL.md` files. Each skill has a **name** and a **description** " - "that tells you what it does and when to use it.\n\n" - "### Available skills\n\n" - f"{skills_block}\n\n" - "### Skill rules\n\n" - "1. **Discovery** — The list above is the complete skill inventory " - "for this session. Full instructions are in the referenced " - "`SKILL.md` file.\n" - "2. **When to trigger** — Use a skill if the user names it " - "explicitly, or if the task clearly matches the skill's description. " - "*Never silently skip a matching skill* — either use it or briefly " - "explain why you chose not to.\n" - "3. **Mandatory grounding** — Before executing any skill you MUST " - "first read its `SKILL.md` by running a shell command compatible " - "with the current runtime shell and using the **absolute path** " - f"shown above (e.g. `{example_command}`). " - "Never rely on memory or assumptions about a skill's content.\n" - "4. **Progressive disclosure** — Load only what is directly " - "referenced from `SKILL.md`:\n" - " - If `scripts/` exist, prefer running or patching them over " - "rewriting code from scratch.\n" - " - If `assets/` or templates exist, reuse them.\n" - " - Do NOT bulk-load every file in the skill directory.\n" - "5. **Coordination** — When multiple skills apply, pick the minimal " - "set needed. Announce which skill(s) you are using and why " - "(one short line). Prefer `astrbot_*` tools when running skill " - "scripts.\n" - "6. **Context hygiene** — Avoid deep reference chasing; open only " - "files that are directly linked from `SKILL.md`.\n" - "7. **Failure handling** — If a skill cannot be applied, state the " - "issue clearly and continue with the best alternative.\n" - ) diff --git a/astrbot/_internal/skills/to_tool.py b/astrbot/_internal/skills/to_tool.py deleted file mode 100644 index 0b2c6a717..000000000 --- a/astrbot/_internal/skills/to_tool.py +++ /dev/null @@ -1,79 +0,0 @@ -"""Skill to Tool converter - converts skills with input_schema to FunctionTool.""" - -from __future__ import annotations - -from collections.abc import Awaitable, Callable -from typing import Any - -from pydantic.dataclasses import dataclass - -from astrbot.core.agent.tool import FunctionTool - -from .manager import SkillInfo - - -@dataclass -class SkillToToolConverter: - """Converter that transforms skills with input_schema into FunctionTool instances. - - This enables skills to be used as callable tools in the agent's function calling - system, providing a structured way to invoke skills with parameters. - """ - - def can_convert(self, skill: SkillInfo) -> bool: - """Check if a skill can be converted to a FunctionTool. - - Args: - skill: The skill to check - - Returns: - True if the skill has an input_schema, False otherwise - """ - return skill.input_schema is not None - - def convert(self, skill: SkillInfo) -> FunctionTool: - """Convert a skill to a FunctionTool. - - Args: - skill: The skill to convert. Must have an input_schema. - - Returns: - A FunctionTool with name=f"skill_{skill.name}", description, parameters, - and a handler that executes the skill. - - Raises: - ValueError: If the skill does not have an input_schema - """ - if not self.can_convert(skill): - raise ValueError( - f"Skill '{skill.name}' cannot be converted to FunctionTool: " - "no input_schema defined" - ) - - tool_name = f"skill_{skill.name}" - tool_description = skill.description or f"Skill: {skill.name}" - - # Create a handler that executes the skill - # The actual execution is delegated to the skill execution system - async def skill_handler( - context: Any = None, - **kwargs: Any, - ) -> str | None: - # This is a placeholder handler that integrates with the skill system - # The actual skill execution would happen here through the skill manager - # For now, we return a message indicating the skill should be executed - # through the standard skill execution flow - return f"Skill '{skill.name}' execution requested with params: {kwargs}" - - handler: Callable[..., Awaitable[str | None]] = skill_handler - - return FunctionTool( - name=tool_name, - description=tool_description, - parameters=skill.input_schema, # type: ignore[arg-type] - handler=handler, - handler_module_path="astrbot._internal.skills.to_tool", - active=True, - is_background_task=False, - source="skill", - ) diff --git a/astrbot/_internal/tools/__init__.py b/astrbot/_internal/tools/__init__.py deleted file mode 100644 index d34d22f55..000000000 --- a/astrbot/_internal/tools/__init__.py +++ /dev/null @@ -1,9 +0,0 @@ -"""Internal tools module for AstrBot. - -This module provides internal tool abstractions and registry functionality -for the AstrBot framework. -""" - -from astrbot._internal.tools.base import FunctionTool, ToolExecResult - -__all__ = ["FunctionTool", "ToolExecResult"] diff --git a/astrbot/_internal/tools/base.py b/astrbot/_internal/tools/base.py deleted file mode 100644 index e69bf7cbc..000000000 --- a/astrbot/_internal/tools/base.py +++ /dev/null @@ -1,442 +0,0 @@ -"""Internal base tool definitions for AstrBot. - -This module provides the core tool abstractions used throughout AstrBot, -including tool schemas, callable function tools, and tool sets for -managing multiple tools. -""" - -from __future__ import annotations - -import copy -from collections.abc import AsyncGenerator, Awaitable, Callable -from typing import Any, Generic - -import jsonschema -import mcp -from pydantic import Field, model_validator -from pydantic.dataclasses import dataclass - -from astrbot.core.agent.run_context import ContextWrapper, TContext -from astrbot.core.message.message_event_result import MessageEventResult - -ParametersType = dict[str, Any] -ToolExecResult = str | mcp.types.CallToolResult - - -@dataclass -class ToolSchema: - """A class representing the schema of a tool for function calling. - - ToolSchema defines the interface contract for a tool, including its - name, description, and parameter specification in JSON Schema format. - The parameters are validated against the JSON Schema Draft 2020-12. - """ - - name: str - """The name of the tool.""" - - description: str - """The description of the tool.""" - - parameters: ParametersType - """The parameters of the tool, in JSON Schema format.""" - - @model_validator(mode="after") - def validate_parameters(self) -> ToolSchema: - """Validate that parameters conform to JSON Schema Draft 2020-12.""" - jsonschema.validate( - self.parameters, jsonschema.Draft202012Validator.META_SCHEMA - ) - return self - - -@dataclass -class FunctionTool(ToolSchema, Generic[TContext]): - """A callable tool, for function calling. - - FunctionTool represents an executable tool that can be called by the LLM. - It extends ToolSchema with a handler callable that performs the actual - tool execution. The handler should be an async function. - - Type Parameters: - TContext: The context type for tool execution. - - Attributes: - handler: The async callable that implements the tool's functionality. - handler_module_path: Module path of the handler function for serialization. - active: Whether the tool is active and should be used. - is_background_task: Whether this tool runs as a background task. - source: Origin of the tool ('plugin', 'internal', or 'mcp'). - """ - - handler: ( - Callable[..., Awaitable[str | None] | AsyncGenerator[MessageEventResult, None]] - | None - ) = None - """A callable that implements the tool's functionality. It should be an async function.""" - - handler_module_path: str | None = None - """ - The module path of the handler function. This is empty when the origin is mcp. - This field must be retained, as the handler will be wrapped in functools.partial during initialization, - causing the handler's __module__ to be functools - """ - active: bool = True - """ - Whether the tool is active. This field is a special field for AstrBot. - You can ignore it when integrating with other frameworks. - """ - is_background_task: bool = False - """ - Declare this tool as a background task. Background tasks return immediately - with a task identifier while the real work continues asynchronously. - """ - source: str = "plugin" - """ - Origin of this tool: 'plugin' (from star plugins), 'internal' (AstrBot built-in), - or 'mcp' (from MCP servers). Used by WebUI for display grouping. - """ - - def __repr__(self) -> str: - return f"FuncTool(name={self.name}, parameters={self.parameters}, description={self.description})" - - async def call( - self, context: ContextWrapper[TContext], **kwargs: Any - ) -> ToolExecResult: - """Run the tool with the given arguments. The handler field has priority. - - Raises: - NotImplementedError: If no handler is set. - """ - raise NotImplementedError( - "FunctionTool.call() must be implemented by subclasses or set a handler." - ) - - -@dataclass -class ToolSet: - """A set of function tools that can be used in function calling. - - This class provides methods to add, remove, and retrieve tools, as well as - convert the tools to different API formats (OpenAI, Anthropic, Google GenAI). - - Tools can be added with add_tool(), which handles duplicate names by - preferring active tools. The normalize() method sorts tools by name - for deterministic serialization. - """ - - tools: list[FunctionTool] = Field(default_factory=list) - - def empty(self) -> bool: - """Check if the tool set is empty.""" - return len(self.tools) == 0 - - def add_tool(self, tool: FunctionTool) -> None: - """Add a tool to the set. - - If a tool with the same name already exists: - - Prefer the one that is active (active=True) - - If both have the same active state, use the new one (overwrite) - """ - for i, existing_tool in enumerate(self.tools): - if existing_tool.name == tool.name: - # Use getattr with a default of True for compatibility with tools - # that may not define an `active` attribute (e.g., mocks). - existing_active = bool(getattr(existing_tool, "active", True)) - new_active = bool(getattr(tool, "active", True)) - # Overwrite if new tool is active, or if existing tool is not active - if new_active or not existing_active: - self.tools[i] = tool - return - self.tools.append(tool) - - def remove_tool(self, name: str) -> None: - """Remove a tool by its name.""" - self.tools = [tool for tool in self.tools if tool.name != name] - - def normalize(self) -> None: - """Sort tools by name for deterministic serialization. - - This ensures the serialized tool schema sent to the LLM is - identical across requests regardless of registration/injection - order, enabling LLM provider prefix cache hits. - """ - self.tools.sort(key=lambda t: t.name) - - def get_tool(self, name: str) -> FunctionTool | None: - """Get a tool by its name.""" - for tool in self.tools: - if tool.name == name: - return tool - return None - - def get_light_tool_set(self) -> ToolSet: - """Return a light tool set with only name/description.""" - light_tools = [] - for tool in self.tools: - if hasattr(tool, "active") and not tool.active: - continue - light_params = { - "type": "object", - "properties": {}, - } - light_tools.append( - FunctionTool( - name=tool.name, - description=tool.description, - parameters=light_params, - handler=None, - ) - ) - return ToolSet(light_tools) - - def get_param_only_tool_set(self) -> ToolSet: - """Return a tool set with name/parameters only (no description).""" - param_tools = [] - for tool in self.tools: - if hasattr(tool, "active") and not tool.active: - continue - params = ( - copy.deepcopy(tool.parameters) - if tool.parameters - else {"type": "object", "properties": {}} - ) - param_tools.append( - FunctionTool( - name=tool.name, - description="", - parameters=params, - handler=None, - ) - ) - return ToolSet(param_tools) - - def add_func( - self, - name: str, - func_args: list, - desc: str, - handler: Callable[..., Awaitable[Any]], - ) -> None: - """Add a function tool to the set. - - .. deprecated:: 4.0.0 - Use add_tool() instead. - """ - params = { - "type": "object", # hard-coded here - "properties": {}, - } - for param in func_args: - params["properties"][param["name"]] = { - "type": param["type"], - "description": param["description"], - } - _func = FunctionTool( - name=name, - parameters=params, - description=desc, - handler=handler, - ) - self.add_tool(_func) - - def remove_func(self, name: str) -> None: - """Remove a function tool by its name. - - .. deprecated:: 4.0.0 - Use remove_tool() instead. - """ - self.remove_tool(name) - - def get_func(self, name: str) -> FunctionTool | None: - """Get all function tools. - - .. deprecated:: 4.0.0 - Use get_tool() instead. - """ - return self.get_tool(name) - - @property - def func_list(self) -> list[FunctionTool]: - """Get the list of function tools.""" - return self.tools - - def openai_schema(self, omit_empty_parameter_field: bool = False) -> list[dict]: - """Convert tools to OpenAI API function calling schema format.""" - result = [] - for tool in self.tools: - func_def: dict[str, Any] = { - "type": "function", - "function": {"name": tool.name}, - } - if tool.description: - func_def["function"]["description"] = tool.description - - if tool.parameters is not None: - if ( - tool.parameters and tool.parameters.get("properties") - ) or not omit_empty_parameter_field: - func_def["function"]["parameters"] = tool.parameters # type: ignore[index] - - result.append(func_def) - return result - - def anthropic_schema(self) -> list[dict]: - """Convert tools to Anthropic API format.""" - result = [] - for tool in self.tools: - input_schema = {"type": "object"} - if tool.parameters: - input_schema["properties"] = tool.parameters.get("properties", {}) - input_schema["required"] = tool.parameters.get("required", []) - tool_def = {"name": tool.name, "input_schema": input_schema} - if tool.description: - tool_def["description"] = tool.description - result.append(tool_def) - return result - - def google_schema(self) -> dict: - """Convert tools to Google GenAI API format.""" - - def convert_schema(schema: dict) -> dict: - """Convert schema to Gemini API format.""" - supported_types = { - "string", - "number", - "integer", - "boolean", - "array", - "object", - "null", - } - supported_formats = { - "string": {"enum", "date-time"}, - "integer": {"int32", "int64"}, - "number": {"float", "double"}, - } - - if "anyOf" in schema: - return {"anyOf": [convert_schema(s) for s in schema["anyOf"]]} - - result = {} - - # Avoid side effects by not modifying the original schema - origin_type = schema.get("type") - target_type = origin_type - - # Compatibility fix: Gemini API expects 'type' to be a string (enum), - # but standard JSON Schema (MCP) allows lists (e.g. ["string", "null"]). - # We fallback to the first non-null type. - if isinstance(origin_type, list): - target_type = next((t for t in origin_type if t != "null"), "string") - - if target_type in supported_types: - result["type"] = target_type - if "format" in schema and schema["format"] in supported_formats.get( - result["type"], - set(), - ): - result["format"] = schema["format"] - else: - result["type"] = "null" - - support_fields = { - "title", - "description", - "enum", - "minimum", - "maximum", - "maxItems", - "minItems", - "nullable", - "required", - } - result.update({k: schema[k] for k in support_fields if k in schema}) - - if "properties" in schema: - properties = {} - for key, value in schema["properties"].items(): - prop_value = convert_schema(value) - if "default" in prop_value: - del prop_value["default"] - # see #5217 - if "additionalProperties" in prop_value: - del prop_value["additionalProperties"] - properties[key] = prop_value - - if properties: - result["properties"] = properties - - if target_type == "array": - items_schema = schema.get("items") - if isinstance(items_schema, dict): - result["items"] = convert_schema(items_schema) - else: - # Gemini requires array schemas to include an `items` schema. - # JSON Schema allows omitting it, so fall back to a permissive - # string item schema instead of emitting an invalid declaration. - result["items"] = {"type": "string"} - - return result - - tools = [] - for tool in self.tools: - d: dict[str, Any] = {"name": tool.name} - if tool.description: - d["description"] = tool.description - if tool.parameters: - d["parameters"] = convert_schema(tool.parameters) - tools.append(d) - - declarations = {} - if tools: - declarations["function_declarations"] = tools - return declarations - - def get_func_desc_openai_style(self, omit_empty_parameter_field: bool = False): - """Get OpenAI style function descriptions. - - .. deprecated:: 4.0.0 - Use openai_schema() instead. - """ - return self.openai_schema(omit_empty_parameter_field) - - def get_func_desc_anthropic_style(self): - """Get Anthropic style function descriptions. - - .. deprecated:: 4.0.0 - Use anthropic_schema() instead. - """ - return self.anthropic_schema() - - def get_func_desc_google_genai_style(self): - """Get Google GenAI style function descriptions. - - .. deprecated:: 4.0.0 - Use google_schema() instead. - """ - return self.google_schema() - - def names(self) -> list[str]: - """Get a list of all tool names.""" - return [tool.name for tool in self.tools] - - def merge(self, other: ToolSet) -> None: - """Merge another ToolSet into this one.""" - for tool in other.tools: - self.add_tool(tool) - - def __len__(self) -> int: - return len(self.tools) - - def __bool__(self) -> bool: - return len(self.tools) > 0 - - def __iter__(self): - return iter(self.tools) - - def __repr__(self) -> str: - return f"ToolSet(tools={self.tools})" - - def __str__(self) -> str: - return f"ToolSet(tools={self.tools})" diff --git a/astrbot/_internal/tools/builtin/__init__.py b/astrbot/_internal/tools/builtin/__init__.py deleted file mode 100644 index b05c44cfc..000000000 --- a/astrbot/_internal/tools/builtin/__init__.py +++ /dev/null @@ -1,52 +0,0 @@ -"""Built-in tools for AstrBot. - -This module provides access to all AstrBot built-in tools that are part of -the internal tool providers system. -""" - -from astrbot._internal.tools.base import FunctionTool -from astrbot._internal.tools.builtin.cron import ( - CREATE_CRON_JOB_TOOL, - DELETE_CRON_JOB_TOOL, - LIST_CRON_JOBS_TOOL, -) -from astrbot._internal.tools.builtin.cron import ( - get_all_tools as cron_get_all_tools, -) -from astrbot._internal.tools.builtin.kb_query import ( - KNOWLEDGE_BASE_QUERY_TOOL, -) -from astrbot._internal.tools.builtin.kb_query import ( - get_all_tools as kb_query_get_all_tools, -) -from astrbot._internal.tools.builtin.send_message import ( - SEND_MESSAGE_TO_USER_TOOL, -) -from astrbot._internal.tools.builtin.send_message import ( - get_all_tools as send_message_get_all_tools, -) - - -def get_all_tools() -> list[FunctionTool]: - """Return all built-in tools for registration. - - This aggregates tools from all built-in tool modules: - - cron tools (create/delete/list future tasks) - - knowledge base query tool - - send message tool - """ - tools: list[FunctionTool] = [] - tools.extend(cron_get_all_tools()) - tools.extend(kb_query_get_all_tools()) - tools.extend(send_message_get_all_tools()) - return tools - - -__all__ = [ - "CREATE_CRON_JOB_TOOL", - "DELETE_CRON_JOB_TOOL", - "KNOWLEDGE_BASE_QUERY_TOOL", - "LIST_CRON_JOBS_TOOL", - "SEND_MESSAGE_TO_USER_TOOL", - "get_all_tools", -] diff --git a/astrbot/_internal/tools/builtin/cron.py b/astrbot/_internal/tools/builtin/cron.py deleted file mode 100644 index ec53958f2..000000000 --- a/astrbot/_internal/tools/builtin/cron.py +++ /dev/null @@ -1,201 +0,0 @@ -from datetime import datetime -from typing import Any - -from pydantic import Field -from pydantic.dataclasses import dataclass - -from astrbot._internal.tools.base import FunctionTool, ToolExecResult -from astrbot.core.agent.run_context import ContextWrapper -from astrbot.core.astr_agent_context import AstrAgentContext - - -def _extract_job_session(job: Any) -> str | None: - payload = getattr(job, "payload", None) - if not isinstance(payload, dict): - return None - session = payload.get("session") - return str(session) if session is not None else None - - -@dataclass -class CreateActiveCronTool(FunctionTool[AstrAgentContext]): - name: str = "create_future_task" - description: str = ( - "Create a future task for your future. Supports recurring cron expressions or one-time run_at datetime. " - "Use this when you or the user want scheduled follow-up or proactive actions." - ) - parameters: dict = Field( - default_factory=lambda: { - "type": "object", - "properties": { - "cron_expression": { - "type": "string", - "description": "Cron expression defining recurring schedule (e.g., '0 8 * * *' or '0 23 * * mon-fri'). Prefer named weekdays like 'mon-fri' or 'sat,sun' instead of numeric day-of-week ranges such as '1-5' to avoid ambiguity across cron implementations.", - }, - "run_at": { - "type": "string", - "description": "ISO datetime for one-time execution, e.g., 2026-02-02T08:00:00+08:00. Use with run_once=true.", - }, - "note": { - "type": "string", - "description": "Detailed instructions for your future agent to execute when it wakes.", - }, - "name": { - "type": "string", - "description": "Optional label to recognize this future task.", - }, - "run_once": { - "type": "boolean", - "description": "If true, the task will run only once and then be deleted. Use run_at to specify the time.", - }, - }, - "required": ["note"], - } - ) - - async def call( - self, context: ContextWrapper[AstrAgentContext], **kwargs - ) -> ToolExecResult: - cron_mgr = context.context.context.cron_manager - if cron_mgr is None: - return "error: cron manager is not available." - - cron_expression = kwargs.get("cron_expression") - run_at = kwargs.get("run_at") - run_once = bool(kwargs.get("run_once", False)) - note = str(kwargs.get("note", "")).strip() - name = str(kwargs.get("name") or "").strip() or "active_agent_task" - - if not note: - return "error: note is required." - if run_once and not run_at: - return "error: run_at is required when run_once=true." - if (not run_once) and not cron_expression: - return "error: cron_expression is required when run_once=false." - if run_once and cron_expression: - cron_expression = None - run_at_dt = None - if run_at: - try: - run_at_dt = datetime.fromisoformat(str(run_at)) - except Exception: - return "error: run_at must be ISO datetime, e.g., 2026-02-02T08:00:00+08:00" - - payload = { - "session": context.context.event.unified_msg_origin, - "sender_id": context.context.event.get_sender_id(), - "note": note, - "origin": "tool", - } - - job = await cron_mgr.add_active_job( - name=name, - cron_expression=str(cron_expression) if cron_expression else None, - payload=payload, - description=note, - run_once=run_once, - run_at=run_at_dt, - ) - next_run = job.next_run_time or run_at_dt - suffix = ( - f"one-time at {next_run}" - if run_once - else f"expression '{cron_expression}' (next {next_run})" - ) - return f"Scheduled future task {job.job_id} ({job.name}) {suffix}." - - -@dataclass -class DeleteCronJobTool(FunctionTool[AstrAgentContext]): - name: str = "delete_future_task" - description: str = "Delete a future task (cron job) by its job_id." - parameters: dict = Field( - default_factory=lambda: { - "type": "object", - "properties": { - "job_id": { - "type": "string", - "description": "The job_id returned when the job was created.", - } - }, - "required": ["job_id"], - } - ) - - async def call( - self, context: ContextWrapper[AstrAgentContext], **kwargs - ) -> ToolExecResult: - cron_mgr = context.context.context.cron_manager - if cron_mgr is None: - return "error: cron manager is not available." - current_umo = context.context.event.unified_msg_origin - job_id = kwargs.get("job_id") - if not job_id: - return "error: job_id is required." - job = await cron_mgr.db.get_cron_job(str(job_id)) - if not job: - return f"error: cron job {job_id} not found." - if _extract_job_session(job) != current_umo: - return "error: you can only delete future tasks in the current umo." - await cron_mgr.delete_job(str(job_id)) - return f"Deleted cron job {job_id}." - - -@dataclass -class ListCronJobsTool(FunctionTool[AstrAgentContext]): - name: str = "list_future_tasks" - description: str = "List existing future tasks (cron jobs) for inspection." - parameters: dict = Field( - default_factory=lambda: { - "type": "object", - "properties": { - "job_type": { - "type": "string", - "description": "Optional filter: basic or active_agent.", - } - }, - } - ) - - async def call( - self, context: ContextWrapper[AstrAgentContext], **kwargs - ) -> ToolExecResult: - cron_mgr = context.context.context.cron_manager - if cron_mgr is None: - return "error: cron manager is not available." - current_umo = context.context.event.unified_msg_origin - job_type = kwargs.get("job_type") - jobs = [ - job - for job in await cron_mgr.list_jobs(job_type) - if _extract_job_session(job) == current_umo - ] - if not jobs: - return "No cron jobs found." - lines = [] - for j in jobs: - lines.append( - f"{j.job_id} | {j.name} | {j.job_type} | run_once={getattr(j, 'run_once', False)} | enabled={j.enabled} | next={j.next_run_time}" - ) - return "\n".join(lines) - - -CREATE_CRON_JOB_TOOL = CreateActiveCronTool() -DELETE_CRON_JOB_TOOL = DeleteCronJobTool() -LIST_CRON_JOBS_TOOL = ListCronJobsTool() - - -def get_all_tools() -> list[FunctionTool]: - """Return all cron-related tools for registration.""" - return [CREATE_CRON_JOB_TOOL, DELETE_CRON_JOB_TOOL, LIST_CRON_JOBS_TOOL] - - -__all__ = [ - "CREATE_CRON_JOB_TOOL", - "DELETE_CRON_JOB_TOOL", - "LIST_CRON_JOBS_TOOL", - "CreateActiveCronTool", - "DeleteCronJobTool", - "ListCronJobsTool", - "get_all_tools", -] diff --git a/astrbot/_internal/tools/builtin/kb_query.py b/astrbot/_internal/tools/builtin/kb_query.py deleted file mode 100644 index f6392bafd..000000000 --- a/astrbot/_internal/tools/builtin/kb_query.py +++ /dev/null @@ -1,139 +0,0 @@ -"""Knowledge base query tool and retrieval logic. - -Extracted from ``astr_main_agent_resources.py`` to its own module. -""" - -from __future__ import annotations - -from typing import TYPE_CHECKING - -from pydantic import Field -from pydantic.dataclasses import dataclass - -from astrbot._internal.tools.base import FunctionTool, ToolExecResult -from astrbot.api import logger, sp -from astrbot.core.agent.run_context import ContextWrapper -from astrbot.core.astr_agent_context import AstrAgentContext - -if TYPE_CHECKING: - from astrbot.core.star.context import Context - - -@dataclass -class KnowledgeBaseQueryTool(FunctionTool[AstrAgentContext]): - name: str = "astr_kb_search" - description: str = ( - "Query the knowledge base for facts or relevant context. " - "Use this tool when the user's question requires factual information, " - "definitions, background knowledge, or previously indexed content. " - "Only send short keywords or a concise question as the query." - ) - parameters: dict = Field( - default_factory=lambda: { - "type": "object", - "properties": { - "query": { - "type": "string", - "description": "A concise keyword query for the knowledge base.", - }, - }, - "required": ["query"], - } - ) - - async def call( - self, context: ContextWrapper[AstrAgentContext], **kwargs - ) -> ToolExecResult: - query = kwargs.get("query", "") - if not query: - return "error: Query parameter is empty." - result = await retrieve_knowledge_base( - query=kwargs.get("query", ""), - umo=context.context.event.unified_msg_origin, - context=context.context.context, - ) - if not result: - return "No relevant knowledge found." - return result - - -async def retrieve_knowledge_base( - query: str, - umo: str, - context: Context, -) -> str | None: - """Inject knowledge base context into the provider request - - Args: - query: The search query string - umo: Unique message object (session ID) - context: Star context - """ - kb_mgr = context.kb_manager - config = context.get_config(umo=umo) - - # 1. Prefer session-level config - session_config = await sp.session_get(umo, "kb_config", default={}) - - if session_config and "kb_ids" in session_config: - kb_ids = session_config.get("kb_ids", []) - - if not kb_ids: - logger.info(f"[知识库] 会话 {umo} 已被配置为不使用知识库") - return - - top_k = session_config.get("top_k", 5) - - kb_names = [] - invalid_kb_ids = [] - for kb_id in kb_ids: - kb_helper = await kb_mgr.get_kb(kb_id) - if kb_helper: - kb_names.append(kb_helper.kb.kb_name) - else: - logger.warning(f"[知识库] 知识库不存在或未加载: {kb_id}") - invalid_kb_ids.append(kb_id) - - if invalid_kb_ids: - logger.warning( - f"[知识库] 会话 {umo} 配置的以下知识库无效: {invalid_kb_ids}", - ) - - if not kb_names: - return - - logger.debug(f"[知识库] 使用会话级配置,知识库数量: {len(kb_names)}") - else: - kb_names = config.get("kb_names", []) - top_k = config.get("kb_final_top_k", 5) - logger.debug(f"[知识库] 使用全局配置,知识库数量: {len(kb_names)}") - - top_k_fusion = config.get("kb_fusion_top_k", 20) - - if not kb_names: - return - - logger.debug(f"[知识库] 开始检索知识库,数量: {len(kb_names)}, top_k={top_k}") - kb_context = await kb_mgr.retrieve( - query=query, - kb_names=kb_names, - top_k_fusion=top_k_fusion, - top_m_final=top_k, - ) - - if not kb_context: - return - - formatted = kb_context.get("context_text", "") - if formatted: - results = kb_context.get("results", []) - logger.debug(f"[知识库] 为会话 {umo} 注入了 {len(results)} 条相关知识块") - return formatted - - -KNOWLEDGE_BASE_QUERY_TOOL = KnowledgeBaseQueryTool() - - -def get_all_tools() -> list[FunctionTool]: - """Return all knowledge-base tools for registration.""" - return [KNOWLEDGE_BASE_QUERY_TOOL] diff --git a/astrbot/_internal/tools/builtin/send_message.py b/astrbot/_internal/tools/builtin/send_message.py deleted file mode 100644 index 9125c6daa..000000000 --- a/astrbot/_internal/tools/builtin/send_message.py +++ /dev/null @@ -1,226 +0,0 @@ -"""SendMessageToUserTool — proactive message delivery to users. - -Extracted from ``astr_main_agent_resources.py`` to its own module. -""" - -from __future__ import annotations - -import json -import os -import uuid -from typing import Any, TypedDict, cast - -import anyio -from pydantic import Field -from pydantic.dataclasses import dataclass - -import astrbot.core.message.components as Comp -from astrbot._internal.tools.base import FunctionTool, ToolExecResult -from astrbot.api import logger -from astrbot.core.agent.run_context import ContextWrapper -from astrbot.core.astr_agent_context import AstrAgentContext -from astrbot.core.computer.computer_client import get_booter -from astrbot.core.message.message_event_result import MessageChain -from astrbot.core.platform.message_session import MessageSession -from astrbot.core.utils.astrbot_path import get_astrbot_temp_path - - -class MessageComponent(TypedDict, total=False): - """Type-safe message component structure.""" - - type: str - text: str - path: str - url: str - mention_user_id: str - - -@dataclass -class SendMessageToUserTool(FunctionTool[AstrAgentContext]): - name: str = "send_message_to_user" - description: str = "Directly send message to the user. Only use this tool when you need to proactively message the user. Otherwise you can directly output the reply in the conversation." - - parameters: dict = Field( - default_factory=lambda: { - "type": "object", - "properties": { - "messages": { - "type": "array", - "description": "An ordered list of message components to send. `mention_user` type can be used to mention the user.", - "items": { - "type": "object", - "additionalProperties": {"type": "string"}, - }, - }, - }, - "required": ["messages"], - } - ) - - async def _resolve_path_from_sandbox( - self, context: ContextWrapper[AstrAgentContext], path: str - ) -> tuple[str, bool]: - """ - If the path exists locally, return it directly. - Otherwise, check if it exists in the sandbox and download it. - - bool: indicates whether the file was downloaded from sandbox. - """ - if await anyio.Path(path).exists(): - return path, False - - # Try to check if the file exists in the sandbox - try: - sb = await get_booter( - context.context.context, - context.context.event.unified_msg_origin, - ) - # Use shell to check if the file exists in sandbox - 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 = anyio.Path(path).name - local_path = os.path.join( - get_astrbot_temp_path(), f"sandbox_{uuid.uuid4().hex[:4]}_{name}" - ) - await sb.download_file(path, local_path) - logger.info(f"Downloaded file from sandbox: {path} -> {local_path}") - return local_path, True - except Exception as e: - logger.warning(f"Failed to check/download file from sandbox: {e}") - - # Return the original path (will likely fail later, but that's expected) - return path, False - - async def call( - self, context: ContextWrapper[AstrAgentContext], **kwargs: Any - ) -> ToolExecResult: - session: str | MessageSession = ( - kwargs.get("session") or context.context.event.unified_msg_origin - ) - messages: list[dict[str, Any]] | None = kwargs.get("messages") - - if not isinstance(messages, list) or not messages: - return "error: messages parameter is empty or invalid." - - components: list[Comp.BaseMessageComponent] = [] - - for idx, msg in enumerate(messages): - if not isinstance(msg, dict): - return f"error: messages[{idx}] should be an object." - - msg_dict: dict[str, Any] = cast(dict[str, Any], msg) - - if "type" not in msg_dict: - return f"error: messages[{idx}].type is required." - msg_type = str(msg_dict["type"]).lower() - - _file_from_sandbox = False - - try: - if msg_type == "plain": - text = str(msg_dict.get("text", "")).strip() - if not text: - return f"error: messages[{idx}].text is required for plain component." - components.append(Comp.Plain(text=text)) - elif msg_type == "image": - path = msg_dict.get("path") - url = msg_dict.get("url") - if path: - ( - local_path, - _file_from_sandbox, - ) = await self._resolve_path_from_sandbox(context, path) - components.append(Comp.Image.fromFileSystem(path=local_path)) - elif url: - components.append(Comp.Image.fromURL(url=url)) - else: - return f"error: messages[{idx}] must include path or url for image component." - elif msg_type == "record": - path = msg_dict.get("path") - url = msg_dict.get("url") - if path: - ( - local_path, - _file_from_sandbox, - ) = await self._resolve_path_from_sandbox(context, path) - components.append(Comp.Record.fromFileSystem(path=local_path)) - elif url: - components.append(Comp.Record.fromURL(url=url)) - else: - return f"error: messages[{idx}] must include path or url for record component." - elif msg_type == "video": - path = msg_dict.get("path") - url = msg_dict.get("url") - if path: - ( - local_path, - _file_from_sandbox, - ) = await self._resolve_path_from_sandbox(context, path) - components.append(Comp.Video.fromFileSystem(path=local_path)) - elif url: - components.append(Comp.Video.fromURL(url=url)) - else: - return f"error: messages[{idx}] must include path or url for video component." - elif msg_type == "file": - path = msg_dict.get("path") - url = msg_dict.get("url") - name = ( - msg_dict.get("text") - or (os.path.basename(path) if path else "") - or (os.path.basename(url) if url else "") - or "file" - ) - if path: - ( - local_path, - _file_from_sandbox, - ) = await self._resolve_path_from_sandbox(context, path) - components.append(Comp.File(name=name, file=local_path)) - elif url: - components.append(Comp.File(name=name, url=url)) - else: - return f"error: messages[{idx}] must include path or url for file component." - elif msg_type == "mention_user": - mention_user_id = msg_dict.get("mention_user_id") - if not mention_user_id: - return f"error: messages[{idx}].mention_user_id is required for mention_user component." - components.append( - Comp.At( - qq=mention_user_id, - ), - ) - else: - return ( - f"error: unsupported message type '{msg_type}' at index {idx}." - ) - except Exception as exc: - return f"error: failed to build messages[{idx}] component: {exc}" - - try: - target_session = ( - MessageSession.from_str(session) - if isinstance(session, str) - else session - ) - except Exception as e: - return f"error: invalid session: {e}" - - await context.context.context.send_message( - target_session, - MessageChain(chain=components), - ) - - return f"Message sent to session {target_session}" - - -SEND_MESSAGE_TO_USER_TOOL = SendMessageToUserTool() - - -def get_all_tools() -> list[FunctionTool]: - """Return all send-message tools for registration.""" - return [SEND_MESSAGE_TO_USER_TOOL] diff --git a/astrbot/_internal/tools/providers/__init__.py b/astrbot/_internal/tools/providers/__init__.py deleted file mode 100644 index ca91fa43d..000000000 --- a/astrbot/_internal/tools/providers/__init__.py +++ /dev/null @@ -1,25 +0,0 @@ -"""Tool providers for AstrBot. - -This module provides different tool providers that supply tools -through a unified interface: - -- InternalToolProvider: Provides built-in AstrBot tools (cron, kb_query, send_message) -- PluginToolProvider: Provides tools registered by star plugins -- ComputerToolProvider: Provides computer-use tools (shell, Python, file ops, etc.) -""" - -from astrbot._internal.tools.providers.computer import ( - ComputerToolProvider, -) -from astrbot._internal.tools.providers.internal import ( - InternalToolProvider, -) -from astrbot._internal.tools.providers.plugin import ( - PluginToolProvider, -) - -__all__ = [ - "ComputerToolProvider", - "InternalToolProvider", - "PluginToolProvider", -] diff --git a/astrbot/_internal/tools/providers/computer.py b/astrbot/_internal/tools/providers/computer.py deleted file mode 100644 index 2ddcb9d8f..000000000 --- a/astrbot/_internal/tools/providers/computer.py +++ /dev/null @@ -1,45 +0,0 @@ -"""Computer tools provider for AstrBot. - -This provider wraps the ComputerToolProvider from the computer module -to ensure computer tools are available through the unified provider interface. -""" - -from typing import TYPE_CHECKING - -from astrbot._internal.tools.base import FunctionTool - -if TYPE_CHECKING: - pass - - -class ComputerToolProvider: - """Provider for computer-use tools (local/sandbox). - - This class wraps the existing ``ComputerToolProvider`` from - ``astrbot.core.computer.computer_tool_provider`` to integrate - computer tools into the unified provider interface. - - The computer tools include shell execution, Python code execution, - file operations, browser automation, and skill management tools. - """ - - @staticmethod - def get_all_tools() -> list[FunctionTool]: - """Return all computer-use tools across all runtimes. - - Delegates to ``ComputerToolProvider.get_all_tools()`` which - collects tools from all runtimes (local, sandbox, browser, neo). - - 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. - - Returns: - list[FunctionTool]: A list of all computer FunctionTool instances. - """ - from astrbot.core.computer.computer_tool_provider import ( - ComputerToolProvider as CoreComputerToolProvider, - ) - - return CoreComputerToolProvider.get_all_tools() diff --git a/astrbot/_internal/tools/providers/internal.py b/astrbot/_internal/tools/providers/internal.py deleted file mode 100644 index b086c0754..000000000 --- a/astrbot/_internal/tools/providers/internal.py +++ /dev/null @@ -1,73 +0,0 @@ -"""Internal tools provider for AstrBot. - -This provider wraps the logic for loading built-in internal tools from -the provider modules: cron_tools, kb_query, and send_message. -""" - -from typing import TYPE_CHECKING - -from astrbot import logger -from astrbot._internal.tools.base import FunctionTool - -if TYPE_CHECKING: - pass - - -# Provider modules that supply internal tools -_INTERNAL_PROVIDER_MODULES: list[str] = [ - "astrbot.core.tools.cron_tools", - "astrbot.core.tools.kb_query", - "astrbot.core.tools.send_message", -] - - -class InternalToolProvider: - """Provider for AstrBot built-in internal tools. - - This class wraps the logic previously found in - ``FunctionToolManager._INTERNAL_TOOL_PROVIDERS`` and provides - a unified interface for loading tools from the internal provider - modules. - - Each provider module is expected to expose a ``get_all_tools()`` - function that returns a list of ``FunctionTool`` instances. - - Tools are marked with ``source='internal'`` so the WebUI can - distinguish them from plugin and MCP tools. - """ - - @staticmethod - def get_all_tools() -> list[FunctionTool]: - """Return all internal tools from all provider modules. - - Iterates through the provider modules and collects tools - from each module's ``get_all_tools()`` function. - - Returns: - list[FunctionTool]: A list of all internal FunctionTool instances. - """ - all_tools: list[FunctionTool] = [] - existing_names: set[str] = set() - - for module_path in _INTERNAL_PROVIDER_MODULES: - try: - import importlib - - mod = importlib.import_module(module_path) - provider_tools = mod.get_all_tools() - except Exception as e: - logger.warning( - "Failed to load internal tool provider %s: %s", - module_path, - e, - ) - continue - - for tool in provider_tools: - tool.source = "internal" - if tool.name not in existing_names: - all_tools.append(tool) - existing_names.add(tool.name) - logger.debug("Loaded internal tool: %s", tool.name) - - return all_tools diff --git a/astrbot/_internal/tools/providers/plugin.py b/astrbot/_internal/tools/providers/plugin.py deleted file mode 100644 index eb1b35515..000000000 --- a/astrbot/_internal/tools/providers/plugin.py +++ /dev/null @@ -1,73 +0,0 @@ -"""Plugin tools provider for AstrBot. - -This provider handles loading tools from star plugins. Plugin tools -are discovered through the star plugin system and made available -through the unified provider interface. -""" - -from astrbot._internal.tools.base import FunctionTool - - -class PluginToolProvider: - """Provider for tools from star plugins. - - This class handles loading tools that are registered by star plugins. - Plugin tools are discovered through the plugin system and integrated - into the tool registry. - - Note: Plugin tools are typically registered dynamically through the - plugin context (``Context.register_llm_tool()``) and are managed - by the ``FunctionToolManager`` in the provider module. - - This provider class serves as an integration point for the plugin - tool system with the unified internal tools architecture. - """ - - @staticmethod - def get_all_tools() -> list[FunctionTool]: - """Return all plugin-registered tools. - - This method retrieves tools that have been registered by plugins - through the ``FunctionToolManager``. It accesses the global - ``llm_tools`` instance from ``astrbot.core.provider.register``. - - Returns: - list[FunctionTool]: A list of all plugin FunctionTool instances. - """ - from astrbot.core.provider.register import llm_tools - from astrbot.core.star.star import star_map - - # Get all tools from the FunctionToolManager that are from plugins - plugin_tools: list[FunctionTool] = [] - existing_names: set[str] = set() - - for tool in llm_tools.func_list: - # Only include tools that are marked as 'plugin' source - # and belong to an activated plugin - if tool.source == "plugin": - if tool.name not in existing_names: - if tool.handler_module_path: - star_meta = star_map.get(tool.handler_module_path) - if star_meta and star_meta.activated: - plugin_tools.append(tool) - existing_names.add(tool.name) - else: - # Tools without handler_module_path are treated as plugin tools - plugin_tools.append(tool) - existing_names.add(tool.name) - - return plugin_tools - - @staticmethod - def get_tool(name: str) -> FunctionTool | None: - """Get a specific plugin tool by name. - - Args: - name: The name of the tool to retrieve. - - Returns: - FunctionTool | None: The tool if found, None otherwise. - """ - from astrbot.core.provider.register import llm_tools - - return llm_tools.get_func(name) diff --git a/astrbot/_internal/tools/registry.py b/astrbot/_internal/tools/registry.py deleted file mode 100644 index bc923f36d..000000000 --- a/astrbot/_internal/tools/registry.py +++ /dev/null @@ -1,821 +0,0 @@ -"""FunctionTool registry and manager for AstrBot. - -This module provides the FunctionToolManager class that serves as the central -registry for all function tools (built-in, plugin, and MCP). -""" - -from __future__ import annotations - -import asyncio -import json -import os -import threading -import urllib.parse -from collections.abc import AsyncGenerator, Awaitable, Callable, Mapping -from dataclasses import dataclass -from types import MappingProxyType -from typing import Any, ClassVar - -import aiofiles -import anyio - -from astrbot import logger -from astrbot.core import sp -from astrbot.core.utils.astrbot_path import get_astrbot_data_path - -from .base import FunctionTool, ToolSet - -# Deferred imports to avoid circular dependency -_mcp_client_module = None -_mcp_tool_module = None - - -def _get_mcp_client(): - global _mcp_client_module - if _mcp_client_module is None: - from astrbot._internal.mcp import client as module - _mcp_client_module = module - return _mcp_client_module - - -def _get_mcp_tool(): - global _mcp_tool_module - if _mcp_tool_module is None: - from astrbot._internal.mcp import tool as module - _mcp_tool_module = module - return _mcp_tool_module - - -DEFAULT_MCP_CONFIG = {"mcpServers": {}} - -DEFAULT_MCP_INIT_TIMEOUT_SECONDS = 180.0 -DEFAULT_ENABLE_MCP_TIMEOUT_SECONDS = 180.0 -MCP_INIT_TIMEOUT_ENV = "ASTRBOT_MCP_INIT_TIMEOUT" -ENABLE_MCP_TIMEOUT_ENV = "ASTRBOT_MCP_ENABLE_TIMEOUT" -MAX_MCP_TIMEOUT_SECONDS = 300.0 - - -class MCPInitError(Exception): - """Base exception for MCP initialization failures.""" - - -class MCPInitTimeoutError(asyncio.TimeoutError, MCPInitError): - """Raised when MCP client initialization exceeds the configured timeout.""" - - -class MCPAllServicesFailedError(MCPInitError): - """Raised when all configured MCP services fail to initialize.""" - - -class MCPShutdownTimeoutError(asyncio.TimeoutError): - """Raised when MCP shutdown exceeds the configured timeout.""" - - def __init__(self, names: list[str], timeout: float) -> None: - self.names = names - self.timeout = timeout - message = f"MCP 服务关闭超时({timeout:g} 秒):{', '.join(names)}" - super().__init__(message) - - -@dataclass -class MCPInitSummary: - total: int - success: int - failed: list[str] - - -@dataclass -class _MCPServerRuntime: - name: str - client: Any # MCPClient - shutdown_event: asyncio.Event - lifecycle_task: asyncio.Task[None] - - -class _MCPClientDictView(Mapping[str, Any]): - """Read-only view of MCP clients derived from runtime state.""" - - def __init__(self, runtime: dict[str, _MCPServerRuntime]) -> None: - self._runtime = runtime - - def __getitem__(self, key: str) -> Any: - return self._runtime[key].client - - def __iter__(self): - return iter(self._runtime) - - def __len__(self) -> int: - return len(self._runtime) - - -def _resolve_timeout( - timeout: float | str | None = None, - *, - env_name: str = MCP_INIT_TIMEOUT_ENV, - default: float = DEFAULT_MCP_INIT_TIMEOUT_SECONDS, -) -> float: - """Resolve timeout with precedence: explicit argument > env value > default.""" - source = f"环境变量 {env_name}" - if timeout is None: - timeout = os.getenv(env_name, str(default)) - else: - source = "显式参数 timeout" - - try: - timeout_value = float(timeout) - except (TypeError, ValueError): - logger.warning( - f"超时配置({source})={timeout!r} 无效,使用默认值 {default:g} 秒。" - ) - return default - - if timeout_value <= 0: - logger.warning( - f"超时配置({source})={timeout_value:g} 必须大于 0,使用默认值 {default:g} 秒。" - ) - return default - - if timeout_value > MAX_MCP_TIMEOUT_SECONDS: - logger.warning( - f"超时配置({source})={timeout_value:g} 过大,已限制为最大值 " - f"{MAX_MCP_TIMEOUT_SECONDS:g} 秒,以避免长时间等待。" - ) - return MAX_MCP_TIMEOUT_SECONDS - - return timeout_value - - -SUPPORTED_TYPES = [ - "string", - "number", - "object", - "array", - "boolean", -] - -PY_TO_JSON_TYPE = { - "int": "number", - "float": "number", - "bool": "boolean", - "str": "string", - "dict": "object", - "list": "array", - "tuple": "array", - "set": "array", -} - - -class FunctionToolManager: - """Central registry for all function tools in AstrBot. - - This class manages: - - Built-in tools (cron, KB query, send message, computer) - - Plugin tools - - MCP tools (from external MCP servers) - - Tools are stored in func_list and can be queried by name. - MCP servers are tracked separately in _mcp_server_runtime. - """ - - def __init__(self) -> None: - self.func_list: list[FunctionTool] = [] - self._mcp_server_runtime: dict[str, _MCPServerRuntime] = {} - self._mcp_server_runtime_view = MappingProxyType(self._mcp_server_runtime) - self._mcp_client_dict_view = _MCPClientDictView(self._mcp_server_runtime) - self._timeout_mismatch_warned = False - self._timeout_warn_lock = threading.Lock() - self._runtime_lock = asyncio.Lock() - self._mcp_starting: set[str] = set() - self._init_timeout_default = _resolve_timeout( - timeout=None, - env_name=MCP_INIT_TIMEOUT_ENV, - default=DEFAULT_MCP_INIT_TIMEOUT_SECONDS, - ) - self._enable_timeout_default = _resolve_timeout( - timeout=None, - env_name=ENABLE_MCP_TIMEOUT_ENV, - default=DEFAULT_ENABLE_MCP_TIMEOUT_SECONDS, - ) - self._warn_on_timeout_mismatch( - self._init_timeout_default, - self._enable_timeout_default, - ) - - @property - def mcp_client_dict(self) -> Mapping[str, Any]: - """Read-only view of MCP clients.""" - return self._mcp_client_dict_view - - @property - def mcp_server_runtime_view(self) -> Mapping[str, _MCPServerRuntime]: - """Read-only view of MCP runtime metadata.""" - return self._mcp_server_runtime_view - - @property - def mcp_server_runtime(self) -> Mapping[str, _MCPServerRuntime]: - """Backward-compatible read-only view (deprecated).""" - return self._mcp_server_runtime_view - - def empty(self) -> bool: - return len(self.func_list) == 0 - - def spec_to_func( - self, - name: str, - func_args: list[dict], - desc: str, - handler: Callable[..., Awaitable[Any] | AsyncGenerator[Any]], - ) -> FunctionTool: - params = { - "type": "object", - "properties": {}, - } - for param in func_args: - p = param.copy() - p.pop("name", None) - params["properties"][param["name"]] = p - return FunctionTool( - name=name, - parameters=params, - description=desc, - handler=handler, - ) - - def add_func( - self, - name: str, - func_args: list, - desc: str, - handler: Callable[..., Awaitable[Any] | AsyncGenerator[Any]], - ) -> None: - """Add a function tool.""" - self.remove_func(name) - self.func_list.append( - self.spec_to_func( - name=name, - func_args=func_args, - desc=desc, - handler=handler, - ), - ) - logger.info(f"添加函数调用工具: {name}") - - def remove_func(self, name: str) -> None: - """Remove a function tool by name.""" - for i, f in enumerate(self.func_list): - if f.name == name: - self.func_list.pop(i) - break - - def get_func(self, name: str) -> FunctionTool | None: - """Get a function tool by name. - - Prefers active tools, falls back to last registered. - """ - for f in reversed(self.func_list): - if f.name == name and getattr(f, "active", True): - return f - for f in reversed(self.func_list): - if f.name == name: - return f - return None - - def get_full_tool_set(self) -> ToolSet: - """Get all tools as a ToolSet.""" - tool_set = ToolSet() - for tool in self.func_list: - tool_set.add_tool(tool) - return tool_set - - @staticmethod - def _log_safe_mcp_debug_config(cfg: dict) -> None: - """Log sanitized MCP config for debugging.""" - if "command" in cfg: - cmd = cfg["command"] - executable = str(cmd[0] if isinstance(cmd, (list, tuple)) and cmd else cmd) - args_val = cfg.get("args", []) - args_count = ( - len(args_val) - if isinstance(args_val, (list, tuple)) - else (0 if args_val is None else 1) - ) - logger.debug(f" 命令可执行文件: {executable}, 参数数量: {args_count}") - return - - if "url" in cfg: - parsed = urllib.parse.urlparse(str(cfg["url"])) - host = parsed.hostname or "" - scheme = parsed.scheme or "unknown" - try: - port = f":{parsed.port}" if parsed.port else "" - except ValueError: - port = "" - logger.debug(f" 主机: {scheme}://{host}{port}") - - async def init_mcp_clients( - self, raise_on_all_failed: bool = False - ) -> MCPInitSummary: - """Initialize MCP clients from mcp_server.json config.""" - data_dir = get_astrbot_data_path() - - mcp_json_file = os.path.join(data_dir, "mcp_server.json") - mcp_json_path = anyio.Path(mcp_json_file) - if not await mcp_json_path.exists(): - async with aiofiles.open(mcp_json_file, "w", encoding="utf-8") as f: - await f.write( - json.dumps(DEFAULT_MCP_CONFIG, ensure_ascii=False, indent=4) - ) - logger.info(f"未找到 MCP 服务配置文件,已创建默认配置文件 {mcp_json_file}") - return MCPInitSummary(total=0, success=0, failed=[]) - - async with aiofiles.open(mcp_json_file, encoding="utf-8") as f: - mcp_server_json_obj: dict[str, dict] = json.loads(await f.read())[ - "mcpServers" - ] - - init_timeout = self._init_timeout_default - timeout_display = f"{init_timeout:g}" - - active_configs: list[tuple[str, dict, asyncio.Event]] = [] - for name, cfg in mcp_server_json_obj.items(): - if cfg.get("active", True): - shutdown_event = asyncio.Event() - active_configs.append((name, cfg, shutdown_event)) - - if not active_configs: - return MCPInitSummary(total=0, success=0, failed=[]) - - logger.info(f"等待 {len(active_configs)} 个 MCP 服务初始化...") - - init_tasks = [ - asyncio.create_task( - self._start_mcp_server( - name=name, - cfg=cfg, - shutdown_event=shutdown_event, - init_timeout=init_timeout, - ), - name=f"mcp-init:{name}", - ) - for (name, cfg, shutdown_event) in active_configs - ] - results = await asyncio.gather(*init_tasks, return_exceptions=True) - - success_count = 0 - failed_services: list[str] = [] - - for (name, cfg, _), result in zip(active_configs, results, strict=False): - if isinstance(result, Exception): - if isinstance(result, MCPInitTimeoutError): - logger.error( - f"Connected to MCP server {name} timeout ({timeout_display} seconds)" - ) - else: - logger.error(f"Failed to initialize MCP server {name}: {result}") - self._log_safe_mcp_debug_config(cfg) - failed_services.append(name) - async with self._runtime_lock: - self._mcp_server_runtime.pop(name, None) - continue - - success_count += 1 - - if failed_services: - logger.warning( - f"The following MCP services failed to initialize: {', '.join(failed_services)}. " - f"Please check the mcp_server.json file and server availability." - ) - - summary = MCPInitSummary( - total=len(active_configs), success=success_count, failed=failed_services - ) - logger.info( - f"MCP services initialization completed: {summary.success}/{summary.total} successful, {len(summary.failed)} failed." - ) - if summary.total > 0 and summary.success == 0: - msg = "All MCP services failed to initialize, please check the mcp_server.json and server availability." - if raise_on_all_failed: - raise MCPAllServicesFailedError(msg) - logger.error(msg) - return summary - - async def _start_mcp_server( - self, - name: str, - cfg: dict, - *, - shutdown_event: asyncio.Event | None = None, - init_timeout: float, - ) -> None: - """Start an MCP server with timeout.""" - async with self._runtime_lock: - if name in self._mcp_server_runtime or name in self._mcp_starting: - logger.warning( - f"Connected to MCP server {name}, ignoring this startup request (timeout={init_timeout:g})." - ) - self._log_safe_mcp_debug_config(cfg) - return - self._mcp_starting.add(name) - - if shutdown_event is None: - shutdown_event = asyncio.Event() - - mcp_client: Any = None - try: - mcp_client = await asyncio.wait_for( - self._init_mcp_client(name, cfg), - timeout=init_timeout, - ) - except asyncio.TimeoutError as exc: - raise MCPInitTimeoutError( - f"Connected to MCP server {name} timeout ({init_timeout:g} seconds)" - ) from exc - except Exception: - logger.error(f"Failed to initialize MCP client {name}", exc_info=True) - raise - finally: - if mcp_client is None: - async with self._runtime_lock: - self._mcp_starting.discard(name) - - async def lifecycle() -> None: - try: - await shutdown_event.wait() - logger.info(f"Received shutdown signal for MCP client {name}") - except asyncio.CancelledError: - logger.debug(f"MCP client {name} task was cancelled") - raise - finally: - await self._terminate_mcp_client(name) - - lifecycle_task = asyncio.create_task(lifecycle(), name=f"mcp-client:{name}") - async with self._runtime_lock: - self._mcp_server_runtime[name] = _MCPServerRuntime( - name=name, - client=mcp_client, - shutdown_event=shutdown_event, - lifecycle_task=lifecycle_task, - ) - self._mcp_starting.discard(name) - - async def _shutdown_runtimes( - self, - runtimes: list[_MCPServerRuntime], - shutdown_timeout: float, - *, - strict: bool = True, - ) -> list[str]: - """Shutdown runtimes and wait for lifecycle tasks.""" - lifecycle_tasks = [ - runtime.lifecycle_task - for runtime in runtimes - if not runtime.lifecycle_task.done() - ] - if not lifecycle_tasks: - return [] - - for runtime in runtimes: - runtime.shutdown_event.set() - - try: - results = await asyncio.wait_for( - asyncio.gather(*lifecycle_tasks, return_exceptions=True), - timeout=shutdown_timeout, - ) - except asyncio.TimeoutError: - pending_names = [ - runtime.name - for runtime in runtimes - if not runtime.lifecycle_task.done() - ] - for task in lifecycle_tasks: - if not task.done(): - task.cancel() - await asyncio.gather(*lifecycle_tasks, return_exceptions=True) - if strict: - raise MCPShutdownTimeoutError(pending_names, shutdown_timeout) - logger.warning( - "MCP server shutdown timeout (%s seconds), the following servers were not fully closed: %s", - f"{shutdown_timeout:g}", - ", ".join(pending_names), - ) - return pending_names - else: - for result in results: - if isinstance(result, asyncio.CancelledError): - logger.debug("MCP lifecycle task was cancelled during shutdown.") - elif isinstance(result, Exception): - logger.error( - "MCP lifecycle task failed during shutdown.", - exc_info=(type(result), result, result.__traceback__), - ) - return [] - - async def _cleanup_mcp_client_safely( - self, mcp_client: Any, name: str - ) -> None: - """Safely cleanup an MCP client.""" - try: - await mcp_client.cleanup() - except Exception as cleanup_exc: - logger.error( - f"Failed to cleanup MCP client resources {name}: {cleanup_exc}" - ) - - async def _init_mcp_client(self, name: str, config: dict) -> Any: - """Initialize a single MCP client.""" - mcp_mod = _get_mcp_client() - MCPClient = mcp_mod.MCPClient - mcp_tool_mod = _get_mcp_tool() - MCPTool = mcp_tool_mod.MCPTool - - mcp_client = MCPClient() - mcp_client.name = name - try: - await mcp_client.connect_to_server(config, name) - tools_res = await mcp_client.list_tools_and_save() - except asyncio.CancelledError: - await self._cleanup_mcp_client_safely(mcp_client, name) - raise - except Exception: - await self._cleanup_mcp_client_safely(mcp_client, name) - raise - logger.debug(f"MCP server {name} list tools response: {tools_res}") - tool_names = [tool.name for tool in tools_res.tools] - - # Remove old MCP tools for this server - self.func_list = [ - f - for f in self.func_list - if not (isinstance(f, MCPTool) and f.mcp_server_name == name) - ] - - # Add new MCP tools - for tool in mcp_client.tools: - func_tool = MCPTool( - mcp_tool=tool, - mcp_client=mcp_client, - mcp_server_name=name, - ) - self.func_list.append(func_tool) - - logger.info(f"Connected to MCP server {name}, Tools: {tool_names}") - return mcp_client - - async def _terminate_mcp_client(self, name: str) -> None: - """Terminate and cleanup an MCP client.""" - async with self._runtime_lock: - runtime = self._mcp_server_runtime.get(name) - if runtime: - client = runtime.client - await self._cleanup_mcp_client_safely(client, name) - self.func_list = [ - f - for f in self.func_list - if not (isinstance(f, _get_mcp_tool().MCPTool) and f.mcp_server_name == name) - ] - async with self._runtime_lock: - self._mcp_server_runtime.pop(name, None) - self._mcp_starting.discard(name) - logger.info(f"Disconnected from MCP server {name}") - return - - self.func_list = [ - f - for f in self.func_list - if not (isinstance(f, _get_mcp_tool().MCPTool) and f.mcp_server_name == name) - ] - async with self._runtime_lock: - self._mcp_starting.discard(name) - - async def test_mcp_server_connection(self, config: dict) -> list[str]: - """Test connection to an MCP server.""" - mcp_mod = _get_mcp_client() - MCPClient = mcp_mod.MCPClient - _prepare_config = mcp_mod._prepare_config - _quick_test_mcp_connection = mcp_mod._quick_test_mcp_connection - - if "url" in config: - cfg = _prepare_config(config.copy()) - success, error_msg = await _quick_test_mcp_connection(cfg) - if not success: - raise Exception(error_msg) - - mcp_client = MCPClient() - try: - logger.debug(f"testing MCP server connection with config: {config}") - await mcp_client.connect_to_server(config, "test") - tools_res = await mcp_client.list_tools_and_save() - tool_names = [tool.name for tool in tools_res.tools] - finally: - logger.debug("Cleaning up MCP client after testing connection.") - await mcp_client.cleanup() - return tool_names - - async def enable_mcp_server( - self, - name: str, - config: dict, - shutdown_event: asyncio.Event | None = None, - init_timeout: float | str | None = None, - ) -> None: - """Enable and initialize an MCP server.""" - if init_timeout is None: - timeout_value = self._enable_timeout_default - else: - timeout_value = _resolve_timeout( - timeout=init_timeout, - env_name=ENABLE_MCP_TIMEOUT_ENV, - default=self._enable_timeout_default, - ) - await self._start_mcp_server( - name=name, - cfg=config, - shutdown_event=shutdown_event, - init_timeout=timeout_value, - ) - - async def disable_mcp_server( - self, - name: str | None = None, - shutdown_timeout: float = 10, - ) -> None: - """Disable an MCP server by name, or all if name is None.""" - if name: - async with self._runtime_lock: - runtime = self._mcp_server_runtime.get(name) - if runtime is None: - return - await self._shutdown_runtimes([runtime], shutdown_timeout, strict=True) - else: - async with self._runtime_lock: - runtimes = list(self._mcp_server_runtime.values()) - await self._shutdown_runtimes(runtimes, shutdown_timeout, strict=False) - - def _warn_on_timeout_mismatch( - self, - init_timeout: float, - enable_timeout: float, - ) -> None: - if init_timeout == enable_timeout: - return - with self._timeout_warn_lock: - if self._timeout_mismatch_warned: - return - logger.info( - "检测到 MCP 初始化超时与动态启用超时配置不同:" - "初始化使用 %s 秒,动态启用使用 %s 秒。如需一致,请设置相同值。", - f"{init_timeout:g}", - f"{enable_timeout:g}", - ) - self._timeout_mismatch_warned = True - - def get_func_desc_openai_style(self, omit_empty_parameter_field=False) -> list: - """Get OpenAI-style function descriptions for active tools.""" - tools = [f for f in self.func_list if f.active] - toolset = ToolSet(tools) - return toolset.openai_schema( - omit_empty_parameter_field=omit_empty_parameter_field, - ) - - def get_func_desc_anthropic_style(self) -> list: - """Get Anthropic-style function descriptions for active tools.""" - tools = [f for f in self.func_list if f.active] - toolset = ToolSet(tools) - return toolset.anthropic_schema() - - def get_func_desc_google_genai_style(self) -> dict: - """Get Google GenAI-style function descriptions for active tools.""" - tools = [f for f in self.func_list if f.active] - toolset = ToolSet(tools) - return toolset.google_schema() - - def deactivate_llm_tool(self, name: str) -> bool: - """Deactivate a registered function tool.""" - func_tool = self.get_func(name) - if func_tool is not None: - func_tool.active = False - - inactivated_llm_tools: list = sp.get( - "inactivated_llm_tools", - [], - scope="global", - scope_id="global", - ) - if name not in inactivated_llm_tools: - inactivated_llm_tools.append(name) - sp.put( - "inactivated_llm_tools", - inactivated_llm_tools, - scope="global", - scope_id="global", - ) - - return True - return False - - def activate_llm_tool(self, name: str, star_map: dict) -> bool: - """Activate a registered function tool.""" - func_tool = self.get_func(name) - if func_tool is not None: - if func_tool.handler_module_path in star_map: - if not star_map[func_tool.handler_module_path].activated: - raise ValueError( - f"此函数调用工具所属的插件 {star_map[func_tool.handler_module_path].name} 已被禁用,请先在管理面板启用再激活此工具。" - ) - - func_tool.active = True - - inactivated_llm_tools: list = sp.get( - "inactivated_llm_tools", - [], - scope="global", - scope_id="global", - ) - if name in inactivated_llm_tools: - inactivated_llm_tools.remove(name) - sp.put( - "inactivated_llm_tools", - inactivated_llm_tools, - scope="global", - scope_id="global", - ) - - return True - return False - - @property - def mcp_config_path(self) -> str: - data_dir = get_astrbot_data_path() - return os.path.join(data_dir, "mcp_server.json") - - def load_mcp_config(self) -> dict: - """Load MCP configuration from file.""" - if not os.path.exists(self.mcp_config_path): - os.makedirs(os.path.dirname(self.mcp_config_path), exist_ok=True) - with open(self.mcp_config_path, "w", encoding="utf-8") as f: - json.dump(DEFAULT_MCP_CONFIG, f, ensure_ascii=False, indent=4) - return DEFAULT_MCP_CONFIG.copy() - - try: - with open(self.mcp_config_path, encoding="utf-8") as f: - return json.load(f) - except Exception as e: - logger.error(f"加载 MCP 配置失败: {e}") - return DEFAULT_MCP_CONFIG.copy() - - def save_mcp_config(self, config: dict) -> bool: - """Save MCP configuration to file.""" - try: - with open(self.mcp_config_path, "w", encoding="utf-8") as f: - json.dump(config, f, ensure_ascii=False, indent=4) - return True - except Exception as e: - logger.error(f"保存 MCP 配置失败: {e}") - return False - - # Module paths for built-in tool providers - _INTERNAL_TOOL_PROVIDERS: ClassVar[list[str]] = [ - "astrbot.core.tools.cron_tools", - "astrbot.core.tools.kb_query", - "astrbot.core.tools.send_message", - "astrbot.core.computer.computer_tool_provider", - ] - - def register_internal_tools(self) -> None: - """Register AstrBot built-in tools from all internal providers. - - Each provider module should expose a get_all_tools() function. - """ - import importlib - - existing_names = {t.name for t in self.func_list} - - for module_path in self._INTERNAL_TOOL_PROVIDERS: - try: - mod = importlib.import_module(module_path) - provider_tools = mod.get_all_tools() - except Exception as e: - logger.warning( - "Failed to load internal tool provider %s: %s", - module_path, - e, - ) - continue - - for tool in provider_tools: - tool.source = "internal" - if tool.name not in existing_names: - self.func_list.append(tool) - existing_names.add(tool.name) - logger.info("Registered internal tool: %s", tool.name) - - def __str__(self) -> str: - return str(self.func_list) - - def __repr__(self) -> str: - return str(self.func_list) - - -# Alias for backward compatibility -FuncCall = FunctionToolManager diff --git a/astrbot/api/__init__.py b/astrbot/api/__init__.py index b1f696401..f6eae3b62 100644 --- a/astrbot/api/__init__.py +++ b/astrbot/api/__init__.py @@ -13,6 +13,9 @@ Modules: from astrbot import logger +# Tool API +from astrbot._internal.tools.base import FunctionTool, ToolSet + # MCP API from astrbot.api.mcp import ( MCPClient, @@ -30,18 +33,15 @@ from astrbot.api.skills import ( skill_to_tool, ) -# Tools API +# Tools API (public interface) from astrbot.api.tools import ToolRegistry, get_registry, tool from astrbot.core import html_renderer, sp -from astrbot.core.agent.tool import FunctionTool, ToolSet -from astrbot.core.agent.tool_executor import BaseFunctionToolExecutor from astrbot.core.config.astrbot_config import AstrBotConfig from astrbot.core.star.register import register_agent as agent from astrbot.core.star.register import register_llm_tool as llm_tool __all__ = [ "AstrBotConfig", - "BaseFunctionToolExecutor", "FunctionTool", "MCPClient", "MCPTool", diff --git a/astrbot/api/mcp.py b/astrbot/api/mcp.py index c3152b82a..3c05aa8b7 100644 --- a/astrbot/api/mcp.py +++ b/astrbot/api/mcp.py @@ -1,6 +1,9 @@ """ MCP (Model Context Protocol) Public API for AstrBot. +This module provides a simple, stable interface for MCP server management, +delegating to the _internal package. + Example: from astrbot.api.mcp import get_mcp_servers, register_mcp_server @@ -26,8 +29,9 @@ from __future__ import annotations from typing import Any -from astrbot.core.agent.mcp_client import MCPClient, MCPTool -from astrbot.core.provider.func_tool_manager import FunctionToolManager +# Import from _internal package (the canonical source) +from astrbot._internal.mcp.client import MCPClient +from astrbot._internal.mcp.tool import MCPTool __all__ = [ "MCPClient", @@ -42,7 +46,7 @@ def get_mcp_servers() -> dict[str, MCPClient]: """Get all connected MCP servers.""" from astrbot.core.provider.register import llm_tools as func_tool_manager - manager: FunctionToolManager = func_tool_manager + manager = func_tool_manager return dict(manager.mcp_client_dict) @@ -69,7 +73,7 @@ async def register_mcp_server( """ from astrbot.core.provider.register import llm_tools as func_tool_manager - manager: FunctionToolManager = func_tool_manager + manager = func_tool_manager config: dict[str, Any] = {} if command is not None: @@ -89,5 +93,5 @@ async def unregister_mcp_server(name: str) -> None: """Disconnect and remove an MCP server.""" from astrbot.core.provider.register import llm_tools as func_tool_manager - manager: FunctionToolManager = func_tool_manager + manager = func_tool_manager await manager.disable_mcp_server(name=name) diff --git a/astrbot/api/skills.py b/astrbot/api/skills.py index 25828074f..eb880431e 100644 --- a/astrbot/api/skills.py +++ b/astrbot/api/skills.py @@ -1,6 +1,9 @@ """ Skills Public API for AstrBot. +This module provides a simple, stable interface for skill management, +delegating to the _internal package. + Two skill types: 1. Prompt-based: SKILL.md files injected into system prompt 2. Tool-based: Skills with input_schema converted to FunctionTool @@ -20,8 +23,9 @@ Example: from __future__ import annotations -from astrbot.core.agent.tool import FunctionTool -from astrbot.core.skills.skill_manager import SkillInfo, SkillManager +# Import from _internal package (the canonical source) +from astrbot._internal.skills.manager import SkillInfo, SkillManager +from astrbot._internal.tools.base import FunctionTool __all__ = ["SkillInfo", "SkillManager", "get_skill_manager", "skill_to_tool"] diff --git a/astrbot/api/tools.py b/astrbot/api/tools.py index 1c2492be7..b3ff77a38 100644 --- a/astrbot/api/tools.py +++ b/astrbot/api/tools.py @@ -1,6 +1,9 @@ """ Tools Public API for AstrBot. +This module provides a simple, stable interface for tool registration +and management. All implementations are delegated to the _internal package. + Example: from astrbot.api.tools import tool, get_registry @@ -18,20 +21,27 @@ from collections.abc import Awaitable, Callable from functools import wraps from typing import Any -from astrbot.core.agent.tool import FunctionTool +# Import from _internal package (the canonical source) +from astrbot._internal.tools.base import FunctionTool, ToolSet +from astrbot._internal.tools.registry import FunctionToolManager -__all__ = ["FunctionTool", "ToolRegistry", "get_registry", "tool"] +__all__ = ["FunctionTool", "ToolRegistry", "ToolSet", "get_registry", "tool"] class ToolRegistry: - """Simple wrapper around FunctionToolManager for tool registration.""" + """Wrapper around FunctionToolManager for simplified tool registration. + + This class provides a user-friendly interface for registering and + managing tools, delegating to the internal FunctionToolManager. + """ _instance: ToolRegistry | None = None def __init__(self) -> None: + # Import here to avoid circular imports from astrbot.core.provider.register import llm_tools as func_tool_manager - self._manager = func_tool_manager + self._manager: FunctionToolManager = func_tool_manager @classmethod def get_instance(cls) -> ToolRegistry: diff --git a/astrbot/cli/__main__.py b/astrbot/cli/__main__.py index e150729da..6ff1f568f 100644 --- a/astrbot/cli/__main__.py +++ b/astrbot/cli/__main__.py @@ -7,7 +7,7 @@ import click from click.shell_completion import get_completion_class from . import __version__ -from .commands import bk, conf, init, plug, run, tui, uninstall +from .commands import bk, conf, dev, init, plug, run, tui, uninstall from .i18n import t logo_tmpl = r""" @@ -86,6 +86,7 @@ cli.add_command(conf) cli.add_command(uninstall) cli.add_command(bk) cli.add_command(tui) +cli.add_command(dev) @click.command() diff --git a/astrbot/cli/commands/__init__.py b/astrbot/cli/commands/__init__.py index 4e3671967..5cd62a3a6 100644 --- a/astrbot/cli/commands/__init__.py +++ b/astrbot/cli/commands/__init__.py @@ -1,9 +1,10 @@ from .cmd_bk import bk from .cmd_conf import conf +from .cmd_dev import dev from .cmd_init import init from .cmd_plug import plug from .cmd_run import run from .cmd_tui import tui from .cmd_uninstall import uninstall -__all__ = ["bk", "conf", "init", "plug", "run", "tui", "uninstall"] +__all__ = ["bk", "conf", "dev", "init", "plug", "run", "tui", "uninstall"] diff --git a/astrbot/core/agent/mcp_client.py b/astrbot/core/agent/mcp_client.py index a055722d4..7e8f1355c 100644 --- a/astrbot/core/agent/mcp_client.py +++ b/astrbot/core/agent/mcp_client.py @@ -14,19 +14,11 @@ MCP client - DEPRECATED This file exists solely for backward compatibility and will be removed in a future version. """ -import warnings - -warnings.warn( - "astrbot.core.agent.mcp_client has been moved to astrbot._internal.mcp. " - "Please update your imports.", - DeprecationWarning, - stacklevel=2, -) - import asyncio import logging import os import sys +import warnings from contextlib import AsyncExitStack from datetime import timedelta from typing import Generic @@ -46,6 +38,12 @@ from astrbot.core.utils.log_pipe import LogPipe from .run_context import TContext from .tool import FunctionTool +warnings.warn( + "astrbot.core.agent.mcp_client has been moved to astrbot._internal.mcp. " + "Please update your imports.", + DeprecationWarning, + stacklevel=2, +) try: import anyio import mcp @@ -246,7 +244,7 @@ class MCPClient: mcp.ClientSession( *streams, read_timeout_seconds=read_timeout, - logging_callback=logging_callback, # type: ignore + logging_callback=logging_callback, ), ) else: @@ -272,7 +270,7 @@ class MCPClient: read_stream=read_s, write_stream=write_s, read_timeout_seconds=read_timeout, - logging_callback=logging_callback, # type: ignore + logging_callback=logging_callback, ), ) @@ -303,7 +301,7 @@ class MCPClient: logger=logger, identifier=f"MCPServer-{name}", callback=callback, - ), # type: ignore + ), ), ) self.process_pid = self._extract_stdio_process_pid(self._streams_context) diff --git a/astrbot/core/agent/runners/tool_loop_agent_runner.py b/astrbot/core/agent/runners/tool_loop_agent_runner.py index 38ef038e6..ff937ed37 100644 --- a/astrbot/core/agent/runners/tool_loop_agent_runner.py +++ b/astrbot/core/agent/runners/tool_loop_agent_runner.py @@ -771,7 +771,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]): ) _final_resp: CallToolResult | None = None - async for resp in self._iter_tool_executor_results(executor): # type: ignore + async for resp in self._iter_tool_executor_results(executor): if isinstance(resp, CallToolResult): res = resp _final_resp = resp @@ -913,7 +913,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]): contexts: list[dict[str, Any]] = [] for msg in self.run_context.messages: if hasattr(msg, "model_dump"): - contexts.append(msg.model_dump()) # type: ignore[call-arg] + contexts.append(msg.model_dump()) elif isinstance(msg, dict): contexts.append(copy.deepcopy(msg)) instruction = ( diff --git a/astrbot/core/agent/tool.py b/astrbot/core/agent/tool.py index eeaec8234..b6b306f5b 100644 --- a/astrbot/core/agent/tool.py +++ b/astrbot/core/agent/tool.py @@ -229,7 +229,7 @@ class ToolSet: if ( tool.parameters and tool.parameters.get("properties") ) or not omit_empty_parameter_field: - func_def["function"]["parameters"] = tool.parameters # type: ignore[index] + func_def["function"]["parameters"] = tool.parameters result.append(func_def) return result diff --git a/astrbot/core/astr_main_agent_resources.py b/astrbot/core/astr_main_agent_resources.py index d92fda533..3ea3ba3a5 100644 --- a/astrbot/core/astr_main_agent_resources.py +++ b/astrbot/core/astr_main_agent_resources.py @@ -182,7 +182,7 @@ class SendMessageToUserTool(FunctionTool[AstrAgentContext]): if not isinstance(msg, dict): return f"error: messages[{idx}] should be an object." - msg_dict: dict[str, Any] = msg # type: ignore[assignment] + msg_dict: dict[str, Any] = msg msg_type = str(msg_dict.get("type", "")).lower() if not msg_type: return f"error: messages[{idx}].type is required." diff --git a/astrbot/core/computer/booters/boxlite.py b/astrbot/core/computer/booters/boxlite.py index 3ea736346..f76eafca3 100644 --- a/astrbot/core/computer/booters/boxlite.py +++ b/astrbot/core/computer/booters/boxlite.py @@ -17,7 +17,12 @@ from astrbot.api import logger if TYPE_CHECKING: from astrbot.core.agent.tool import FunctionTool -from ..olayer import FileSystemComponent, PythonComponent, ShellComponent +from astrbot.core.computer.olayer import ( + FileSystemComponent, + PythonComponent, + ShellComponent, +) + from .base import ComputerBooter @@ -194,17 +199,17 @@ class BoxliteBooter(ComputerBooter): sb_url=f"http://127.0.0.1:{random_port}" ) self._fs = ShipyardFileSystemComponent( - client=self.mocked, # type: ignore + client=self.mocked, ship_id=self.box.id, session_id=session_id, ) self._python = ShipyardPythonComponent( - client=self.mocked, # type: ignore + client=self.mocked, ship_id=self.box.id, session_id=session_id, ) self._shell = ShipyardShellComponent( - client=self.mocked, # type: ignore + client=self.mocked, ship_id=self.box.id, session_id=session_id, ) diff --git a/astrbot/core/db/po.py b/astrbot/core/db/po.py index b972c5a1d..4b62522c0 100644 --- a/astrbot/core/db/po.py +++ b/astrbot/core/db/po.py @@ -389,7 +389,7 @@ class SessionProjectRelation(SQLModel, table=True): class CommandConfig(TimestampMixin, SQLModel, table=True): """Per-command configuration overrides for dashboard management.""" - __tablename__ = "command_configs" # type: ignore + __tablename__ = "command_configs" handler_full_name: str = Field( primary_key=True, @@ -411,7 +411,7 @@ class CommandConfig(TimestampMixin, SQLModel, table=True): class CommandConflict(TimestampMixin, SQLModel, table=True): """Conflict tracking for duplicated command names.""" - __tablename__ = "command_conflicts" # type: ignore + __tablename__ = "command_conflicts" id: int | None = Field( default=None, primary_key=True, sa_column_kwargs={"autoincrement": True} diff --git a/astrbot/core/db/sqlite.py b/astrbot/core/db/sqlite.py index f48272f7d..3d414b849 100644 --- a/astrbot/core/db/sqlite.py +++ b/astrbot/core/db/sqlite.py @@ -362,7 +362,7 @@ class SQLiteDatabase(BaseDatabase): col(Preference.scope_id).label("session_id"), func.json_extract(Preference.value, "$.val").label( "conversation_id", - ), # type: ignore + ), col(ConversationV2.persona_id).label("persona_id"), col(ConversationV2.title).label("title"), col(Persona.persona_id).label("persona_name"), diff --git a/astrbot/core/db/vec_db/faiss_impl/document_storage.py b/astrbot/core/db/vec_db/faiss_impl/document_storage.py index 2adae69cc..af5a8d984 100644 --- a/astrbot/core/db/vec_db/faiss_impl/document_storage.py +++ b/astrbot/core/db/vec_db/faiss_impl/document_storage.py @@ -18,7 +18,7 @@ class BaseDocModel(SQLModel, table=False): class Document(BaseDocModel, table=True): """SQLModel for documents table.""" - __tablename__ = "documents" # type: ignore + __tablename__ = "documents" id: int | None = Field( default=None, @@ -46,7 +46,7 @@ class DocumentStorage: async def initialize(self) -> None: """Initialize the SQLite database and create the documents table if it doesn't exist.""" await self.connect() - async with self.engine.begin() as conn: # type: ignore + async with self.engine.begin() as conn: # Create tables using SQLModel await conn.run_sync(BaseDocModel.metadata.create_all) @@ -89,15 +89,15 @@ class DocumentStorage: future=True, ) self.async_session_maker = sessionmaker( - self.engine, # type: ignore + self.engine, class_=AsyncSession, expire_on_commit=False, - ) # type: ignore + ) @asynccontextmanager async def get_session(self): """Context manager for database sessions.""" - async with self.async_session_maker() as session: # type: ignore + async with self.async_session_maker() as session: yield session async def get_documents( @@ -172,7 +172,7 @@ class DocumentStorage: ) session.add(document) await session.flush() # Flush to get the ID - return document.id # type: ignore + return document.id async def insert_documents_batch( self, @@ -209,7 +209,7 @@ class DocumentStorage: session.add(document) await session.flush() # Flush to get all IDs - return [doc.id for doc in documents] # type: ignore + return [doc.id for doc in documents] async def delete_document_by_doc_id(self, doc_id: str) -> None: """Delete a document by its doc_id. diff --git a/astrbot/core/knowledge_base/models.py b/astrbot/core/knowledge_base/models.py index 8b6c1f786..3386c4e2b 100644 --- a/astrbot/core/knowledge_base/models.py +++ b/astrbot/core/knowledge_base/models.py @@ -14,7 +14,7 @@ class KnowledgeBase(BaseKBModel, table=True): 存储知识库的基本信息和统计数据。 """ - __tablename__ = "knowledge_bases" # type: ignore + __tablename__ = "knowledge_bases" id: int | None = Field( primary_key=True, @@ -62,7 +62,7 @@ class KBDocument(BaseKBModel, table=True): 存储上传到知识库的文档元数据。 """ - __tablename__ = "kb_documents" # type: ignore + __tablename__ = "kb_documents" id: int | None = Field( primary_key=True, @@ -96,7 +96,7 @@ class KBMedia(BaseKBModel, table=True): 存储从文档中提取的图片、视频等多媒体资源。 """ - __tablename__ = "kb_media" # type: ignore + __tablename__ = "kb_media" id: int | None = Field( primary_key=True, diff --git a/astrbot/core/persona_mgr.py b/astrbot/core/persona_mgr.py index 01b6620af..da865febd 100644 --- a/astrbot/core/persona_mgr.py +++ b/astrbot/core/persona_mgr.py @@ -433,7 +433,7 @@ class PersonaManager: user_turn = not user_turn try: - persona = Personality( # type: ignore[misc] + persona = Personality( **persona_cfg, _begin_dialogs_processed=bd_processed, _mood_imitation_dialogs_processed="", # deprecated diff --git a/astrbot/core/pipeline/process_stage/method/agent_sub_stages/third_party.py b/astrbot/core/pipeline/process_stage/method/agent_sub_stages/third_party.py index 9e720fe67..15d57e66c 100644 --- a/astrbot/core/pipeline/process_stage/method/agent_sub_stages/third_party.py +++ b/astrbot/core/pipeline/process_stage/method/agent_sub_stages/third_party.py @@ -60,7 +60,7 @@ async def run_third_party_agent( 类似于 run_agent 函数,但专门处理第三方 agent runner """ try: - async for resp in runner.step_until_done(max_step=30): # type: ignore[misc] + async for resp in runner.step_until_done(max_step=30): if resp.type == "streaming_delta": if stream_to_general: continue diff --git a/astrbot/core/platform/sources/misskey/misskey_adapter.py b/astrbot/core/platform/sources/misskey/misskey_adapter.py index 633d387ff..0adc967e9 100644 --- a/astrbot/core/platform/sources/misskey/misskey_adapter.py +++ b/astrbot/core/platform/sources/misskey/misskey_adapter.py @@ -18,7 +18,7 @@ from astrbot.core.platform.astr_message_event import MessageSession from .misskey_api import MisskeyAPI try: - import magic # type: ignore + import magic except Exception: magic = None @@ -200,7 +200,7 @@ class MisskeyPlatformAdapter(Platform): try: if not isinstance(message.raw_message, dict): message.raw_message = {} - raw_msg: dict[str, Any] = message.raw_message # type: ignore[assignment] + raw_msg: dict[str, Any] = message.raw_message raw_msg["poll"] = poll message.__setattr__("poll", poll) except Exception: diff --git a/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py b/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py index 9b108fa6e..2300a15a1 100644 --- a/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py +++ b/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py @@ -35,7 +35,7 @@ def _patch_qq_botpy_formdata() -> None: """ try: - from botpy.http import _FormData # type: ignore + from botpy.http import _FormData if not hasattr(_FormData, "_is_processed"): setattr(_FormData, "_is_processed", False) @@ -276,7 +276,7 @@ class QQOfficialMessageEvent(AstrMessageEvent): payload["content"] = plain_text or None ret = await self._send_with_markdown_fallback( send_func=lambda retry_payload: self.bot.api.post_group_message( - group_openid=source.group_openid, # type: ignore + group_openid=source.group_openid, **retry_payload, ), payload=payload, @@ -572,7 +572,7 @@ class QQOfficialMessageEvent(AstrMessageEvent): logger.error(f"[QQOfficial] post_c2c_message: 响应不是 dict: {result}") return None - return message.Message(**cast(dict[str, Any], result)) # type: ignore[arg-type] + return message.Message(**cast(dict[str, Any], result)) @staticmethod async def _parse_to_qqofficial(message: MessageChain): diff --git a/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py b/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py index 7e31536a1..37ad1ba19 100644 --- a/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py +++ b/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py @@ -185,7 +185,7 @@ class QQOfficialPlatformAdapter(Platform): payload["msg_seq"] = random.randint(1, 10000) if image_base64: media = await QQOfficialMessageEvent.upload_group_and_c2c_image( - send_helper, # type: ignore + send_helper, image_base64, QQOfficialMessageEvent.IMAGE_FILE_TYPE, group_openid=session.session_id, @@ -194,7 +194,7 @@ class QQOfficialPlatformAdapter(Platform): payload["msg_type"] = 7 if record_file_path: media = await QQOfficialMessageEvent.upload_group_and_c2c_media( - send_helper, # type: ignore + send_helper, record_file_path, QQOfficialMessageEvent.VOICE_FILE_TYPE, group_openid=session.session_id, @@ -204,7 +204,7 @@ class QQOfficialPlatformAdapter(Platform): payload["msg_type"] = 7 if video_file_source: media = await QQOfficialMessageEvent.upload_group_and_c2c_media( - send_helper, # type: ignore + send_helper, video_file_source, QQOfficialMessageEvent.VIDEO_FILE_TYPE, group_openid=session.session_id, @@ -215,7 +215,7 @@ class QQOfficialPlatformAdapter(Platform): payload.pop("msg_id", None) if file_source: media = await QQOfficialMessageEvent.upload_group_and_c2c_media( - send_helper, # type: ignore + send_helper, file_source, QQOfficialMessageEvent.FILE_FILE_TYPE, file_name=file_name, @@ -241,7 +241,7 @@ class QQOfficialPlatformAdapter(Platform): payload["msg_seq"] = random.randint(1, 10000) if image_base64: media = await QQOfficialMessageEvent.upload_group_and_c2c_image( - send_helper, # type: ignore + send_helper, image_base64, QQOfficialMessageEvent.IMAGE_FILE_TYPE, openid=session.session_id, @@ -250,7 +250,7 @@ class QQOfficialPlatformAdapter(Platform): payload["msg_type"] = 7 if record_file_path: media = await QQOfficialMessageEvent.upload_group_and_c2c_media( - send_helper, # type: ignore + send_helper, record_file_path, QQOfficialMessageEvent.VOICE_FILE_TYPE, openid=session.session_id, @@ -260,7 +260,7 @@ class QQOfficialPlatformAdapter(Platform): payload["msg_type"] = 7 if video_file_source: media = await QQOfficialMessageEvent.upload_group_and_c2c_media( - send_helper, # type: ignore + send_helper, video_file_source, QQOfficialMessageEvent.VIDEO_FILE_TYPE, openid=session.session_id, @@ -273,7 +273,7 @@ class QQOfficialPlatformAdapter(Platform): payload.pop("msg_id", None) if file_source: media = await QQOfficialMessageEvent.upload_group_and_c2c_media( - send_helper, # type: ignore + send_helper, file_source, QQOfficialMessageEvent.FILE_FILE_TYPE, file_name=file_name, @@ -285,7 +285,7 @@ class QQOfficialPlatformAdapter(Platform): payload.pop("msg_id", None) ret = await QQOfficialMessageEvent.post_c2c_message( - send_helper, # type: ignore + send_helper, openid=session.session_id, **payload, ) diff --git a/astrbot/core/platform/sources/satori/satori_adapter.py b/astrbot/core/platform/sources/satori/satori_adapter.py index a49b77ab5..bdfc09c21 100644 --- a/astrbot/core/platform/sources/satori/satori_adapter.py +++ b/astrbot/core/platform/sources/satori/satori_adapter.py @@ -158,7 +158,7 @@ class SatoriPlatformAdapter(Platform): async for message in websocket: try: - await self.handle_message(message) # type: ignore + await self.handle_message(message) except Exception as e: logger.error(f"Satori 处理消息异常: {e}") diff --git a/astrbot/core/platform/sources/wecom_ai_bot/WXBizJsonMsgCrypt.py b/astrbot/core/platform/sources/wecom_ai_bot/WXBizJsonMsgCrypt.py index 76631be29..d4e7a063c 100644 --- a/astrbot/core/platform/sources/wecom_ai_bot/WXBizJsonMsgCrypt.py +++ b/astrbot/core/platform/sources/wecom_ai_bot/WXBizJsonMsgCrypt.py @@ -170,7 +170,7 @@ class Prpcrypt: pkcs7 = PKCS7Encoder() text = pkcs7.encode(text) # 加密 - cryptor = AES.new(self.key, self.mode, self.key[:16]) # type: ignore + cryptor = AES.new(self.key, self.mode, self.key[:16]) try: ciphertext = cryptor.encrypt(text) # 使用BASE64对加密后的字符串进行编码 @@ -186,7 +186,7 @@ class Prpcrypt: @return: 删除填充补位后的明文 """ try: - cryptor = AES.new(self.key, self.mode, self.key[:16]) # type: ignore + cryptor = AES.new(self.key, self.mode, self.key[:16]) # 使用BASE64对密文进行解码,然后AES-CBC解密 plain_text = cryptor.decrypt(base64.b64decode(text)) except Exception as e: @@ -259,7 +259,7 @@ class WXBizJsonMsgCrypt: # return:成功0,sEncryptMsg,失败返回对应的错误码None pc = Prpcrypt(self.key) ret, encrypt = pc.encrypt(sReplyMsg, self.m_sReceiveId) - encrypt = encrypt.decode("utf-8") # type: ignore + encrypt = encrypt.decode("utf-8") if ret != 0: return ret, None if timestamp is None: diff --git a/astrbot/core/provider/func_tool_manager.py b/astrbot/core/provider/func_tool_manager.py index 5aa50b129..2c9b37d56 100644 --- a/astrbot/core/provider/func_tool_manager.py +++ b/astrbot/core/provider/func_tool_manager.py @@ -1,132 +1,20 @@ +""" +FunctionToolManager - Central registry for all function tools. + +This module re-exports from _internal package for backward compatibility. +The canonical implementation is in astrbot._internal.tools.registry. +""" + from __future__ import annotations -import asyncio -import copy -import json -import os -import threading -import urllib.parse -from collections.abc import AsyncGenerator, Awaitable, Callable, Mapping -from dataclasses import dataclass -from types import MappingProxyType -from typing import Any - -import aiofiles -import aiohttp -import anyio - -from astrbot import logger -from astrbot.core import sp -from astrbot.core.agent.mcp_client import MCPClient, MCPTool -from astrbot.core.agent.tool import FunctionTool, ToolSet -from astrbot.core.utils.astrbot_path import get_astrbot_data_path - -DEFAULT_MCP_CONFIG = {"mcpServers": {}} - -DEFAULT_MCP_INIT_TIMEOUT_SECONDS = 180.0 -DEFAULT_ENABLE_MCP_TIMEOUT_SECONDS = 180.0 -MCP_INIT_TIMEOUT_ENV = "ASTRBOT_MCP_INIT_TIMEOUT" -ENABLE_MCP_TIMEOUT_ENV = "ASTRBOT_MCP_ENABLE_TIMEOUT" -MAX_MCP_TIMEOUT_SECONDS = 300.0 - - -class MCPInitError(Exception): - """Base exception for MCP initialization failures.""" - - -class MCPInitTimeoutError(asyncio.TimeoutError, MCPInitError): - """Raised when MCP client initialization exceeds the configured timeout.""" - - -class MCPAllServicesFailedError(MCPInitError): - """Raised when all configured MCP services fail to initialize.""" - - -class MCPShutdownTimeoutError(asyncio.TimeoutError): - """Raised when MCP shutdown exceeds the configured timeout.""" - - def __init__(self, names: list[str], timeout: float) -> None: - self.names = names - self.timeout = timeout - message = f"MCP 服务关闭超时({timeout:g} 秒):{', '.join(names)}" - super().__init__(message) - - -@dataclass -class MCPInitSummary: - total: int - success: int - failed: list[str] - - -@dataclass -class _MCPServerRuntime: - name: str - client: MCPClient - shutdown_event: asyncio.Event - lifecycle_task: asyncio.Task[None] - - -class _MCPClientDictView(Mapping[str, MCPClient]): - """Read-only view of MCP clients derived from runtime state.""" - - def __init__(self, runtime: dict[str, _MCPServerRuntime]) -> None: - self._runtime = runtime - - def __getitem__(self, key: str) -> MCPClient: - return self._runtime[key].client - - def __iter__(self): - return iter(self._runtime) - - def __len__(self) -> int: - return len(self._runtime) - - -def _resolve_timeout( - timeout: float | str | None = None, - *, - env_name: str = MCP_INIT_TIMEOUT_ENV, - default: float = DEFAULT_MCP_INIT_TIMEOUT_SECONDS, -) -> float: - """Resolve timeout with precedence: explicit argument > env value > default.""" - source = f"环境变量 {env_name}" - if timeout is None: - timeout = os.getenv(env_name, str(default)) - else: - source = "显式参数 timeout" - - try: - timeout_value = float(timeout) - except (TypeError, ValueError): - logger.warning( - f"超时配置({source})={timeout!r} 无效,使用默认值 {default:g} 秒。" - ) - return default - - if timeout_value <= 0: - logger.warning( - f"超时配置({source})={timeout_value:g} 必须大于 0,使用默认值 {default:g} 秒。" - ) - return default - - if timeout_value > MAX_MCP_TIMEOUT_SECONDS: - logger.warning( - f"超时配置({source})={timeout_value:g} 过大,已限制为最大值 " - f"{MAX_MCP_TIMEOUT_SECONDS:g} 秒,以避免长时间等待。" - ) - return MAX_MCP_TIMEOUT_SECONDS - - return timeout_value - - +# Constants that are still imported by other modules SUPPORTED_TYPES = [ "string", "number", "object", "array", "boolean", -] # json schema 支持的数据类型 +] PY_TO_JSON_TYPE = { "int": "number", @@ -138,855 +26,41 @@ PY_TO_JSON_TYPE = { "tuple": "array", "set": "array", } -# alias + +# Re-export from _internal for backward compatibility +# ruff: noqa: E402 - Constants above must be defined before this import +from astrbot._internal.tools.registry import ( + DEFAULT_MCP_CONFIG, + ENABLE_MCP_TIMEOUT_ENV, + MCP_INIT_TIMEOUT_ENV, + FuncCall, + FunctionTool, + FunctionToolManager, + MCPAllServicesFailedError, + MCPInitError, + MCPInitSummary, + MCPInitTimeoutError, + MCPShutdownTimeoutError, + ToolSet, +) + +# For backward compatibility - alias FunctionTool as FuncTool FuncTool = FunctionTool - -def _prepare_config(config: dict) -> dict: - """准备配置,处理嵌套格式""" - if config.get("mcpServers"): - first_key = next(iter(config["mcpServers"])) - config = config["mcpServers"][first_key] - config.pop("active", None) - return config - - -async def _quick_test_mcp_connection(config: dict) -> tuple[bool, str]: - """快速测试 MCP 服务器可达性""" - import aiohttp - - cfg = _prepare_config(config.copy()) - - url = cfg["url"] - headers = cfg.get("headers", {}) - timeout = cfg.get("timeout", 10) - - try: - async with aiohttp.ClientSession() as session: - if cfg.get("transport") == "streamable_http": - test_payload = { - "jsonrpc": "2.0", - "method": "initialize", - "id": 0, - "params": { - "protocolVersion": "2024-11-05", - "capabilities": {}, - "clientInfo": {"name": "test-client", "version": "1.2.3"}, - }, - } - async with session.post( - url, - headers={ - **headers, - "Content-Type": "application/json", - "Accept": "application/json, text/event-stream", - }, - json=test_payload, - timeout=aiohttp.ClientTimeout(total=timeout), - ) as response: - if response.status == 200: - return True, "" - return False, f"HTTP {response.status}: {response.reason}" - else: - async with session.get( - url, - headers={ - **headers, - "Accept": "application/json, text/event-stream", - }, - timeout=aiohttp.ClientTimeout(total=timeout), - ) as response: - if response.status == 200: - return True, "" - return False, f"HTTP {response.status}: {response.reason}" - - except asyncio.TimeoutError: - return False, f"连接超时: {timeout}秒" - except Exception as e: - return False, f"{e!s}" - - -class FunctionToolManager: - def __init__(self) -> None: - self.func_list: list[FuncTool] = [] - self._mcp_server_runtime: dict[str, _MCPServerRuntime] = {} - """MCP 服务运行时状态(唯一事实来源)""" - self._mcp_server_runtime_view = MappingProxyType(self._mcp_server_runtime) - self._mcp_client_dict_view = _MCPClientDictView(self._mcp_server_runtime) - self._timeout_mismatch_warned = False - self._timeout_warn_lock = threading.Lock() - self._runtime_lock = asyncio.Lock() - self._mcp_starting: set[str] = set() - self._init_timeout_default = _resolve_timeout( - timeout=None, - env_name=MCP_INIT_TIMEOUT_ENV, - default=DEFAULT_MCP_INIT_TIMEOUT_SECONDS, - ) - self._enable_timeout_default = _resolve_timeout( - timeout=None, - env_name=ENABLE_MCP_TIMEOUT_ENV, - default=DEFAULT_ENABLE_MCP_TIMEOUT_SECONDS, - ) - self._warn_on_timeout_mismatch( - self._init_timeout_default, - self._enable_timeout_default, - ) - - @property - def mcp_client_dict(self) -> Mapping[str, MCPClient]: - """Read-only compatibility view for external callers that still read mcp_client_dict. - - Note: Mutating this mapping is unsupported and will raise TypeError. - """ - return self._mcp_client_dict_view - - @property - def mcp_server_runtime_view(self) -> Mapping[str, _MCPServerRuntime]: - """Read-only view of MCP runtime metadata for external callers.""" - return self._mcp_server_runtime_view - - @property - def mcp_server_runtime(self) -> Mapping[str, _MCPServerRuntime]: - """Backward-compatible read-only view (deprecated). Do not mutate. - - Note: Mutations are not supported and will raise TypeError. - """ - return self._mcp_server_runtime_view - - def empty(self) -> bool: - return len(self.func_list) == 0 - - def spec_to_func( - self, - name: str, - func_args: list[dict], - desc: str, - handler: Callable[..., Awaitable[Any] | AsyncGenerator[Any]], - ) -> FuncTool: - params = { - "type": "object", # hard-coded here - "properties": {}, - } - for param in func_args: - p = copy.deepcopy(param) - p.pop("name", None) - params["properties"][param["name"]] = p - return FuncTool( - name=name, - parameters=params, - description=desc, - handler=handler, - ) - - def add_func( - self, - name: str, - func_args: list, - desc: str, - handler: Callable[..., Awaitable[Any] | AsyncGenerator[Any]], - ) -> None: - """添加函数调用工具 - - @param name: 函数名 - @param func_args: 函数参数列表,格式为 [{"type": "string", "name": "arg_name", "description": "arg_description"}, ...] - @param desc: 函数描述 - @param func_obj: 处理函数 - """ - # check if the tool has been added before - self.remove_func(name) - - self.func_list.append( - self.spec_to_func( - name=name, - func_args=func_args, - desc=desc, - handler=handler, - ), - ) - logger.info(f"添加函数调用工具: {name}") - - def remove_func(self, name: str) -> None: - """删除一个函数调用工具。""" - for i, f in enumerate(self.func_list): - if f.name == name: - self.func_list.pop(i) - break - - def get_func(self, name) -> FuncTool | None: - # 优先返回已激活的工具(后加载的覆盖前面的,与 ToolSet.add_tool 保持一致) - # 使用 getattr(..., True) 与 ToolSet.add_tool 保持一致:没有 active 属性的工具视为已激活 - for f in reversed(self.func_list): - if f.name == name and getattr(f, "active", True): - return f - # 退化则拿最后一个同名工具 - for f in reversed(self.func_list): - if f.name == name: - return f - return None - - def get_full_tool_set(self) -> ToolSet: - """获取完整工具集 - - 使用 ToolSet.add_tool 进行填充。对于同名工具,去重规则为: - - 优先保留 active=True 的工具; - - 当 active 状态相同时,后加载的工具会覆盖前面的工具。 - - 因此,后加载的 inactive 工具不会覆盖已激活的工具; - 同时,MCP 工具在需要时仍可覆盖被禁用的内置工具。 - """ - tool_set = ToolSet() - for tool in self.func_list: - tool_set.add_tool(tool) - return tool_set - - @staticmethod - def _log_safe_mcp_debug_config(cfg: dict) -> None: - # 仅记录脱敏后的摘要,避免泄露 command/args/url 中的敏感信息 - if "command" in cfg: - cmd = cfg["command"] - executable = str(cmd[0] if isinstance(cmd, (list, tuple)) and cmd else cmd) - args_val = cfg.get("args", []) - args_count = ( - len(args_val) - if isinstance(args_val, (list, tuple)) - else (0 if args_val is None else 1) - ) - logger.debug(f" 命令可执行文件: {executable}, 参数数量: {args_count}") - return - - if "url" in cfg: - parsed = urllib.parse.urlparse(str(cfg["url"])) - host = parsed.hostname or "" - scheme = parsed.scheme or "unknown" - try: - port = f":{parsed.port}" if parsed.port else "" - except ValueError: - port = "" - logger.debug(f" 主机: {scheme}://{host}{port}") - - async def init_mcp_clients( - self, raise_on_all_failed: bool = False - ) -> MCPInitSummary: - """从项目根目录读取 mcp_server.json 文件,初始化 MCP 服务列表。文件格式如下: - ``` - { - "mcpServers": { - "weather": { - "command": "uv", - "args": [ - "--directory", - "/ABSOLUTE/PATH/TO/PARENT/FOLDER/weather", - "run", - "weather.py" - ] - } - } - ... - } - ``` - - Timeout behavior: - - 初始化超时使用环境变量 ASTRBOT_MCP_INIT_TIMEOUT 或默认值。 - - 动态启用超时使用 ASTRBOT_MCP_ENABLE_TIMEOUT(独立于初始化超时)。 - """ - data_dir = get_astrbot_data_path() - - mcp_json_file = os.path.join(data_dir, "mcp_server.json") - mcp_json_path = anyio.Path(mcp_json_file) - if not await mcp_json_path.exists(): - # 配置文件不存在错误处理 - async with aiofiles.open(mcp_json_file, "w", encoding="utf-8") as f: - await f.write( - json.dumps(DEFAULT_MCP_CONFIG, ensure_ascii=False, indent=4) - ) - logger.info(f"未找到 MCP 服务配置文件,已创建默认配置文件 {mcp_json_file}") - return MCPInitSummary(total=0, success=0, failed=[]) - - async with aiofiles.open(mcp_json_file, encoding="utf-8") as f: - mcp_server_json_obj: dict[str, dict] = json.loads(await f.read())[ - "mcpServers" - ] - - init_timeout = self._init_timeout_default - timeout_display = f"{init_timeout:g}" - - active_configs: list[tuple[str, dict, asyncio.Event]] = [] - for name, cfg in mcp_server_json_obj.items(): - if cfg.get("active", True): - shutdown_event = asyncio.Event() - active_configs.append((name, cfg, shutdown_event)) - - if not active_configs: - return MCPInitSummary(total=0, success=0, failed=[]) - - logger.info(f"等待 {len(active_configs)} 个 MCP 服务初始化...") - - init_tasks = [ - asyncio.create_task( - self._start_mcp_server( - name=name, - cfg=cfg, - shutdown_event=shutdown_event, - init_timeout=init_timeout, - ), - name=f"mcp-init:{name}", - ) - for (name, cfg, shutdown_event) in active_configs - ] - results = await asyncio.gather(*init_tasks, return_exceptions=True) - - success_count = 0 - failed_services: list[str] = [] - - for (name, cfg, _), result in zip(active_configs, results, strict=False): - if isinstance(result, Exception): - if isinstance(result, MCPInitTimeoutError): - logger.error( - f"Connected to MCP server {name} timeout ({timeout_display} seconds)" - ) - else: - logger.error(f"Failed to initialize MCP server {name}: {result}") - self._log_safe_mcp_debug_config(cfg) - failed_services.append(name) - async with self._runtime_lock: - self._mcp_server_runtime.pop(name, None) - continue - - success_count += 1 - - if failed_services: - logger.warning( - f"The following MCP services failed to initialize: {', '.join(failed_services)}. " - f"Please check the mcp_server.json file and server availability." - ) - - summary = MCPInitSummary( - total=len(active_configs), success=success_count, failed=failed_services - ) - logger.info( - f"MCP services initialization completed: {summary.success}/{summary.total} successful, {len(summary.failed)} failed." - ) - if summary.total > 0 and summary.success == 0: - msg = "All MCP services failed to initialize, please check the mcp_server.json and server availability." - if raise_on_all_failed: - raise MCPAllServicesFailedError(msg) - logger.error(msg) - return summary - - async def _start_mcp_server( - self, - name: str, - cfg: dict, - *, - shutdown_event: asyncio.Event | None = None, - init_timeout: float, - ) -> None: - """Initialize MCP server with timeout and register task/event together. - - This method is idempotent. If the server is already running, the existing - runtime is kept and the new config is ignored. - """ - async with self._runtime_lock: - if name in self._mcp_server_runtime or name in self._mcp_starting: - logger.warning( - f"Connected to MCP server {name}, ignoring this startup request (timeout={init_timeout:g})." - ) - self._log_safe_mcp_debug_config(cfg) - return - self._mcp_starting.add(name) - - if shutdown_event is None: - shutdown_event = asyncio.Event() - - mcp_client: MCPClient | None = None - try: - mcp_client = await asyncio.wait_for( - self._init_mcp_client(name, cfg), - timeout=init_timeout, - ) - except asyncio.TimeoutError as exc: - raise MCPInitTimeoutError( - f"Connected to MCP server {name} timeout ({init_timeout:g} seconds)" - ) from exc - except Exception: - logger.error(f"Failed to initialize MCP client {name}", exc_info=True) - raise - finally: - if mcp_client is None: - async with self._runtime_lock: - self._mcp_starting.discard(name) - - async def lifecycle() -> None: - try: - await shutdown_event.wait() - logger.info(f"Received shutdown signal for MCP client {name}") - except asyncio.CancelledError: - logger.debug(f"MCP client {name} task was cancelled") - raise - finally: - await self._terminate_mcp_client(name) - - lifecycle_task = asyncio.create_task(lifecycle(), name=f"mcp-client:{name}") - async with self._runtime_lock: - self._mcp_server_runtime[name] = _MCPServerRuntime( - name=name, - client=mcp_client, - shutdown_event=shutdown_event, - lifecycle_task=lifecycle_task, - ) - self._mcp_starting.discard(name) - - async def _shutdown_runtimes( - self, - runtimes: list[_MCPServerRuntime], - shutdown_timeout: float, - *, - strict: bool = True, - ) -> list[str]: - """Shutdown runtimes and wait for lifecycle tasks to complete.""" - lifecycle_tasks = [ - runtime.lifecycle_task - for runtime in runtimes - if not runtime.lifecycle_task.done() - ] - if not lifecycle_tasks: - return [] - - for runtime in runtimes: - runtime.shutdown_event.set() - - try: - results = await asyncio.wait_for( - asyncio.gather(*lifecycle_tasks, return_exceptions=True), - timeout=shutdown_timeout, - ) - except asyncio.TimeoutError: - pending_names = [ - runtime.name - for runtime in runtimes - if not runtime.lifecycle_task.done() - ] - for task in lifecycle_tasks: - if not task.done(): - task.cancel() - await asyncio.gather(*lifecycle_tasks, return_exceptions=True) - if strict: - raise MCPShutdownTimeoutError(pending_names, shutdown_timeout) - logger.warning( - "MCP server shutdown timeout (%s seconds), the following servers were not fully closed: %s", - f"{shutdown_timeout:g}", - ", ".join(pending_names), - ) - return pending_names - else: - for result in results: - if isinstance(result, asyncio.CancelledError): - logger.debug("MCP lifecycle task was cancelled during shutdown.") - elif isinstance(result, Exception): - logger.error( - "MCP lifecycle task failed during shutdown.", - exc_info=(type(result), result, result.__traceback__), - ) - return [] - - async def _cleanup_mcp_client_safely( - self, mcp_client: MCPClient, name: str - ) -> None: - """安全清理单个 MCP 客户端,避免清理异常中断主流程。""" - try: - await mcp_client.cleanup() - except Exception as cleanup_exc: # only log here - logger.error( - f"Failed to cleanup MCP client resources {name}: {cleanup_exc}" - ) - - async def _init_mcp_client(self, name: str, config: dict) -> MCPClient: - """初始化单个MCP客户端""" - mcp_client = MCPClient() - mcp_client.name = name - try: - await mcp_client.connect_to_server(config, name) - tools_res = await mcp_client.list_tools_and_save() - except asyncio.CancelledError: - await self._cleanup_mcp_client_safely(mcp_client, name) - raise - except Exception: - await self._cleanup_mcp_client_safely(mcp_client, name) - raise - logger.debug(f"MCP server {name} list tools response: {tools_res}") - tool_names = [tool.name for tool in tools_res.tools] - - # 移除该MCP服务之前的工具(如有) - self.func_list = [ - f - for f in self.func_list - if not (isinstance(f, MCPTool) and f.mcp_server_name == name) - ] - - # 将 MCP 工具转换为 FuncTool 并添加到 func_list - for tool in mcp_client.tools: - func_tool = MCPTool( - mcp_tool=tool, - mcp_client=mcp_client, - mcp_server_name=name, - ) - self.func_list.append(func_tool) - - logger.info(f"Connected to MCP server {name}, Tools: {tool_names}") - return mcp_client - - async def _terminate_mcp_client(self, name: str) -> None: - """关闭并清理MCP客户端""" - async with self._runtime_lock: - runtime = self._mcp_server_runtime.get(name) - if runtime: - client = runtime.client - # 关闭MCP连接 - await self._cleanup_mcp_client_safely(client, name) - # 移除关联的FuncTool - self.func_list = [ - f - for f in self.func_list - if not (isinstance(f, MCPTool) and f.mcp_server_name == name) - ] - async with self._runtime_lock: - self._mcp_server_runtime.pop(name, None) - self._mcp_starting.discard(name) - logger.info(f"Disconnected from MCP server {name}") - return - - # Runtime missing but stale tools may still exist after failed flows. - self.func_list = [ - f - for f in self.func_list - if not (isinstance(f, MCPTool) and f.mcp_server_name == name) - ] - async with self._runtime_lock: - self._mcp_starting.discard(name) - - @staticmethod - async def test_mcp_server_connection(config: dict) -> list[str]: - if "url" in config: - success, error_msg = await _quick_test_mcp_connection(config) - if not success: - raise Exception(error_msg) - - mcp_client = MCPClient() - try: - logger.debug(f"testing MCP server connection with config: {config}") - await mcp_client.connect_to_server(config, "test") - tools_res = await mcp_client.list_tools_and_save() - tool_names = [tool.name for tool in tools_res.tools] - finally: - logger.debug("Cleaning up MCP client after testing connection.") - await mcp_client.cleanup() - return tool_names - - async def enable_mcp_server( - self, - name: str, - config: dict, - shutdown_event: asyncio.Event | None = None, - init_timeout: float | str | None = None, - ) -> None: - """Enable a new MCP server and initialize it. - - Args: - name: The name of the MCP server. - config: Configuration for the MCP server. - shutdown_event: Event to signal when the MCP client should shut down. - init_timeout: Timeout in seconds for initialization. - Uses ASTRBOT_MCP_ENABLE_TIMEOUT by default (separate from init timeout). - - Raises: - MCPInitTimeoutError: If initialization does not complete within timeout. - Exception: If there is an error during initialization. - """ - if init_timeout is None: - timeout_value = self._enable_timeout_default - else: - timeout_value = _resolve_timeout( - timeout=init_timeout, - env_name=ENABLE_MCP_TIMEOUT_ENV, - default=self._enable_timeout_default, - ) - await self._start_mcp_server( - name=name, - cfg=config, - shutdown_event=shutdown_event, - init_timeout=timeout_value, - ) - - async def disable_mcp_server( - self, - name: str | None = None, - shutdown_timeout: float = 10, - ) -> None: - """Disable an MCP server by its name. - - Args: - name (str): The name of the MCP server to disable. If None, ALL MCP servers will be disabled. - shutdown_timeout (int): Timeout. - - Raises: - MCPShutdownTimeoutError: If shutdown does not complete within timeout. - Only raised when disabling a specific server (name is not None). - - """ - if name: - async with self._runtime_lock: - runtime = self._mcp_server_runtime.get(name) - if runtime is None: - return - - await self._shutdown_runtimes([runtime], shutdown_timeout, strict=True) - else: - async with self._runtime_lock: - runtimes = list(self._mcp_server_runtime.values()) - await self._shutdown_runtimes(runtimes, shutdown_timeout, strict=False) - - def _warn_on_timeout_mismatch( - self, - init_timeout: float, - enable_timeout: float, - ) -> None: - if init_timeout == enable_timeout: - return - with self._timeout_warn_lock: - if self._timeout_mismatch_warned: - return - logger.info( - "检测到 MCP 初始化超时与动态启用超时配置不同:" - "初始化使用 %s 秒,动态启用使用 %s 秒。如需一致,请设置相同值。", - f"{init_timeout:g}", - f"{enable_timeout:g}", - ) - self._timeout_mismatch_warned = True - - def get_func_desc_openai_style(self, omit_empty_parameter_field=False) -> list: - """获得 OpenAI API 风格的**已经激活**的工具描述""" - tools = [f for f in self.func_list if f.active] - toolset = ToolSet(tools) - return toolset.openai_schema( - omit_empty_parameter_field=omit_empty_parameter_field, - ) - - def get_func_desc_anthropic_style(self) -> list: - """获得 Anthropic API 风格的**已经激活**的工具描述""" - tools = [f for f in self.func_list if f.active] - toolset = ToolSet(tools) - return toolset.anthropic_schema() - - def get_func_desc_google_genai_style(self) -> dict: - """获得 Google GenAI API 风格的**已经激活**的工具描述""" - tools = [f for f in self.func_list if f.active] - toolset = ToolSet(tools) - return toolset.google_schema() - - def deactivate_llm_tool(self, name: str) -> bool: - """停用一个已经注册的函数调用工具。 - - Returns: - 如果没找到,会返回 False - - """ - func_tool = self.get_func(name) - if func_tool is not None: - func_tool.active = False - - inactivated_llm_tools: list = sp.get( - "inactivated_llm_tools", - [], - scope="global", - scope_id="global", - ) - if name not in inactivated_llm_tools: - inactivated_llm_tools.append(name) - sp.put( - "inactivated_llm_tools", - inactivated_llm_tools, - scope="global", - scope_id="global", - ) - - return True - return False - - # 因为不想解决循环引用,所以这里直接传入 star_map 先了... - def activate_llm_tool(self, name: str, star_map: dict) -> bool: - func_tool = self.get_func(name) - if func_tool is not None: - if func_tool.handler_module_path in star_map: - if not star_map[func_tool.handler_module_path].activated: - raise ValueError( - f"此函数调用工具所属的插件 {star_map[func_tool.handler_module_path].name} 已被禁用,请先在管理面板启用再激活此工具。", - ) - - func_tool.active = True - - inactivated_llm_tools: list = sp.get( - "inactivated_llm_tools", - [], - scope="global", - scope_id="global", - ) - if name in inactivated_llm_tools: - inactivated_llm_tools.remove(name) - sp.put( - "inactivated_llm_tools", - inactivated_llm_tools, - scope="global", - scope_id="global", - ) - - return True - return False - - @property - def mcp_config_path(self): - data_dir = get_astrbot_data_path() - return os.path.join(data_dir, "mcp_server.json") - - def load_mcp_config(self): - if not os.path.exists(self.mcp_config_path): - # 配置文件不存在,创建默认配置 - os.makedirs(os.path.dirname(self.mcp_config_path), exist_ok=True) - with open(self.mcp_config_path, "w", encoding="utf-8") as f: - json.dump(DEFAULT_MCP_CONFIG, f, ensure_ascii=False, indent=4) - return DEFAULT_MCP_CONFIG - - try: - with open(self.mcp_config_path, encoding="utf-8") as f: - return json.load(f) - except Exception as e: - logger.error(f"加载 MCP 配置失败: {e}") - return DEFAULT_MCP_CONFIG - - def save_mcp_config(self, config: dict) -> bool: - try: - with open(self.mcp_config_path, "w", encoding="utf-8") as f: - json.dump(config, f, ensure_ascii=False, indent=4) - return True - except Exception as e: - logger.error(f"保存 MCP 配置失败: {e}") - return False - - async def sync_modelscope_mcp_servers(self, access_token: str) -> None: - """从 ModelScope 平台同步 MCP 服务器配置""" - base_url = "https://www.modelscope.cn/openapi/v1" - url = f"{base_url}/mcp/servers/operational" - headers = { - "Authorization": f"Bearer {access_token.strip()}", - "Content-Type": "application/json", - } - - try: - async with aiohttp.ClientSession() as session: - async with session.get(url, headers=headers) as response: - if response.status == 200: - data = await response.json() - mcp_server_list = data.get("data", {}).get( - "mcp_server_list", - [], - ) - local_mcp_config = self.load_mcp_config() - - synced_count = 0 - for server in mcp_server_list: - server_name = server["name"] - operational_urls = server.get("operational_urls", []) - if not operational_urls: - continue - url_info = operational_urls[0] - server_url = url_info.get("url") - if not server_url: - continue - # 添加到配置中(同名会覆盖) - local_mcp_config["mcpServers"][server_name] = { - "url": server_url, - "transport": "sse", - "active": True, - "provider": "modelscope", - } - synced_count += 1 - - if synced_count > 0: - self.save_mcp_config(local_mcp_config) - tasks = [] - for server in mcp_server_list: - name = server["name"] - tasks.append( - self.enable_mcp_server( - name=name, - config=local_mcp_config["mcpServers"][name], - ), - ) - await asyncio.gather(*tasks) - logger.info( - f"从 ModelScope 同步了 {synced_count} 个 MCP 服务器", - ) - else: - logger.warning("没有找到可用的 ModelScope MCP 服务器") - else: - raise Exception( - f"ModelScope API 请求失败: HTTP {response.status}", - ) - - except aiohttp.ClientError as e: - raise Exception(f"网络连接错误: {e!s}") - except Exception as e: - raise Exception(f"同步 ModelScope MCP 服务器时发生错误: {e!s}") - - # Module paths whose ``get_all_tools()`` function returns internal tools. - # To add a new internal-tool provider, simply append its module path here. - _INTERNAL_TOOL_PROVIDERS: list[str] = [ - "astrbot.core.tools.cron_tools", - "astrbot.core.tools.kb_query", - "astrbot.core.tools.send_message", - "astrbot.core.computer.computer_tool_provider", - ] - - def register_internal_tools(self) -> None: - """Register AstrBot built-in tools from all internal providers. - - Each provider module is expected to expose a ``get_all_tools()`` - function that returns a list of ``FunctionTool`` instances. - - Tools are marked with ``source='internal'`` so the WebUI can - distinguish them from plugin and MCP tools, and subagent - orchestrators can resolve them by name. - - Duplicate registration is idempotent (skips if name already present). - """ - import importlib - - existing_names = {t.name for t in self.func_list} - - for module_path in self._INTERNAL_TOOL_PROVIDERS: - try: - mod = importlib.import_module(module_path) - provider_tools = mod.get_all_tools() - except Exception as e: - logger.warning( - "Failed to load internal tool provider %s: %s", - module_path, - e, - ) - continue - - for tool in provider_tools: - tool.source = "internal" - if tool.name not in existing_names: - self.func_list.append(tool) - existing_names.add(tool.name) - logger.info("Registered internal tool: %s", tool.name) - - def __str__(self) -> str: - return str(self.func_list) - - def __repr__(self) -> str: - return str(self.func_list) - - -# alias -FuncCall = FunctionToolManager +__all__ = [ + "DEFAULT_MCP_CONFIG", + "ENABLE_MCP_TIMEOUT_ENV", + "MCP_INIT_TIMEOUT_ENV", + "PY_TO_JSON_TYPE", + "SUPPORTED_TYPES", + "FuncCall", + "FuncTool", + "FunctionTool", + "FunctionToolManager", + "MCPAllServicesFailedError", + "MCPInitError", + "MCPInitSummary", + "MCPInitTimeoutError", + "MCPShutdownTimeoutError", + "ToolSet", +] diff --git a/astrbot/core/provider/sources/genie_tts.py b/astrbot/core/provider/sources/genie_tts.py index 1e78982ea..b39ac295f 100644 --- a/astrbot/core/provider/sources/genie_tts.py +++ b/astrbot/core/provider/sources/genie_tts.py @@ -12,7 +12,7 @@ from astrbot.core.provider.register import register_provider_adapter from astrbot.core.utils.astrbot_path import get_astrbot_temp_path try: - import genie_tts as genie # type: ignore + import genie_tts as genie except ImportError: genie = None diff --git a/astrbot/core/utils/file_extract.py b/astrbot/core/utils/file_extract.py index 020ecc67d..81d466151 100644 --- a/astrbot/core/utils/file_extract.py +++ b/astrbot/core/utils/file_extract.py @@ -18,6 +18,6 @@ async def extract_file_moonshotai(file_path: str, api_key: str) -> str: ) file_object = await client.files.create( file=Path(file_path), - purpose="file-extract", # type: ignore + purpose="file-extract", ) return (await client.files.content(file_id=file_object.id)).text diff --git a/astrbot/core/utils/shared_preferences.py b/astrbot/core/utils/shared_preferences.py index e629fb0be..8ba6e6928 100644 --- a/astrbot/core/utils/shared_preferences.py +++ b/astrbot/core/utils/shared_preferences.py @@ -44,8 +44,8 @@ class SharedPreferences: scope: str, scope_id: str, key: str, - default: _VT = None, # type: ignore[invalid-parameter-default] - ) -> _VT: + default: _VT | None = None, + ) -> _VT | None: """获取指定范围和键的偏好设置""" if scope_id is not None and key is not None: result = await self.db_helper.get_preference(scope, scope_id, key) @@ -72,8 +72,8 @@ class SharedPreferences: self, umo: str, key: str, - default: _VT = None, # type: ignore[invalid-parameter-default] - ) -> _VT: ... + default: _VT | None = None, + ) -> _VT | None: ... @overload async def session_get( @@ -103,8 +103,8 @@ class SharedPreferences: self, umo: str | None, key: str | None = None, - default: _VT = None, # type: ignore[invalid-parameter-default] - ) -> _VT | list[Preference]: + default: _VT | None = None, + ) -> _VT | None | list[Preference]: """获取会话范围的偏好设置 Note: 当 umo 或者 key 为 None,时,返回 Preference 列表,其中的 value 属性是一个 dict,value["val"] 为值。 @@ -117,13 +117,13 @@ class SharedPreferences: async def global_get(self, key: None, default: Any = None) -> list[Preference]: ... @overload - async def global_get(self, key: str, default: _VT = None) -> _VT: ... # type: ignore[invalid-parameter-default] + async def global_get(self, key: str, default: _VT | None = None) -> _VT | None: ... async def global_get( self, key: str | None, - default: _VT = None, # type: ignore[invalid-parameter-default] - ) -> _VT | list[Preference]: + default: _VT | None = None, + ) -> _VT | None | list[Preference]: """获取全局范围的偏好设置 Note: 当 scope_id 或者 key 为 None,时,返回 Preference 列表,其中的 value 属性是一个 dict,value["val"] 为值。 @@ -169,10 +169,10 @@ class SharedPreferences: def get( self, key: str, - default: _VT = None, # type: ignore[invalid-parameter-default] + default: _VT | None = None, scope: str | None = None, scope_id: str | None = "", - ) -> _VT: + ) -> _VT | None: """获取偏好设置(已弃用)""" if scope_id == "": scope_id = "unknown" diff --git a/dashboard/src/components/chat/message_list_comps/MessagePartsRenderer.vue b/dashboard/src/components/chat/message_list_comps/MessagePartsRenderer.vue index c7718e698..215bc2a74 100644 --- a/dashboard/src/components/chat/message_list_comps/MessagePartsRenderer.vue +++ b/dashboard/src/components/chat/message_list_comps/MessagePartsRenderer.vue @@ -71,13 +71,18 @@ - - + + { class="rounded-lg d-flex flex-column plugin-card" elevation="0" > - @@ -51,7 +50,7 @@ const handleInstall = (plugin) => { :src="plugin?.logo || defaultPluginIcon" :alt="plugin.name" class="plugin-cover__image" - /> + >
@@ -130,7 +129,7 @@ const handleInstall = (plugin) => { icon="mdi-star" size="x-small" style="margin-right: 2px" - > + /> {{ plugin.stars }}
@@ -159,7 +158,7 @@ const handleInstall = (plugin) => { /> -
+
diff --git a/dashboard/src/components/extension/PinnedPluginItem.vue b/dashboard/src/components/extension/PinnedPluginItem.vue index 8fbb7dfd0..241059e98 100644 --- a/dashboard/src/components/extension/PinnedPluginItem.vue +++ b/dashboard/src/components/extension/PinnedPluginItem.vue @@ -65,55 +65,119 @@ const handlePinnedImgError = (e) => { :src="(typeof plugin.logo === 'string' && plugin.logo.trim()) ? plugin.logo : defaultPluginIcon" :alt="plugin.name" @error="handlePinnedImgError" - /> + > - - + + - + - + - + - + - + @@ -127,11 +191,13 @@ const handlePinnedImgError = (e) => { size="small" class="pinned-pin-btn" :color="isPinned ? 'primary' : 'secondary'" - @click.stop="$emit('toggle-pin', plugin)" :title="isPinned ? tm('buttons.unpin') : tm('buttons.pin')" style="position:absolute; top:6px; right:6px; min-width:22px; width:22px; height:22px;" + @click.stop="$emit('toggle-pin', plugin)" > - {{ isPinned ? 'mdi-pin' : 'mdi-pin-outline' }} + + {{ isPinned ? 'mdi-pin' : 'mdi-pin-outline' }} + diff --git a/dashboard/src/components/folder/BaseFolderCard.vue b/dashboard/src/components/folder/BaseFolderCard.vue index dfab57224..0ba4c6c91 100644 --- a/dashboard/src/components/folder/BaseFolderCard.vue +++ b/dashboard/src/components/folder/BaseFolderCard.vue @@ -1,48 +1,89 @@