Files
MoviePilot/app/agent/tools/manager.py
T

502 lines
18 KiB
Python
Raw 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.
from __future__ import annotations
import json
import threading
import uuid
from typing import TYPE_CHECKING, Any, Dict, List, Optional
from app.runtime.log import logger
if TYPE_CHECKING:
from app.agent.policy import AgentToolPolicyOrchestrator, ToolPolicyContext
from app.agent.tools.catalog import ToolCatalogSnapshot
class ToolDefinition:
"""
工具定义
"""
def __init__(self, name: str, description: str, input_schema: Dict[str, Any]):
self.name = name
self.description = description
self.input_schema = input_schema
class MoviePilotToolsManager:
"""
MoviePilot工具管理器(用于HTTP API
"""
def __init__(
self,
user_id: str = "api_user",
session_id: str = uuid.uuid4(),
is_admin: bool = True,
policy_orchestrator: Optional[AgentToolPolicyOrchestrator] = None,
):
"""
初始化工具管理器
Args:
user_id: 用户ID
session_id: 会话ID
"""
self.user_id = user_id
self.session_id = session_id
self.is_admin = is_admin
self.policy_orchestrator = policy_orchestrator
self._policy_context: Optional[ToolPolicyContext] = None
self.tools: List[Any] = []
self.catalog: Optional[ToolCatalogSnapshot] = None
self._tools_lock = threading.Lock()
self._plugin_agent_tools_revision = -1
self._catalog_materialized = False
self._catalog_managed_by_factory = False
@staticmethod
def _summarize_error(error: Exception) -> str:
"""仅在错误路径加载策略脱敏器,保持默认导入轻量。"""
from app.agent.policy import summarize_error
return summarize_error(error)
def _load_tools_locked(self) -> None:
"""
在 manager 锁内加载所有 MoviePilot 工具。
工厂负责插件 revision 前后稳定窗口;manager 只发布完整快照,避免
并发调用观察到一半刷新后的工具列表。
"""
from app.agent.runtime_loader import get_tool_factory
try:
catalog = get_tool_factory().create_catalog(
session_id=self.session_id,
user_id=self.user_id,
channel=None,
source="api",
username="API Client",
stream_handler=None,
agent_context={"is_admin": self.is_admin},
)
self.catalog = catalog
self.tools = catalog.tools
self._plugin_agent_tools_revision = catalog.plugin_revision
self._catalog_materialized = True
self._catalog_managed_by_factory = True
logger.info(f"成功加载 {len(self.tools)} 个工具")
except Exception as e:
logger.error(f"加载工具失败: {self._summarize_error(e)}")
self.tools = []
self.catalog = None
self._plugin_agent_tools_revision = -1
self._catalog_materialized = False
self._catalog_managed_by_factory = False
def _load_tools(self) -> None:
"""兼容显式刷新入口,并保证外部调用仍原子发布完整目录。"""
with self._tools_lock:
self._load_tools_locked()
def _ensure_tools_current(self) -> None:
"""
首次使用时加载目录,并在插件注册表变化后惰性刷新工具实例。
"""
# 调用方可能显式注入工具实例;这些实例仍由调用方拥有,manager 不应
# 在第一次查询时用全量目录覆盖它们。
if not self._catalog_materialized and self.tools:
self._catalog_materialized = True
return
if self._catalog_materialized and not self._catalog_managed_by_factory:
return
if not self._catalog_materialized:
with self._tools_lock:
if not self._catalog_materialized:
self._load_tools_locked()
return
from app.runtime.extensions.plugin_manager import PluginManager
plugin_manager = PluginManager()
if (
self._plugin_agent_tools_revision
== plugin_manager.get_plugin_agent_tools_revision()
):
return
with self._tools_lock:
if (
self._plugin_agent_tools_revision
== plugin_manager.get_plugin_agent_tools_revision()
):
return
self._load_tools_locked()
def _ensure_policy_runtime(
self,
) -> tuple[AgentToolPolicyOrchestrator, ToolPolicyContext]:
"""返回 direct 入口的策略对象,仅在真实工具调用前完成构造。"""
policy_orchestrator = self.policy_orchestrator
policy_context = self._policy_context
if policy_orchestrator is not None and policy_context is not None:
return policy_orchestrator, policy_context
from app.agent.policy import (
DEFAULT_TOOL_POLICY_ORCHESTRATOR,
AuthSource,
PrincipalType,
ToolOrigin,
ToolPolicyContext,
)
if policy_orchestrator is None:
policy_orchestrator = DEFAULT_TOOL_POLICY_ORCHESTRATOR
if policy_context is None:
policy_context = ToolPolicyContext(
session_id=self.session_id,
user_id=self.user_id,
origin=ToolOrigin.OPERATOR_DIRECT,
principal_type=PrincipalType.SYSTEM_ADMIN_INTEGRATION,
auth_source=AuthSource.API_TOKEN,
channel=None,
source="api",
agent_context={"is_admin": self.is_admin},
)
self.policy_orchestrator = policy_orchestrator
self._policy_context = policy_context
return policy_orchestrator, policy_context
def list_tools(self) -> List[ToolDefinition]:
"""
列出所有可用的工具
Returns:
工具定义列表
"""
self._ensure_tools_current()
with self._tools_lock:
tools = list(self.tools)
tools_list = []
for tool in tools:
if getattr(tool, "_require_admin", False) and not self.is_admin:
continue
# 获取工具的输入参数模型
args_schema = getattr(tool, "args_schema", None)
if args_schema:
# 将Pydantic模型转换为JSON Schema
input_schema = self._convert_to_json_schema(args_schema)
else:
# 如果没有args_schema,使用基本信息
input_schema = {"type": "object", "properties": {}, "required": []}
tools_list.append(
ToolDefinition(
name=tool.name,
description=tool.description or "",
input_schema=input_schema,
)
)
return tools_list
def get_tool(self, tool_name: str) -> Optional[Any]:
"""
获取指定工具实例
Args:
tool_name: 工具名称
Returns:
工具实例,如果未找到返回None
"""
self._ensure_tools_current()
with self._tools_lock:
return next(
(tool for tool in self.tools if tool.name == tool_name),
None,
)
def get_strict_tool(self, tool_name: str) -> Optional[Any]:
"""按当前目录唯一身份解析严格调用,重名时稳定失败。"""
self._ensure_tools_current()
with self._tools_lock:
if self.catalog is None or [
id(tool) for tool in self.catalog.tools
] != [id(tool) for tool in self.tools]:
from app.agent.runtime_loader import get_tool_factory
from app.agent.tools.catalog import ToolCatalogSnapshot
self.catalog = ToolCatalogSnapshot.from_tools(
self.tools,
plugin_revision=self._plugin_agent_tools_revision,
factory_revision=get_tool_factory().catalog_factory_revision(),
)
if self.catalog is None:
return None
entry = self.catalog.resolve_unique(tool_name)
return entry.tool if entry else None
@staticmethod
def _resolve_field_schema(field_info: Dict[str, Any]) -> Dict[str, Any]:
"""
解析字段schema,兼容 Optional[T] 生成的 anyOf 结构
"""
if field_info.get("type"):
return field_info
any_of = field_info.get("anyOf")
if not any_of:
return field_info
for type_option in any_of:
if type_option.get("type") and type_option["type"] != "null":
merged = dict(type_option)
if "description" not in merged and field_info.get("description"):
merged["description"] = field_info["description"]
if "default" not in merged and "default" in field_info:
merged["default"] = field_info["default"]
return merged
return field_info
@staticmethod
def _normalize_scalar_value(field_type: Optional[str], value: Any, key: str) -> Any:
"""
根据字段类型规范化单个值
"""
if field_type == "integer" and isinstance(value, str):
try:
return int(value)
except (ValueError, TypeError):
logger.warning(f"无法将参数 {key}='{value}' 转换为整数,返回 None")
return None
if field_type == "number" and isinstance(value, str):
try:
return float(value)
except (ValueError, TypeError):
logger.warning(f"无法将参数 {key}='{value}' 转换为浮点数,返回 None")
return None
if field_type == "boolean":
if isinstance(value, str):
return value.lower() in ("true", "1", "yes", "on")
if isinstance(value, (int, float)):
return value != 0
if isinstance(value, bool):
return value
return True
return value
@staticmethod
def _parse_array_string(value: str, key: str, item_type: str = "string") -> list:
"""
将逗号分隔的字符串解析为列表,并根据 item_type 转换元素类型
"""
trimmed = value.strip()
if not trimmed:
return []
return [
MoviePilotToolsManager._normalize_scalar_value(item_type, item.strip(), key)
for item in trimmed.split(",")
if item.strip()
]
@staticmethod
def _normalize_arguments(
tool_instance: Any, arguments: Dict[str, Any]
) -> Dict[str, Any]:
"""
根据工具的参数schema规范化参数类型
Args:
tool_instance: 工具实例
arguments: 原始参数
Returns:
规范化后的参数
"""
# 获取工具的参数schema
args_schema = getattr(tool_instance, "args_schema", None)
if not args_schema:
return arguments
# 获取schema中的字段定义
try:
schema = args_schema.model_json_schema()
properties = schema.get("properties", {})
except Exception as e:
logger.warning(f"获取工具schema失败: {MoviePilotToolsManager._summarize_error(e)}")
return arguments
# 规范化参数
normalized = {}
for key, value in arguments.items():
if key not in properties:
# 参数不在schema中,保持原样
normalized[key] = value
continue
field_info = MoviePilotToolsManager._resolve_field_schema(properties[key])
field_type = field_info.get("type")
# 数组类型:将字符串解析为列表
if field_type == "array" and isinstance(value, str):
item_type = field_info.get("items", {}).get("type", "string")
normalized[key] = MoviePilotToolsManager._parse_array_string(
value, key, item_type
)
continue
# 根据类型进行转换
normalized[key] = MoviePilotToolsManager._normalize_scalar_value(
field_type, value, key
)
return normalized
def _check_tool_permission(self, tool_instance: Any) -> Optional[str]:
"""为 HTTP/MCP/CLI 入口补齐 require_admin 门禁。"""
if getattr(tool_instance, "_require_admin", False) and not self.is_admin:
return "抱歉,您没有执行此工具的权限。只有系统管理员才能执行工具操作。"
return None
async def call_tool(self, tool_name: str, arguments: Dict[str, Any]) -> str:
"""
调用工具
Args:
tool_name: 工具名称
arguments: 工具参数
Returns:
工具执行结果(字符串)
"""
tool_instance = self.get_tool(tool_name)
if not tool_instance:
error_msg = json.dumps(
{"error": f"工具 '{tool_name}' 未找到"}, ensure_ascii=False
)
return error_msg
from app.agent.policy import call_policy_hook
from app.agent.tools.base import (
ToolExecutionTimeoutError,
format_tool_result_for_agent,
)
observation = None
policy_orchestrator = None
try:
permission_error = self._check_tool_permission(tool_instance)
if permission_error:
return json.dumps({"error": permission_error}, ensure_ascii=False)
# 规范化参数类型
normalized_arguments = self._normalize_arguments(tool_instance, arguments)
policy_orchestrator, policy_context = self._ensure_policy_runtime()
policy_context.agent_context["is_admin"] = self.is_admin
observation = call_policy_hook(
"start",
policy_orchestrator.start,
context=policy_context,
tool=tool_instance,
arguments=normalized_arguments,
)
# 调用工具的run方法。HTTP/MCP 工具调用不会经过 BaseTool._arun
# 因此这里也必须复用同一套返回值格式化和兜底截断逻辑。
result = await tool_instance.run_with_timeout(**normalized_arguments)
str_result = format_tool_result_for_agent(
result,
tool_name=tool_name,
max_chars=getattr(tool_instance, "result_max_chars", None),
)
except ToolExecutionTimeoutError as e:
if observation is not None and policy_orchestrator is not None:
call_policy_hook("fail", policy_orchestrator.fail, observation, e)
error_summary = self._summarize_error(e)
logger.warning(error_summary)
return format_tool_result_for_agent(
error_summary,
tool_name=tool_name,
max_chars=getattr(tool_instance, "result_max_chars", None),
)
except Exception as e:
if observation is not None and policy_orchestrator is not None:
call_policy_hook("fail", policy_orchestrator.fail, observation, e)
error_summary = self._summarize_error(e)
logger.error(f"调用工具 {tool_name} 时发生错误: {error_summary}")
error_msg = json.dumps(
{"error": f"调用工具 '{tool_name}' 时发生错误: {error_summary}"},
ensure_ascii=False,
)
return error_msg
if observation is not None and policy_orchestrator is not None:
call_policy_hook(
"finish",
policy_orchestrator.finish,
observation,
str_result,
)
return str_result
@staticmethod
def _convert_to_json_schema(args_schema: Any) -> Dict[str, Any]:
"""
将Pydantic模型转换为JSON Schema
Args:
args_schema: Pydantic模型类
Returns:
JSON Schema字典
"""
# 获取Pydantic模型的字段信息
schema = args_schema.model_json_schema()
# 构建JSON Schema
properties = {}
required = []
if "properties" in schema:
for field_name, field_info in schema["properties"].items():
resolved_field_info = MoviePilotToolsManager._resolve_field_schema(
field_info
)
# 转换字段类型
field_type = resolved_field_info.get("type", "string")
field_description = resolved_field_info.get("description", "")
# 处理可选字段
if field_name not in schema.get("required", []):
# 可选字段
default_value = resolved_field_info.get("default")
properties[field_name] = {
"type": field_type,
"description": field_description,
}
if default_value is not None:
properties[field_name]["default"] = default_value
else:
properties[field_name] = {
"type": field_type,
"description": field_description,
}
required.append(field_name)
# 处理枚举类型
if "enum" in resolved_field_info:
properties[field_name]["enum"] = resolved_field_info["enum"]
# 处理数组类型
if field_type == "array" and "items" in resolved_field_info:
properties[field_name]["items"] = resolved_field_info["items"]
return {"type": "object", "properties": properties, "required": required}
moviepilot_tool_manager = MoviePilotToolsManager()