Files
MoviePilot/app/agent/tools/factory.py
jxxghp 6d4ce83837 fix(types): clear host mypy debt and prevent typing regressions
严格检查全部宿主源码,清零类型错误预算并移除按模块类型豁免。
按原因、所属方法、错误码及语法指纹锁定必要兼容例外和站点类型桩,
CI 阻断新增忽略、检查范围退化、本地桩替代真实接口及新的类型错误。

修复空值、异步和供应方类型边界,保持插件与 API 兼容合同,
并补充存储、订阅、通知、Agent 和工作流边界的回归覆盖。
2026-10-21 22:03:18 +08:00

330 lines
13 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
构造并缓存内置、插件及 MCP 工具实例,统一注入调用上下文。
"""
import hashlib
from typing import (
TYPE_CHECKING,
List,
NotRequired,
Optional,
Type,
TypedDict,
Unpack,
cast,
)
if TYPE_CHECKING:
from app.agent.callback import StreamingHandler
from app.agent.llm.capability import AgentCapabilityManager
from app.agent.tools.impl.agent_task import AgentTaskTool
from app.agent.tools.impl.api import MoviePilotApiTool
from app.agent.tools.impl.apply_patch import ApplyPatchTool
from app.agent.tools.impl.ask_user_choice import AskUserChoiceTool
from app.agent.tools.impl.browse_webpage import BrowseWebpageTool
from app.agent.tools.impl.edit_file import EditFileTool
from app.agent.tools.impl.execute_code import ExecuteCodeTool
from app.agent.tools.impl.execute_command import ExecuteCommandTool
from app.agent.tools.impl.persona import PersonaTool
from app.agent.tools.impl.query_doctor_report import QueryDoctorReportTool
from app.agent.tools.impl.read_file import ReadFileTool
from app.agent.tools.impl.recognize_captcha import RecognizeCaptchaTool
from app.agent.tools.impl.search_web import SearchWebTool
from app.agent.tools.impl.send_local_file import SendLocalFileTool
from app.agent.tools.impl.send_message import SendMessageTool
from app.agent.tools.impl.send_voice_message import SendVoiceMessageTool
from app.agent.tools.impl.service import (
DatabaseOperationTool,
DownloaderOperationTool,
MediaServerOperationTool,
)
from app.agent.tools.impl.view_image import ViewImageTool
from app.agent.tools.impl.write_file import WriteFileTool
from app.application.agent import AgentDataContext
from app.application.plugin.runtime import get_plugin_manager
from app.runtime.log import logger
from app.schemas.notification import ChannelCapabilityManager
from app.schemas.types import NotificationChannel
from .base import MoviePilotTool
from .catalog import ToolCatalogError, ToolCatalogSnapshot
class _PluginToolInfo(TypedDict):
"""
插件运行时提供的一条 Agent 工具登记记录。
:ivar plugin_id: 插件的稳定标识
:ivar plugin_name: 插件显示名称
:ivar tools: 插件登记的工具类
"""
plugin_id: str
plugin_name: str
tools: list[type[MoviePilotTool]]
class _ToolFactoryOptions(TypedDict):
"""
构造目录时原样交给工具创建入口的可选参数。
:ivar session_id: 当前会话标识
:ivar user_id: 当前用户标识
:ivar channel: 可缺省的消息渠道
:ivar source: 可缺省的消息来源
:ivar username: 可缺省的用户显示名
:ivar stream_handler: 可缺省的流式回调处理器
:ivar agent_context: 可缺省的 Agent 上下文
:ivar allow_message_tools: 是否允许消息工具
:ivar include_external_service_tools: 是否包含外部服务工具
:ivar data: 可缺省的 Agent 数据上下文
"""
session_id: str
user_id: str
channel: NotRequired[Optional[str]]
source: NotRequired[Optional[str]]
username: NotRequired[Optional[str]]
stream_handler: NotRequired[Optional["StreamingHandler"]]
agent_context: NotRequired[Optional[dict[str, object]]]
allow_message_tools: NotRequired[bool]
include_external_service_tools: NotRequired[bool]
data: NotRequired[Optional[AgentDataContext]]
def _get_plugin_agent_tools() -> list[_PluginToolInfo]:
"""
读取当前插件工具投影,隔离 Agent 工具工厂与 Runtime 管理器。
"""
try:
return cast(list[_PluginToolInfo], get_plugin_manager().get_plugin_agent_tools())
except RuntimeError as error:
# 纯工具目录探针可以在启动组合根之前运行;此时只跳过可选插件工具,
# 不隐式创建 PluginManager,避免冷导入重新引入 Runtime 定位器。
if "尚未由启动组合根装配" not in str(error):
raise
return []
class MoviePilotToolFactory:
"""
MoviePilot工具工厂
"""
BUILTIN_TOOL_CLASSES: tuple[Type[MoviePilotTool], ...] = (
SearchWebTool,
RecognizeCaptchaTool,
SendMessageTool,
AgentTaskTool,
PersonaTool,
ExecuteCommandTool,
ExecuteCodeTool,
EditFileTool,
ApplyPatchTool,
WriteFileTool,
ReadFileTool,
BrowseWebpageTool,
ViewImageTool,
QueryDoctorReportTool,
)
# 下载器与媒体服务器原生操作通过 Skill 按需提供给内置 Agent;只有外部
# HTTP/MCP 工具管理器需要常驻、自描述的结构化入口。
EXTERNAL_SERVICE_TOOL_CLASSES: tuple[Type[MoviePilotTool], ...] = (
DownloaderOperationTool,
MediaServerOperationTool,
DatabaseOperationTool,
)
# 这些通用工具需要始终保留,避免大工具集裁剪后让 Agent 丢失基础的
# 文件系统、命令执行、历史检索或交互确认能力。AskUserChoiceTool 仅在支持按钮
# 的渠道中才会实际注入,因此后续会再按已加载工具做一次求交集。
TOOL_SELECTOR_ALWAYS_INCLUDE_NAMES = (
"moviepilot_api",
"write_file",
"read_file",
"edit_file",
"apply_patch",
"execute_command",
"execute_code",
"ask_user_choice",
"agent_task",
"search_memory",
"session_search",
)
CATALOG_BUILD_MAX_ATTEMPTS = 3
@classmethod
def catalog_factory_revision(cls) -> str:
"""
返回当前内置工具工厂定义的稳定摘要。
"""
identities = (
f"{tool_class.__module__}.{tool_class.__qualname__}"
for tool_class in (*cls.BUILTIN_TOOL_CLASSES, *cls.EXTERNAL_SERVICE_TOOL_CLASSES)
)
return hashlib.sha256("\n".join(identities).encode("utf-8")).hexdigest()
@staticmethod
def _should_enable_choice_tool(channel: Optional[str] = None) -> bool:
"""
只在同时支持按钮与回调的渠道暴露交互选择能力。
"""
if not channel:
return False
try:
message_channel = NotificationChannel(channel)
except ValueError:
return False
return ChannelCapabilityManager.supports_buttons(
message_channel
) and ChannelCapabilityManager.supports_callbacks(message_channel)
@classmethod
def get_tool_selector_always_include_names(cls, tools: List[MoviePilotTool]) -> List[str]:
"""
返回当前实际已加载且需要绕过工具筛选的工具名。
`LLMToolSelectorMiddleware` 会校验 `always_include` 中的工具名是否
存在于当前请求里,因此这里必须根据运行时工具列表做交集过滤。
"""
available_tool_names = {tool.name for tool in tools if getattr(tool, "name", None)}
return [tool_name for tool_name in cls.TOOL_SELECTOR_ALWAYS_INCLUDE_NAMES if tool_name in available_tool_names]
@classmethod
def _get_builtin_tool_classes(cls, channel: Optional[str] = None) -> list[Type[MoviePilotTool]]:
"""
返回当前渠道可用的内置工具类清单。
"""
tool_definitions = list(cls.BUILTIN_TOOL_CLASSES)
if cls._should_enable_choice_tool(channel):
tool_definitions.append(AskUserChoiceTool)
tool_definitions.append(SendLocalFileTool)
if AgentCapabilityManager.supports_audio_output():
tool_definitions.append(SendVoiceMessageTool)
tool_definitions.append(MoviePilotApiTool)
return tool_definitions
@staticmethod
def _tool_class_name(tool_class: Type[MoviePilotTool]) -> str:
"""
读取工具类的稳定名称,兼容 Pydantic 字段未直接暴露在类对象上的情况。
"""
name = getattr(tool_class, "name", None)
if isinstance(name, str) and name:
return name
fields = getattr(tool_class, "model_fields", {})
field = fields.get("name") if isinstance(fields, dict) else None
default = getattr(field, "default", None)
return default if isinstance(default, str) else ""
@classmethod
def create_tools(
cls,
session_id: str,
user_id: str,
channel: Optional[str] = None,
source: Optional[str] = None,
username: Optional[str] = None,
stream_handler: Optional["StreamingHandler"] = None,
agent_context: Optional[dict[str, object]] = None,
allow_message_tools: bool = True,
include_external_service_tools: bool = False,
data: Optional[AgentDataContext] = None,
) -> List[MoviePilotTool]:
"""
创建统一 MoviePilot 工具列表。
主 Agent、子 Agent、HTTP/MCP 管理器均使用同一新模式:业务能力由
`moviepilot_api` 结构化网关承载,只有宿主交互与 Agent 编排能力保留为
原生工具。
"""
tools = []
tool_definitions = cls._get_builtin_tool_classes(channel)
if include_external_service_tools:
tool_definitions.extend(cls.EXTERNAL_SERVICE_TOOL_CLASSES)
# 创建内置工具
for ToolClass in tool_definitions:
if ToolClass is ExecuteCodeTool and (include_external_service_tools or not (agent_context or {}).get('is_admin')):
continue
tool = ToolClass(session_id=session_id, user_id=user_id, data=data)
if not allow_message_tools and getattr(tool, "sends_message", False):
continue
tool.set_message_attr(channel=channel, source=source, username=username)
tool.set_stream_handler(stream_handler=stream_handler)
tool.set_agent_context(agent_context=agent_context)
object.__setattr__(tool, "_agent_tool_source", "builtin")
tools.append(tool)
# 加载插件提供的工具
plugin_tools_count = 0
plugin_tools_info = _get_plugin_agent_tools()
for plugin_info in plugin_tools_info:
plugin_id = plugin_info.get("plugin_id")
plugin_name = plugin_info.get("plugin_name")
tool_classes = plugin_info.get("tools", [])
for ToolClass in tool_classes:
try:
# 验证工具类是否继承自 MoviePilotTool
if not issubclass(ToolClass, MoviePilotTool):
logger.warning(
f"插件 {plugin_name}({plugin_id}) 提供的工具类 {ToolClass.__name__} 未继承自 MoviePilotTool,已跳过"
)
continue
# 创建工具实例
tool = ToolClass(session_id=session_id, user_id=user_id)
if not allow_message_tools and getattr(tool, "sends_message", False):
continue
tool.set_message_attr(channel=channel, source=source, username=username)
tool.set_stream_handler(stream_handler=stream_handler)
tool.set_agent_context(agent_context=agent_context)
object.__setattr__(
tool,
"_agent_tool_source",
f"plugin:{plugin_id or 'unknown'}",
)
tools.append(tool)
plugin_tools_count += 1
logger.debug(f"成功加载插件 {plugin_name}({plugin_id}) 的工具: {ToolClass.__name__}")
except Exception as e:
logger.error(f"加载插件 {plugin_name}({plugin_id}) 的工具 {ToolClass.__name__} 失败: {str(e)}")
builtin_tools_count = len(tool_definitions)
if plugin_tools_count > 0:
logger.debug(
f"成功创建 {len(tools)} 个MoviePilot工具(内置工具: {builtin_tools_count} 个,插件工具: {plugin_tools_count} 个)"
)
else:
logger.debug(f"成功创建 {len(tools)} 个MoviePilot工具")
return tools
@classmethod
def create_catalog(cls, **tool_kwargs: Unpack[_ToolFactoryOptions]) -> ToolCatalogSnapshot:
"""
在插件目录稳定窗口内构造一份完整本地工具快照。
"""
try:
plugin_manager = get_plugin_manager()
except RuntimeError as error:
# 没有启动上下文时仍允许构造内置工具目录;插件工具会在正式启动
# 后由组合根提供的 Runtime 中重新物化。
if "尚未由启动组合根装配" not in str(error):
raise
return ToolCatalogSnapshot.from_tools(
cls.create_tools(**tool_kwargs),
plugin_revision=0,
factory_revision=cls.catalog_factory_revision(),
).require_unique()
for _attempt in range(cls.CATALOG_BUILD_MAX_ATTEMPTS):
before_revision = plugin_manager.get_plugin_agent_tools_revision()
tools = cls.create_tools(**tool_kwargs)
after_revision = plugin_manager.get_plugin_agent_tools_revision()
if before_revision == after_revision:
return ToolCatalogSnapshot.from_tools(
tools,
plugin_revision=after_revision,
factory_revision=cls.catalog_factory_revision(),
).require_unique()
raise ToolCatalogError("插件工具目录持续变化,无法建立当前快照")