mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-06 07:56:52 +08:00
refactor(agent): 按需加载 Agent 运行时 (#6336)
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
"""Agent Capability 声明与通用入口适配器。"""
|
||||
|
||||
AGENT_ENTRYPOINT_KIND = "agent_entrypoint"
|
||||
AGENT_SERVICE_KIND = "agent_service"
|
||||
AGENT_MANAGER_CAPABILITY_ID = "agent.manager"
|
||||
AGENT_SERVICE_CAPABILITY_ID = "agent.service"
|
||||
MOVIEPILOT_AGENT_TYPE_CAPABILITY_ID = "agent.moviepilot_type"
|
||||
TOOL_FACTORY_CAPABILITY_ID = "agent.tool_factory"
|
||||
@@ -0,0 +1,218 @@
|
||||
"""Agent canonical entrypoint 的 Capability Runtime 适配器。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import importlib
|
||||
import inspect
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable, Mapping
|
||||
|
||||
from app.agent.capabilities import AGENT_ENTRYPOINT_KIND, AGENT_SERVICE_KIND
|
||||
from app.runtime.capabilities.errors import CapabilityAdapterContractError
|
||||
from app.runtime.capabilities.model import (
|
||||
ActivationPolicy,
|
||||
AdapterExecutionMode,
|
||||
CapabilitySpec,
|
||||
SelectorSchema,
|
||||
)
|
||||
from app.runtime.capabilities.registry import CapabilityRegistry
|
||||
from app.runtime.config import settings
|
||||
|
||||
|
||||
_DEFAULT_CAPABILITY_ROOT = Path(__file__).resolve().parent
|
||||
_SETTING_SELECTOR = "setting_truthy"
|
||||
|
||||
|
||||
def _validate_setting_selector(config: Mapping[str, Any]) -> None:
|
||||
"""限制 selector 只能读取已声明的应用设置。"""
|
||||
key = config["key"]
|
||||
if not isinstance(key, str) or not key or not hasattr(settings, key):
|
||||
raise ValueError(f"未知应用设置:{key!r}")
|
||||
|
||||
|
||||
AGENT_SELECTOR_SCHEMAS = {
|
||||
_SETTING_SELECTOR: SelectorSchema(
|
||||
required_fields=frozenset({"key"}),
|
||||
validator=_validate_setting_selector,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
def _load_entrypoint(spec: CapabilitySpec) -> Any:
|
||||
"""按 manifest 解析 canonical 符号,不创建额外业务对象。"""
|
||||
module_name, symbol_name = spec.entrypoint.split(":", maxsplit=1)
|
||||
module = importlib.import_module(module_name)
|
||||
try:
|
||||
return getattr(module, symbol_name)
|
||||
except AttributeError as error:
|
||||
raise CapabilityAdapterContractError(
|
||||
f"{spec.entrypoint} 未公开 Agent entrypoint"
|
||||
) from error
|
||||
|
||||
|
||||
def _lifecycle_method(spec: CapabilitySpec, candidate: Any, name: str) -> Any:
|
||||
"""读取 Agent Service 必需的异步生命周期方法。"""
|
||||
callback = getattr(candidate, name, None)
|
||||
if not callable(callback):
|
||||
raise CapabilityAdapterContractError(
|
||||
f"{spec.entrypoint} 的 Agent Service 缺少 {name}()"
|
||||
)
|
||||
return callback
|
||||
|
||||
|
||||
class AgentEntrypointAdapter:
|
||||
"""把 canonical Python 符号作为无资源副作用的同步能力发布。"""
|
||||
|
||||
execution_mode = AdapterExecutionMode.SYNC
|
||||
|
||||
@staticmethod
|
||||
def materialize(spec: CapabilitySpec) -> Any:
|
||||
"""按 manifest entrypoint 导入 canonical 符号。"""
|
||||
return _load_entrypoint(spec)
|
||||
|
||||
@staticmethod
|
||||
def create(
|
||||
_spec: CapabilitySpec,
|
||||
implementation: Any,
|
||||
_generation: int,
|
||||
_previous: Any = None,
|
||||
) -> Any:
|
||||
"""发布 canonical 符号本身,不创建第二份业务对象。"""
|
||||
return implementation
|
||||
|
||||
@staticmethod
|
||||
def start(
|
||||
_spec: CapabilitySpec,
|
||||
_candidate: Any,
|
||||
_generation: int,
|
||||
) -> None:
|
||||
"""entrypoint 不拥有业务资源,初始化由独立 service 能力负责。"""
|
||||
|
||||
@staticmethod
|
||||
def stop(
|
||||
_spec: CapabilitySpec,
|
||||
_instance: Any,
|
||||
_generation: int,
|
||||
) -> None:
|
||||
"""撤销入口可见性;业务资源由独立 service 能力关闭。"""
|
||||
|
||||
@staticmethod
|
||||
def cleanup(
|
||||
_spec: CapabilitySpec,
|
||||
_candidate: Any,
|
||||
_generation: int,
|
||||
_error: BaseException,
|
||||
) -> None:
|
||||
"""entrypoint 启动无副作用,因此失败候选无需额外释放。"""
|
||||
|
||||
|
||||
class AgentServiceAdapter:
|
||||
"""把具备 initialize/close 的 canonical 对象接入异步资源生命周期。"""
|
||||
|
||||
execution_mode = AdapterExecutionMode.ASYNC
|
||||
|
||||
@staticmethod
|
||||
async def materialize(spec: CapabilitySpec) -> Any:
|
||||
"""在线程中导入 canonical service,避免阻塞应用事件循环。"""
|
||||
return await asyncio.to_thread(_load_entrypoint, spec)
|
||||
|
||||
@staticmethod
|
||||
async def create(
|
||||
_spec: CapabilitySpec,
|
||||
implementation: Any,
|
||||
_generation: int,
|
||||
_previous: Any = None,
|
||||
) -> Any:
|
||||
"""复用 canonical service,不复制其内部队列和后台任务所有权。"""
|
||||
return implementation
|
||||
|
||||
@staticmethod
|
||||
async def start(
|
||||
spec: CapabilitySpec,
|
||||
candidate: Any,
|
||||
_generation: int,
|
||||
) -> None:
|
||||
"""等待 service 在当前应用事件循环完成初始化。"""
|
||||
result = _lifecycle_method(spec, candidate, "initialize")()
|
||||
if not inspect.isawaitable(result):
|
||||
raise CapabilityAdapterContractError(
|
||||
f"{spec.entrypoint}.initialize() 必须返回 awaitable"
|
||||
)
|
||||
await result
|
||||
|
||||
@staticmethod
|
||||
async def stop(
|
||||
spec: CapabilitySpec,
|
||||
instance: Any,
|
||||
_generation: int,
|
||||
) -> None:
|
||||
"""等待 service 停止后台任务并释放其资源。"""
|
||||
result = _lifecycle_method(spec, instance, "close")()
|
||||
if not inspect.isawaitable(result):
|
||||
raise CapabilityAdapterContractError(
|
||||
f"{spec.entrypoint}.close() 必须返回 awaitable"
|
||||
)
|
||||
await result
|
||||
|
||||
@staticmethod
|
||||
async def cleanup(
|
||||
spec: CapabilitySpec,
|
||||
candidate: Any,
|
||||
generation: int,
|
||||
_error: BaseException,
|
||||
) -> None:
|
||||
"""初始化失败或关闭竞态时按相同 close 合同释放部分资源。"""
|
||||
await AgentServiceAdapter.stop(spec, candidate, generation)
|
||||
|
||||
|
||||
def _validate_registry(registry: CapabilityRegistry) -> None:
|
||||
"""固定 entrypoint 物化轴与 service 资源轴的声明合同。"""
|
||||
for spec in registry.list_specs():
|
||||
if set(spec.metadata) != {"name"}:
|
||||
raise ValueError(f"{spec.source}: Agent Capability metadata 只能包含 name")
|
||||
if spec.kind == AGENT_ENTRYPOINT_KIND:
|
||||
if spec.activation is not ActivationPolicy.ON_FIRST_USE:
|
||||
raise ValueError(
|
||||
f"{spec.source}: Agent entrypoint 必须使用 on_first_use"
|
||||
)
|
||||
if spec.selector is not None or spec.watch:
|
||||
raise ValueError(
|
||||
f"{spec.source}: Agent entrypoint 不接受 selector 或 watch"
|
||||
)
|
||||
continue
|
||||
if spec.activation is not ActivationPolicy.WHEN_CONFIGURED:
|
||||
raise ValueError(f"{spec.source}: Agent Service 必须使用 when_configured")
|
||||
selector = spec.selector
|
||||
if selector is None or selector.kind != _SETTING_SELECTOR:
|
||||
raise ValueError(f"{spec.source}: Agent Service 必须声明 setting_truthy")
|
||||
selector_key = str(selector.config["key"])
|
||||
if spec.watch != (selector_key,):
|
||||
raise ValueError(
|
||||
f"{spec.source}: Agent Service watch 必须只包含 selector key"
|
||||
)
|
||||
|
||||
|
||||
def build_agent_capability_registry(
|
||||
roots: Iterable[Path | str] | None = None,
|
||||
) -> CapabilityRegistry:
|
||||
"""发现 data-only Agent manifests,不导入编排器、Provider 或工具实现。"""
|
||||
registry = CapabilityRegistry.discover(
|
||||
tuple(roots) if roots is not None else (_DEFAULT_CAPABILITY_ROOT,),
|
||||
kinds={AGENT_ENTRYPOINT_KIND, AGENT_SERVICE_KIND},
|
||||
selector_schemas=AGENT_SELECTOR_SCHEMAS,
|
||||
)
|
||||
_validate_registry(registry)
|
||||
return registry
|
||||
|
||||
|
||||
def should_run_agent_service(spec: CapabilitySpec) -> bool:
|
||||
"""依据 manifest selector 判断 service 是否应拥有运行实例。"""
|
||||
selector = spec.selector
|
||||
if (
|
||||
spec.kind != AGENT_SERVICE_KIND
|
||||
or selector is None
|
||||
or selector.kind != _SETTING_SELECTOR
|
||||
):
|
||||
raise ValueError(f"{spec.source}: 不是可协调的 Agent Service 声明")
|
||||
return bool(getattr(settings, selector.config["key"]))
|
||||
@@ -0,0 +1,12 @@
|
||||
schema_version = 1
|
||||
id = "agent.manager"
|
||||
kind = "agent_entrypoint"
|
||||
entrypoint = "app.agent.orchestrator:agent_manager"
|
||||
depends_on = []
|
||||
|
||||
[metadata]
|
||||
name = "Agent Manager"
|
||||
|
||||
[activation]
|
||||
policy = "on_first_use"
|
||||
watch = []
|
||||
@@ -0,0 +1,12 @@
|
||||
schema_version = 1
|
||||
id = "agent.moviepilot_type"
|
||||
kind = "agent_entrypoint"
|
||||
entrypoint = "app.agent.orchestrator:MoviePilotAgent"
|
||||
depends_on = []
|
||||
|
||||
[metadata]
|
||||
name = "MoviePilot Agent Type"
|
||||
|
||||
[activation]
|
||||
policy = "on_first_use"
|
||||
watch = []
|
||||
@@ -0,0 +1,16 @@
|
||||
schema_version = 1
|
||||
id = "agent.service"
|
||||
kind = "agent_service"
|
||||
entrypoint = "app.agent.orchestrator:agent_manager"
|
||||
depends_on = []
|
||||
|
||||
[metadata]
|
||||
name = "Agent Service"
|
||||
|
||||
[activation]
|
||||
policy = "when_configured"
|
||||
watch = ["AI_AGENT_ENABLE"]
|
||||
|
||||
[activation.selector]
|
||||
kind = "setting_truthy"
|
||||
key = "AI_AGENT_ENABLE"
|
||||
@@ -0,0 +1,12 @@
|
||||
schema_version = 1
|
||||
id = "agent.tool_factory"
|
||||
kind = "agent_entrypoint"
|
||||
entrypoint = "app.agent.tools.factory:MoviePilotToolFactory"
|
||||
depends_on = []
|
||||
|
||||
[metadata]
|
||||
name = "Agent Tool Factory"
|
||||
|
||||
[activation]
|
||||
policy = "on_first_use"
|
||||
watch = []
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Agent 轻量公共合同,不触发模型、工具或编排运行时加载。"""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional
|
||||
|
||||
from app.schemas.types import ReplyMode
|
||||
|
||||
|
||||
def build_display_message(
|
||||
role: str,
|
||||
content: str = "",
|
||||
attachments: Optional[list[dict]] = None,
|
||||
status: str = "done",
|
||||
) -> dict[str, Any]:
|
||||
"""构造前后端共享的 Agent 会话展示消息。"""
|
||||
normalized_content = content or ""
|
||||
return {
|
||||
"id": f"{role}-{uuid.uuid4().hex}",
|
||||
"role": role,
|
||||
"content": normalized_content,
|
||||
"createdAt": int(datetime.now().timestamp() * 1000),
|
||||
"status": status,
|
||||
"tools": [],
|
||||
"segments": (
|
||||
[{"type": "text", "content": normalized_content}]
|
||||
if normalized_content
|
||||
else []
|
||||
),
|
||||
"attachments": attachments or [],
|
||||
"choices": [],
|
||||
}
|
||||
|
||||
|
||||
__all__ = ["ReplyMode", "build_display_message"]
|
||||
+52
-16
@@ -1,20 +1,56 @@
|
||||
"""Agent 内部使用的 LLM 适配层。"""
|
||||
"""Agent 内部使用的 LLM 适配层,公开对象按需解析。"""
|
||||
|
||||
from app.agent.llm.helper import LLMHelper, LLMTestError, LLMTestTimeout
|
||||
from app.agent.llm.capability import (
|
||||
AgentCapabilityManager,
|
||||
AgentCapabilityProvider,
|
||||
AudioCapabilityProvider,
|
||||
MiMoAudioProvider,
|
||||
OpenAIChatAudioProvider,
|
||||
OpenAIAudioProvider,
|
||||
)
|
||||
from app.agent.llm.provider import (
|
||||
LLMProviderAuthError,
|
||||
LLMProviderError,
|
||||
LLMProviderManager,
|
||||
render_auth_result_html,
|
||||
)
|
||||
from importlib import import_module
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.agent.llm.capability import (
|
||||
AgentCapabilityManager,
|
||||
AgentCapabilityProvider,
|
||||
AudioCapabilityProvider,
|
||||
MiMoAudioProvider,
|
||||
OpenAIAudioProvider,
|
||||
OpenAIChatAudioProvider,
|
||||
)
|
||||
from app.agent.llm.helper import LLMHelper, LLMTestError, LLMTestTimeout
|
||||
from app.agent.llm.provider import (
|
||||
LLMProviderAuthError,
|
||||
LLMProviderError,
|
||||
LLMProviderManager,
|
||||
render_auth_result_html,
|
||||
)
|
||||
|
||||
|
||||
_EXPORT_MODULES = {
|
||||
"LLMHelper": "app.agent.llm.helper",
|
||||
"LLMTestError": "app.agent.llm.helper",
|
||||
"LLMTestTimeout": "app.agent.llm.helper",
|
||||
"AgentCapabilityManager": "app.agent.llm.capability",
|
||||
"AgentCapabilityProvider": "app.agent.llm.capability",
|
||||
"AudioCapabilityProvider": "app.agent.llm.capability",
|
||||
"MiMoAudioProvider": "app.agent.llm.capability",
|
||||
"OpenAIChatAudioProvider": "app.agent.llm.capability",
|
||||
"OpenAIAudioProvider": "app.agent.llm.capability",
|
||||
"LLMProviderAuthError": "app.agent.llm.provider",
|
||||
"LLMProviderError": "app.agent.llm.provider",
|
||||
"LLMProviderManager": "app.agent.llm.provider",
|
||||
"render_auth_result_html": "app.agent.llm.provider",
|
||||
}
|
||||
|
||||
|
||||
def __getattr__(name: str) -> Any:
|
||||
"""首次访问公开对象时只加载其所属适配模块。"""
|
||||
module_name = _EXPORT_MODULES.get(name)
|
||||
if module_name is None:
|
||||
raise AttributeError(f"module 'app.agent.llm' has no attribute {name!r}")
|
||||
value = getattr(import_module(module_name), name)
|
||||
globals()[name] = value
|
||||
return value
|
||||
|
||||
|
||||
def __dir__() -> list[str]:
|
||||
"""让延迟公开对象继续支持交互式发现。"""
|
||||
return sorted(set(globals()) | set(_EXPORT_MODULES))
|
||||
|
||||
__all__ = [
|
||||
"LLMHelper",
|
||||
|
||||
+157
-108
@@ -4,6 +4,7 @@ import json
|
||||
import re
|
||||
import traceback
|
||||
import uuid
|
||||
import warnings
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
@@ -16,12 +17,10 @@ from langchain_core.messages import ( # noqa: F401
|
||||
SystemMessage,
|
||||
)
|
||||
|
||||
import warnings
|
||||
warnings.filterwarnings("ignore", message=".*allowed_objects.*")
|
||||
|
||||
from langgraph.checkpoint.memory import InMemorySaver
|
||||
|
||||
from app.agent.callback import StreamingHandler
|
||||
from app.agent.contracts import ReplyMode, build_display_message
|
||||
from app.agent.llm import LLMHelper
|
||||
from app.agent.llm.server_tools import ServerToolRegistry
|
||||
from app.agent.memory import memory_manager
|
||||
@@ -60,7 +59,6 @@ from app.agent.policy import (
|
||||
)
|
||||
from app.agent.runtime import agent_runtime_manager
|
||||
from app.agent.mcp import agent_mcp_manager
|
||||
from app.agent.tools.factory import MoviePilotToolFactory
|
||||
from app.agent.tools.catalog import ToolCatalogSnapshot
|
||||
from app.agent.tools.impl.mcp import (
|
||||
create_external_mcp_tools,
|
||||
@@ -76,11 +74,12 @@ from app.db.oper.agenttask import AgentTaskOper
|
||||
from app.db.oper.user import UserOper
|
||||
from app.runtime.log import logger
|
||||
from app.schemas import AgentLLMProviderEventData, AgentTokensUsageEventData, Notification, NotificationType
|
||||
from app.schemas.agent import ReplyMode
|
||||
from app.schemas.message import ChannelCapabilityManager, ChannelCapability
|
||||
from app.schemas.types import ChainEventType, EventType, MessageChannel
|
||||
from app.foundation.identity import SYSTEM_INTERNAL_USER_ID
|
||||
|
||||
warnings.filterwarnings("ignore", message=".*allowed_objects.*")
|
||||
|
||||
|
||||
def _finish_processing_status(status: Optional[dict], user_id: Optional[str] = None) -> None:
|
||||
"""结束入站消息的渠道处理状态。"""
|
||||
@@ -393,11 +392,6 @@ class MoviePilotAgent:
|
||||
# 流式token管理
|
||||
self.stream_handler = StreamingHandler()
|
||||
|
||||
@staticmethod
|
||||
def _current_timestamp_ms() -> int:
|
||||
"""返回当前毫秒时间戳。"""
|
||||
return int(datetime.now().timestamp() * 1000)
|
||||
|
||||
@classmethod
|
||||
def build_display_message(
|
||||
cls,
|
||||
@@ -409,22 +403,12 @@ class MoviePilotAgent:
|
||||
"""
|
||||
构造可展示的 Agent 会话消息。
|
||||
"""
|
||||
normalized_content = content or ""
|
||||
return {
|
||||
"id": f"{role}-{uuid.uuid4().hex}",
|
||||
"role": role,
|
||||
"content": normalized_content,
|
||||
"createdAt": cls._current_timestamp_ms(),
|
||||
"status": status,
|
||||
"tools": [],
|
||||
"segments": (
|
||||
[{"type": "text", "content": normalized_content}]
|
||||
if normalized_content
|
||||
else []
|
||||
),
|
||||
"attachments": attachments or [],
|
||||
"choices": [],
|
||||
}
|
||||
return build_display_message(
|
||||
role=role,
|
||||
content=content,
|
||||
attachments=attachments,
|
||||
status=status,
|
||||
)
|
||||
|
||||
def _should_save_display_history(self) -> bool:
|
||||
"""
|
||||
@@ -1560,7 +1544,9 @@ class MoviePilotAgent:
|
||||
"""
|
||||
初始化主 Agent 本地工具实例。
|
||||
"""
|
||||
return MoviePilotToolFactory.create_tools(
|
||||
from app.agent.runtime_loader import get_tool_factory
|
||||
|
||||
return get_tool_factory().create_tools(
|
||||
session_id=self.session_id,
|
||||
user_id=self.user_id,
|
||||
channel=self.channel,
|
||||
@@ -1575,14 +1561,17 @@ class MoviePilotAgent:
|
||||
self,
|
||||
) -> tuple[ToolCatalogSnapshot, ToolCatalogSnapshot]:
|
||||
"""在同一插件 revision 窗口内建立主图和子图工具目录。"""
|
||||
from app.agent.runtime_loader import get_tool_factory
|
||||
|
||||
tool_factory = get_tool_factory()
|
||||
plugin_manager = PluginManager()
|
||||
for _attempt in range(MoviePilotToolFactory.CATALOG_BUILD_MAX_ATTEMPTS):
|
||||
for _attempt in range(tool_factory.CATALOG_BUILD_MAX_ATTEMPTS):
|
||||
before_revision = plugin_manager.get_plugin_agent_tools_revision()
|
||||
tools = self._initialize_tools()
|
||||
subagent_tools = self._initialize_subagent_tools()
|
||||
after_revision = plugin_manager.get_plugin_agent_tools_revision()
|
||||
if before_revision == after_revision:
|
||||
factory_revision = MoviePilotToolFactory.catalog_factory_revision()
|
||||
factory_revision = tool_factory.catalog_factory_revision()
|
||||
return (
|
||||
ToolCatalogSnapshot.from_tools(
|
||||
tools,
|
||||
@@ -1670,12 +1659,19 @@ class MoviePilotAgent:
|
||||
(tool_catalog.signature, subagent_catalog.signature)
|
||||
if tool_catalog is not None and subagent_catalog is not None
|
||||
else (
|
||||
MoviePilotToolFactory.catalog_factory_revision(),
|
||||
self._tool_factory_revision(),
|
||||
PluginManager().get_plugin_agent_tools_revision(),
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _tool_factory_revision() -> str:
|
||||
"""在目录签名确实需要时解析工具工厂版本。"""
|
||||
from app.agent.runtime_loader import get_tool_factory
|
||||
|
||||
return get_tool_factory().catalog_factory_revision()
|
||||
|
||||
def _get_cached_agent(
|
||||
self, signature: tuple[Any, ...], streaming: bool
|
||||
) -> Optional[Any]:
|
||||
@@ -1722,7 +1718,9 @@ class MoviePilotAgent:
|
||||
"""
|
||||
初始化子代理专用静默工具列表。
|
||||
"""
|
||||
return MoviePilotToolFactory.create_tools(
|
||||
from app.agent.runtime_loader import get_tool_factory
|
||||
|
||||
return get_tool_factory().create_tools(
|
||||
session_id=self.session_id,
|
||||
user_id=self.user_id,
|
||||
channel=self.channel,
|
||||
@@ -1907,8 +1905,10 @@ class MoviePilotAgent:
|
||||
logger.debug(f"复用会话内 Agent 图: session_id={self.session_id}")
|
||||
return cached_agent
|
||||
max_tools = settings.LLM_MAX_TOOLS
|
||||
from app.agent.runtime_loader import get_tool_factory
|
||||
|
||||
always_include_tools = (
|
||||
MoviePilotToolFactory.get_tool_selector_always_include_names(tools)
|
||||
get_tool_factory().get_tool_selector_always_include_names(tools)
|
||||
)
|
||||
if subagent_task_tools:
|
||||
always_include_tools.extend(
|
||||
@@ -2438,9 +2438,16 @@ class _MessageTask:
|
||||
protected_output_callback: Optional[Callable[[str], Optional[bool]]] = None
|
||||
notification_callback: Optional[Callable[[Any], None]] = None
|
||||
agent_factory: Optional[Callable[..., MoviePilotAgent]] = None
|
||||
agent_setup: Optional[Callable[[MoviePilotAgent], None]] = None
|
||||
completion_future: Optional[asyncio.Future] = None
|
||||
|
||||
|
||||
class AgentManagerUnavailableError(RuntimeError):
|
||||
"""AgentManager 未运行或已开始关闭,不能再接收新任务。"""
|
||||
|
||||
code = "agent_manager_unavailable"
|
||||
|
||||
|
||||
class AgentManager:
|
||||
"""
|
||||
AI智能体管理器
|
||||
@@ -2458,6 +2465,9 @@ class AgentManager:
|
||||
self._idle_cleanup_task: Optional[asyncio.Task] = None
|
||||
self._idle_session_ttl = timedelta(hours=24)
|
||||
self._idle_cleanup_interval = 60 * 60
|
||||
# 接收门禁与队列写入共用一把锁,确保关闭开始后不会再创建 worker。
|
||||
self._lifecycle_lock = asyncio.Lock()
|
||||
self._accepting_tasks = False
|
||||
|
||||
def get_session_status(self, session_id: str) -> dict[str, Any]:
|
||||
"""获取会话当前模型与 token 使用状态。"""
|
||||
@@ -2504,40 +2514,51 @@ class AgentManager:
|
||||
"""
|
||||
初始化管理器
|
||||
"""
|
||||
memory_manager.initialize()
|
||||
if self._idle_cleanup_task and not self._idle_cleanup_task.done():
|
||||
return
|
||||
self._idle_cleanup_task = asyncio.create_task(self._cleanup_idle_sessions())
|
||||
async with self._lifecycle_lock:
|
||||
if self._accepting_tasks:
|
||||
return
|
||||
memory_manager.initialize()
|
||||
if not self._idle_cleanup_task or self._idle_cleanup_task.done():
|
||||
self._idle_cleanup_task = asyncio.create_task(
|
||||
self._cleanup_idle_sessions()
|
||||
)
|
||||
self._accepting_tasks = True
|
||||
|
||||
async def close(self):
|
||||
"""
|
||||
关闭管理器
|
||||
"""
|
||||
if self._idle_cleanup_task:
|
||||
self._idle_cleanup_task.cancel()
|
||||
try:
|
||||
await self._idle_cleanup_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._idle_cleanup_task = None
|
||||
await memory_manager.close()
|
||||
# 取消所有会话worker
|
||||
for task in list(self._session_workers.values()):
|
||||
task.cancel()
|
||||
# 等待所有worker结束
|
||||
for session_id, task in list(self._session_workers.items()):
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._session_workers.clear()
|
||||
for queue in list(self._session_queues.values()):
|
||||
self._discard_queued_messages(queue)
|
||||
self._session_queues.clear()
|
||||
self._session_last_used.clear()
|
||||
for agent in list(self.active_agents.values()):
|
||||
await agent.cleanup()
|
||||
self.active_agents.clear()
|
||||
async with self._lifecycle_lock:
|
||||
# 门禁必须先关闭;锁内完成清理可阻止等待中的请求在收口期间重新入队。
|
||||
self._accepting_tasks = False
|
||||
if self._idle_cleanup_task:
|
||||
self._idle_cleanup_task.cancel()
|
||||
try:
|
||||
await self._idle_cleanup_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._idle_cleanup_task = None
|
||||
# 取消所有会话worker
|
||||
for task in list(self._session_workers.values()):
|
||||
task.cancel()
|
||||
# 等待所有worker结束
|
||||
for session_id, task in list(self._session_workers.items()):
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._session_workers.clear()
|
||||
for queue in list(self._session_queues.values()):
|
||||
self._discard_queued_messages(
|
||||
queue,
|
||||
error=AgentManagerUnavailableError("AgentManager 已关闭"),
|
||||
)
|
||||
self._session_queues.clear()
|
||||
self._session_last_used.clear()
|
||||
for agent in list(self.active_agents.values()):
|
||||
await agent.cleanup()
|
||||
self.active_agents.clear()
|
||||
await memory_manager.close()
|
||||
|
||||
def _record_session_activity(self, session_id: str, user_id: str) -> None:
|
||||
"""
|
||||
@@ -2607,6 +2628,7 @@ class AgentManager:
|
||||
protected_output_callback: Optional[Callable[[str], Optional[bool]]] = None,
|
||||
notification_callback: Optional[Callable[[Any], None]] = None,
|
||||
agent_factory: Optional[Callable[..., MoviePilotAgent]] = None,
|
||||
agent_setup: Optional[Callable[[MoviePilotAgent], None]] = None,
|
||||
wait_for_completion: bool = False,
|
||||
) -> str:
|
||||
"""
|
||||
@@ -2635,38 +2657,40 @@ class AgentManager:
|
||||
protected_output_callback=protected_output_callback,
|
||||
notification_callback=notification_callback,
|
||||
agent_factory=agent_factory,
|
||||
agent_setup=agent_setup,
|
||||
completion_future=completion_future,
|
||||
)
|
||||
self._record_session_activity(session_id, user_id)
|
||||
async with self._lifecycle_lock:
|
||||
if not self._accepting_tasks:
|
||||
raise AgentManagerUnavailableError("AgentManager 未运行或已关闭")
|
||||
self._record_session_activity(session_id, user_id)
|
||||
|
||||
# 获取或创建会话队列
|
||||
if session_id not in self._session_queues:
|
||||
self._session_queues[session_id] = asyncio.Queue()
|
||||
# 获取或创建会话队列
|
||||
if session_id not in self._session_queues:
|
||||
self._session_queues[session_id] = asyncio.Queue()
|
||||
|
||||
queue = self._session_queues[session_id]
|
||||
queue_size = queue.qsize()
|
||||
queue = self._session_queues[session_id]
|
||||
queue_size = queue.qsize()
|
||||
|
||||
# 如果队列中已有等待的消息,通知用户消息已排队
|
||||
if queue_size > 0 or (
|
||||
session_id in self._session_workers
|
||||
and not self._session_workers[session_id].done()
|
||||
):
|
||||
logger.info(
|
||||
f"会话 {session_id} 有任务正在处理,消息已排队等待 "
|
||||
f"(队列中待处理: {queue_size} 条)"
|
||||
)
|
||||
# 如果队列中已有等待的消息,通知用户消息已排队
|
||||
if queue_size > 0 or (
|
||||
session_id in self._session_workers
|
||||
and not self._session_workers[session_id].done()
|
||||
):
|
||||
logger.info(
|
||||
f"会话 {session_id} 有任务正在处理,消息已排队等待 "
|
||||
f"(队列中待处理: {queue_size} 条)"
|
||||
)
|
||||
|
||||
# 放入队列
|
||||
await queue.put(task)
|
||||
|
||||
# 确保该会话有一个worker在运行
|
||||
if (
|
||||
session_id not in self._session_workers
|
||||
or self._session_workers[session_id].done()
|
||||
):
|
||||
self._session_workers[session_id] = asyncio.create_task(
|
||||
self._session_worker(session_id)
|
||||
)
|
||||
# 放入队列并创建 worker 与关闭门禁保持原子关系。
|
||||
await queue.put(task)
|
||||
if (
|
||||
session_id not in self._session_workers
|
||||
or self._session_workers[session_id].done()
|
||||
):
|
||||
self._session_workers[session_id] = asyncio.create_task(
|
||||
self._session_worker(session_id)
|
||||
)
|
||||
|
||||
if completion_future:
|
||||
return await completion_future
|
||||
@@ -2698,7 +2722,12 @@ class AgentManager:
|
||||
task.completion_future.set_result(result)
|
||||
except asyncio.CancelledError:
|
||||
if task.completion_future and not task.completion_future.done():
|
||||
task.completion_future.cancel()
|
||||
if self._accepting_tasks:
|
||||
task.completion_future.cancel()
|
||||
else:
|
||||
task.completion_future.set_exception(
|
||||
AgentManagerUnavailableError("AgentManager 已关闭")
|
||||
)
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"处理会话 {session_id} 的消息失败: {e}")
|
||||
@@ -2723,7 +2752,10 @@ class AgentManager:
|
||||
self._session_queues.pop(session_id, None)
|
||||
|
||||
@staticmethod
|
||||
def _discard_queued_messages(queue: asyncio.Queue) -> None:
|
||||
def _discard_queued_messages(
|
||||
queue: asyncio.Queue,
|
||||
error: Optional[Exception] = None,
|
||||
) -> None:
|
||||
"""丢弃会话队列时同步结束等待任务完成的调用方。"""
|
||||
while not queue.empty():
|
||||
try:
|
||||
@@ -2731,7 +2763,10 @@ class AgentManager:
|
||||
except asyncio.QueueEmpty:
|
||||
break
|
||||
if task.completion_future and not task.completion_future.done():
|
||||
task.completion_future.cancel()
|
||||
if error is None:
|
||||
task.completion_future.cancel()
|
||||
else:
|
||||
task.completion_future.set_exception(error)
|
||||
queue.task_done()
|
||||
|
||||
@staticmethod
|
||||
@@ -2810,6 +2845,9 @@ class AgentManager:
|
||||
if task.notification_callback is not None and hasattr(agent, "set_notification_callback"):
|
||||
agent.set_notification_callback(task.notification_callback)
|
||||
|
||||
if task.agent_setup is not None:
|
||||
task.agent_setup(agent)
|
||||
|
||||
process_kwargs = {
|
||||
"images": task.images,
|
||||
"files": task.files,
|
||||
@@ -2824,6 +2862,11 @@ class AgentManager:
|
||||
与 clear_session 不同,此方法不会销毁Agent实例或清除记忆,
|
||||
用户可以在停止后继续对话。
|
||||
"""
|
||||
async with self._lifecycle_lock:
|
||||
return await self._stop_current_task_locked(session_id)
|
||||
|
||||
async def _stop_current_task_locked(self, session_id: str):
|
||||
"""在 lifecycle 互斥域内停止会话 worker。"""
|
||||
stopped = False
|
||||
|
||||
worker = self._session_workers.get(session_id)
|
||||
@@ -2831,7 +2874,7 @@ class AgentManager:
|
||||
if queue and self._session_queues.get(session_id) is queue:
|
||||
self._session_queues.pop(session_id, None)
|
||||
|
||||
# 先摘下旧队列;清理期间的新消息进入新队列,但等待旧 worker 完全退出后再执行。
|
||||
# 先摘下旧队列再等待 worker 退出;lifecycle 锁保证清理期间不会并发建立新队列。
|
||||
if worker:
|
||||
worker.cancel()
|
||||
if queue:
|
||||
@@ -2869,6 +2912,11 @@ class AgentManager:
|
||||
"""
|
||||
清空会话
|
||||
"""
|
||||
async with self._lifecycle_lock:
|
||||
await self._clear_session_locked(session_id=session_id, user_id=user_id)
|
||||
|
||||
async def _clear_session_locked(self, session_id: str, user_id: str) -> None:
|
||||
"""在 lifecycle 互斥域内释放会话、Agent 与记忆。"""
|
||||
self._session_last_used.pop(session_id, None)
|
||||
# 取消该会话的worker
|
||||
if session_id in self._session_workers:
|
||||
@@ -2879,8 +2927,10 @@ class AgentManager:
|
||||
pass
|
||||
self._session_workers.pop(session_id, None) # noqa
|
||||
|
||||
# 清理队列
|
||||
self._session_queues.pop(session_id, None)
|
||||
# 清理队列时同步结束未执行请求,避免 wait_for_completion 调用方永久等待。
|
||||
queue = self._session_queues.pop(session_id, None)
|
||||
if queue:
|
||||
self._discard_queued_messages(queue)
|
||||
|
||||
# 清理agent
|
||||
if session_id in self.active_agents:
|
||||
@@ -2890,8 +2940,8 @@ class AgentManager:
|
||||
memory_manager.clear_memory(session_id, user_id)
|
||||
logger.info(f"会话 {session_id} 的记忆已清空")
|
||||
|
||||
@staticmethod
|
||||
async def run_background_prompt(
|
||||
self,
|
||||
message: str,
|
||||
session_prefix: str = "__agent_background",
|
||||
output_callback: Optional[Callable[[str], None]] = None,
|
||||
@@ -2909,22 +2959,21 @@ class AgentManager:
|
||||
elif allow_message_tools is None:
|
||||
allow_message_tools = True
|
||||
|
||||
agent = MoviePilotAgent(
|
||||
session_id=session_id,
|
||||
user_id=user_id,
|
||||
channel=None,
|
||||
source=None,
|
||||
username=settings.SUPERUSER,
|
||||
replay_mode=reply_mode,
|
||||
output_callback=output_callback,
|
||||
allow_message_tools=allow_message_tools,
|
||||
)
|
||||
|
||||
try:
|
||||
await agent.process(message)
|
||||
await self.process_message(
|
||||
session_id=session_id,
|
||||
user_id=user_id,
|
||||
message=message,
|
||||
channel=None,
|
||||
source=None,
|
||||
username=settings.SUPERUSER,
|
||||
reply_mode=reply_mode,
|
||||
output_callback=output_callback,
|
||||
allow_message_tools=allow_message_tools,
|
||||
wait_for_completion=True,
|
||||
)
|
||||
finally:
|
||||
await agent.cleanup()
|
||||
memory_manager.clear_memory(session_id, user_id)
|
||||
await self.clear_session(session_id=session_id, user_id=user_id)
|
||||
|
||||
async def execute_scheduled_task(
|
||||
self,
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
"""Agent 重量级 canonical 对象的轻量首用入口。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from typing import Any
|
||||
|
||||
from app.agent.capabilities import (
|
||||
AGENT_ENTRYPOINT_KIND,
|
||||
AGENT_MANAGER_CAPABILITY_ID,
|
||||
AGENT_SERVICE_CAPABILITY_ID,
|
||||
AGENT_SERVICE_KIND,
|
||||
MOVIEPILOT_AGENT_TYPE_CAPABILITY_ID,
|
||||
TOOL_FACTORY_CAPABILITY_ID,
|
||||
)
|
||||
from app.agent.capabilities.adapter import (
|
||||
AgentEntrypointAdapter,
|
||||
AgentServiceAdapter,
|
||||
build_agent_capability_registry,
|
||||
should_run_agent_service,
|
||||
)
|
||||
from app.runtime.capabilities.model import CapabilityMaterializationState
|
||||
from app.runtime.capabilities.runtime import CapabilityRuntime
|
||||
|
||||
|
||||
_runtime_lock = threading.RLock()
|
||||
_agent_runtime: CapabilityRuntime | None = None
|
||||
|
||||
|
||||
def _build_agent_runtime() -> CapabilityRuntime:
|
||||
"""装配 Agent Runtime;构建阶段只解析 manifests。"""
|
||||
return CapabilityRuntime(
|
||||
build_agent_capability_registry(),
|
||||
adapters={
|
||||
AGENT_ENTRYPOINT_KIND: AgentEntrypointAdapter(),
|
||||
AGENT_SERVICE_KIND: AgentServiceAdapter(),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _ensure_runtime() -> CapabilityRuntime:
|
||||
"""返回进程唯一 Runtime,同进程关闭后不重新创建。"""
|
||||
global _agent_runtime
|
||||
with _runtime_lock:
|
||||
if _agent_runtime is None:
|
||||
_agent_runtime = _build_agent_runtime()
|
||||
return _agent_runtime
|
||||
|
||||
|
||||
def _materialize_entrypoint(capability_id: str) -> Any:
|
||||
"""通过通用 Runtime 完成并发 single-flight 物化,不声明资源运行态。"""
|
||||
return _ensure_runtime().materialize(
|
||||
capability_id,
|
||||
reason="agent_entrypoint_first_use",
|
||||
)
|
||||
|
||||
|
||||
def get_agent_manager() -> Any:
|
||||
"""返回 canonical Agent Manager;关闭门禁生效后稳定拒绝首用。"""
|
||||
return _materialize_entrypoint(AGENT_MANAGER_CAPABILITY_ID)
|
||||
|
||||
|
||||
async def reconcile_agent_service(
|
||||
*,
|
||||
reason: str,
|
||||
changed_keys: set[str] | None = None,
|
||||
retry: bool = False,
|
||||
) -> Any | None:
|
||||
"""按 manifest watch/selector 协调唯一 Agent Service 生命周期。"""
|
||||
runtime = _ensure_runtime()
|
||||
spec = runtime.get_spec(AGENT_SERVICE_CAPABILITY_ID)
|
||||
if spec is None:
|
||||
raise RuntimeError("缺少 agent.service capability")
|
||||
if changed_keys is not None and not changed_keys.intersection(spec.watch):
|
||||
return runtime.get_running(AGENT_SERVICE_CAPABILITY_ID)
|
||||
if not should_run_agent_service(spec):
|
||||
# stop_async 会等待并发首启后再撤销实例;未物化能力则保持零导入。
|
||||
await runtime.stop_async(
|
||||
AGENT_SERVICE_CAPABILITY_ID,
|
||||
reason=reason,
|
||||
)
|
||||
return None
|
||||
return await runtime.activate_async(
|
||||
AGENT_SERVICE_CAPABILITY_ID,
|
||||
reason=reason,
|
||||
retry=retry,
|
||||
)
|
||||
|
||||
|
||||
async def activate_agent_service(*, retry: bool = False) -> Any | None:
|
||||
"""执行启动期协调;selector 未启用时保持 service 未物化。"""
|
||||
return await reconcile_agent_service(
|
||||
reason="agent_service_startup_reconcile",
|
||||
retry=retry,
|
||||
)
|
||||
|
||||
|
||||
def get_running_agent_manager() -> Any | None:
|
||||
"""只读返回 RUNNING Agent Service;未构建 Runtime 时不触发声明发现。"""
|
||||
with _runtime_lock:
|
||||
runtime = _agent_runtime
|
||||
if runtime is None:
|
||||
return None
|
||||
return runtime.get_running(AGENT_SERVICE_CAPABILITY_ID)
|
||||
|
||||
|
||||
def get_moviepilot_agent_type() -> type:
|
||||
"""返回 canonical MoviePilotAgent 类型。"""
|
||||
agent_type = _materialize_entrypoint(MOVIEPILOT_AGENT_TYPE_CAPABILITY_ID)
|
||||
if not isinstance(agent_type, type):
|
||||
raise TypeError("MoviePilot Agent entrypoint 必须是类型")
|
||||
return agent_type
|
||||
|
||||
|
||||
def get_tool_factory() -> type:
|
||||
"""返回 canonical 工具工厂类型。"""
|
||||
factory_type = _materialize_entrypoint(TOOL_FACTORY_CAPABILITY_ID)
|
||||
if not isinstance(factory_type, type):
|
||||
raise TypeError("Agent Tool Factory entrypoint 必须是类型")
|
||||
return factory_type
|
||||
|
||||
|
||||
def is_tool_factory_materialized() -> bool:
|
||||
"""只读判断工具工厂是否已解析;未建 Runtime 时不触发发现或导入。"""
|
||||
with _runtime_lock:
|
||||
runtime = _agent_runtime
|
||||
if runtime is None:
|
||||
return False
|
||||
return (
|
||||
runtime.snapshot(TOOL_FACTORY_CAPABILITY_ID).materialization
|
||||
is CapabilityMaterializationState.RESOLVED
|
||||
)
|
||||
|
||||
|
||||
async def begin_agent_shutdown() -> None:
|
||||
"""不可逆关闭首用闸门,并等待全部同步及异步能力释放。"""
|
||||
await _ensure_runtime().shutdown_async(reason="application_shutdown")
|
||||
+60
-4
@@ -5,12 +5,11 @@ from abc import ABCMeta, abstractmethod
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from functools import partial
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, ClassVar, Optional
|
||||
from typing import TYPE_CHECKING, Any, Callable, ClassVar, Optional, Protocol
|
||||
|
||||
from langchain_core.tools import BaseTool
|
||||
from pydantic import PrivateAttr
|
||||
|
||||
from app.agent.callback import StreamingHandler
|
||||
from app.agent.policy.sanitizer import (
|
||||
summarize_error,
|
||||
summarize_input,
|
||||
@@ -25,6 +24,54 @@ from app.runtime.log import logger
|
||||
from app.schemas import Notification
|
||||
from app.schemas.types import MessageChannel, NotificationType
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.agent.callback import StreamingHandler as _StreamingHandlerProtocol
|
||||
else:
|
||||
class _StreamingHandlerProtocol(Protocol):
|
||||
"""工具执行仅依赖的流式缓冲合同。"""
|
||||
|
||||
@property
|
||||
def is_streaming(self) -> bool:
|
||||
"""是否正在收集流式输出。"""
|
||||
...
|
||||
|
||||
@property
|
||||
def is_auto_flushing(self) -> bool:
|
||||
"""是否由渠道编辑能力自动刷新缓冲。"""
|
||||
...
|
||||
|
||||
@property
|
||||
def last_buffer_char(self) -> str:
|
||||
"""返回缓冲区最后一个字符。"""
|
||||
...
|
||||
|
||||
def emit(self, token: str) -> str:
|
||||
"""追加流式文本并返回实际追加内容。"""
|
||||
...
|
||||
|
||||
async def take(self) -> str:
|
||||
"""取出并清空当前缓冲内容。"""
|
||||
...
|
||||
|
||||
def record_tool_call(
|
||||
self,
|
||||
tool_name: str,
|
||||
tool_message: Optional[str] = None,
|
||||
tool_kwargs: Optional[dict[str, Any]] = None,
|
||||
) -> None:
|
||||
"""记录一次待汇总的工具调用。"""
|
||||
...
|
||||
|
||||
|
||||
|
||||
def __getattr__(name: str) -> Any:
|
||||
"""显式访问历史 StreamingHandler 符号时返回 canonical 实现。"""
|
||||
if name == "StreamingHandler":
|
||||
from app.agent.callback import StreamingHandler
|
||||
|
||||
return StreamingHandler
|
||||
raise AttributeError(f"module 'app.agent.tools.base' has no attribute {name!r}")
|
||||
|
||||
|
||||
class ToolChain(ChainBase):
|
||||
pass
|
||||
@@ -206,7 +253,7 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
|
||||
_channel: Optional[str] = PrivateAttr(default=None)
|
||||
_source: Optional[str] = PrivateAttr(default=None)
|
||||
_username: Optional[str] = PrivateAttr(default=None)
|
||||
_stream_handler: Optional[StreamingHandler] = PrivateAttr(default=None)
|
||||
_stream_handler: Optional[_StreamingHandlerProtocol] = PrivateAttr(default=None)
|
||||
_require_admin: bool = PrivateAttr(default=False)
|
||||
_agent_context: dict = PrivateAttr(default_factory=dict)
|
||||
|
||||
@@ -387,7 +434,9 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
|
||||
self._source = source
|
||||
self._username = username
|
||||
|
||||
def set_stream_handler(self, stream_handler: StreamingHandler):
|
||||
def set_stream_handler(
|
||||
self, stream_handler: Optional[_StreamingHandlerProtocol]
|
||||
) -> None:
|
||||
"""
|
||||
设置回调处理器
|
||||
"""
|
||||
@@ -642,3 +691,10 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
|
||||
save_history=False,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# 普通导入保持 callback 冷态;显式导入或历史星号导入仍解析真实类。
|
||||
__all__ = sorted(
|
||||
{name for name in globals() if not name.startswith("_")}
|
||||
| {"StreamingHandler"}
|
||||
)
|
||||
|
||||
+135
-65
@@ -1,24 +1,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import threading
|
||||
import uuid
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import TYPE_CHECKING, Any, Dict, List, Optional
|
||||
|
||||
from app.agent.policy import (
|
||||
DEFAULT_TOOL_POLICY_ORCHESTRATOR,
|
||||
AgentToolPolicyOrchestrator,
|
||||
AuthSource,
|
||||
PrincipalType,
|
||||
ToolOrigin,
|
||||
ToolPolicyContext,
|
||||
call_policy_hook,
|
||||
summarize_error,
|
||||
)
|
||||
from app.agent.tools.base import ToolExecutionTimeoutError, format_tool_result_for_agent
|
||||
from app.agent.tools.factory import MoviePilotToolFactory
|
||||
from app.agent.tools.catalog import ToolCatalogSnapshot
|
||||
from app.runtime.extensions.plugin_manager import PluginManager
|
||||
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:
|
||||
"""
|
||||
@@ -53,31 +45,33 @@ class MoviePilotToolsManager:
|
||||
self.user_id = user_id
|
||||
self.session_id = session_id
|
||||
self.is_admin = is_admin
|
||||
self.policy_orchestrator = (
|
||||
policy_orchestrator or DEFAULT_TOOL_POLICY_ORCHESTRATOR
|
||||
)
|
||||
self._policy_context = ToolPolicyContext(
|
||||
session_id=session_id,
|
||||
user_id=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": 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._load_tools()
|
||||
self._catalog_materialized = False
|
||||
self._catalog_managed_by_factory = False
|
||||
|
||||
def _load_tools(self) -> None:
|
||||
@staticmethod
|
||||
def _summarize_error(error: Exception) -> str:
|
||||
"""仅在错误路径加载策略脱敏器,保持默认导入轻量。"""
|
||||
from app.agent.policy import summarize_error
|
||||
|
||||
return summarize_error(error)
|
||||
|
||||
def _load_tools_locked(self) -> None:
|
||||
"""
|
||||
加载所有MoviePilot工具
|
||||
在 manager 锁内加载所有 MoviePilot 工具。
|
||||
|
||||
工厂负责插件 revision 前后稳定窗口;manager 只发布完整快照,避免
|
||||
并发调用观察到一半刷新后的工具列表。
|
||||
"""
|
||||
from app.agent.runtime_loader import get_tool_factory
|
||||
|
||||
try:
|
||||
catalog = MoviePilotToolFactory.create_catalog(
|
||||
catalog = get_tool_factory().create_catalog(
|
||||
session_id=self.session_id,
|
||||
user_id=self.user_id,
|
||||
channel=None,
|
||||
@@ -89,17 +83,43 @@ class MoviePilotToolsManager:
|
||||
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"加载工具失败: {summarize_error(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
|
||||
@@ -112,7 +132,41 @@ class MoviePilotToolsManager:
|
||||
== plugin_manager.get_plugin_agent_tools_revision()
|
||||
):
|
||||
return
|
||||
self._load_tools()
|
||||
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]:
|
||||
"""
|
||||
@@ -122,8 +176,10 @@ class MoviePilotToolsManager:
|
||||
工具定义列表
|
||||
"""
|
||||
self._ensure_tools_current()
|
||||
with self._tools_lock:
|
||||
tools = list(self.tools)
|
||||
tools_list = []
|
||||
for tool in self.tools:
|
||||
for tool in tools:
|
||||
if getattr(tool, "_require_admin", False) and not self.is_admin:
|
||||
continue
|
||||
# 获取工具的输入参数模型
|
||||
@@ -156,26 +212,31 @@ class MoviePilotToolsManager:
|
||||
工具实例,如果未找到返回None
|
||||
"""
|
||||
self._ensure_tools_current()
|
||||
return next(
|
||||
(tool for tool in self.tools if tool.name == tool_name),
|
||||
None,
|
||||
)
|
||||
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()
|
||||
if self.catalog is None or [
|
||||
id(tool) for tool in self.catalog.tools
|
||||
] != [id(tool) for tool in self.tools]:
|
||||
self.catalog = ToolCatalogSnapshot.from_tools(
|
||||
self.tools,
|
||||
plugin_revision=self._plugin_agent_tools_revision,
|
||||
factory_revision=MoviePilotToolFactory.catalog_factory_revision(),
|
||||
)
|
||||
if self.catalog is None:
|
||||
return None
|
||||
entry = self.catalog.resolve_unique(tool_name)
|
||||
return entry.tool if entry else None
|
||||
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]:
|
||||
@@ -265,7 +326,7 @@ class MoviePilotToolsManager:
|
||||
schema = args_schema.model_json_schema()
|
||||
properties = schema.get("properties", {})
|
||||
except Exception as e:
|
||||
logger.warning(f"获取工具schema失败: {summarize_error(e)}")
|
||||
logger.warning(f"获取工具schema失败: {MoviePilotToolsManager._summarize_error(e)}")
|
||||
return arguments
|
||||
|
||||
# 规范化参数
|
||||
@@ -320,7 +381,14 @@ class MoviePilotToolsManager:
|
||||
)
|
||||
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:
|
||||
@@ -328,11 +396,12 @@ class MoviePilotToolsManager:
|
||||
|
||||
# 规范化参数类型
|
||||
normalized_arguments = self._normalize_arguments(tool_instance, arguments)
|
||||
self._policy_context.agent_context["is_admin"] = self.is_admin
|
||||
policy_orchestrator, policy_context = self._ensure_policy_runtime()
|
||||
policy_context.agent_context["is_admin"] = self.is_admin
|
||||
observation = call_policy_hook(
|
||||
"start",
|
||||
self.policy_orchestrator.start,
|
||||
context=self._policy_context,
|
||||
policy_orchestrator.start,
|
||||
context=policy_context,
|
||||
tool=tool_instance,
|
||||
arguments=normalized_arguments,
|
||||
)
|
||||
@@ -346,28 +415,29 @@ class MoviePilotToolsManager:
|
||||
max_chars=getattr(tool_instance, "result_max_chars", None),
|
||||
)
|
||||
except ToolExecutionTimeoutError as e:
|
||||
if observation:
|
||||
call_policy_hook("fail", self.policy_orchestrator.fail, observation, e)
|
||||
logger.warning(summarize_error(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(
|
||||
summarize_error(e),
|
||||
error_summary,
|
||||
tool_name=tool_name,
|
||||
max_chars=getattr(tool_instance, "result_max_chars", None),
|
||||
)
|
||||
except Exception as e:
|
||||
if observation:
|
||||
call_policy_hook("fail", self.policy_orchestrator.fail, observation, e)
|
||||
error_summary = summarize_error(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:
|
||||
if observation is not None and policy_orchestrator is not None:
|
||||
call_policy_hook(
|
||||
"finish",
|
||||
self.policy_orchestrator.finish,
|
||||
policy_orchestrator.finish,
|
||||
observation,
|
||||
str_result,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user