From 5b367011c5b938ab4dbd7563f652f06a1c94716a Mon Sep 17 00:00:00 2001 From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com> Date: Sun, 16 Aug 2026 19:25:24 +0800 Subject: [PATCH] =?UTF-8?q?refactor(agent):=20=E6=8C=89=E9=9C=80=E5=8A=A0?= =?UTF-8?q?=E8=BD=BD=20Agent=20=E8=BF=90=E8=A1=8C=E6=97=B6=20(#6336)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/agent/capabilities/__init__.py | 8 + app/agent/capabilities/adapter.py | 218 ++++++ .../capabilities/manager/capability.toml | 12 + .../moviepilot_type/capability.toml | 12 + .../capabilities/service/capability.toml | 16 + .../capabilities/tool_factory/capability.toml | 12 + app/agent/contracts.py | 35 + app/agent/llm/__init__.py | 68 +- app/agent/orchestrator.py | 265 ++++--- app/agent/runtime_loader.py | 137 ++++ app/agent/tools/base.py | 64 +- app/agent/tools/manager.py | 200 ++++-- app/api/endpoints/agent.py | 109 ++- app/api/endpoints/anthropic.py | 80 ++- app/api/endpoints/history.py | 16 +- app/api/endpoints/llm.py | 14 +- app/api/endpoints/openai.py | 279 +++++++- app/application/agent.py | 121 ++-- app/application/transfer.py | 9 +- app/chain/_transfer.py | 7 +- app/chain/message.py | 84 ++- app/chain/search.py | 8 +- app/scheduler.py | 16 +- app/schemas/agent.py | 9 +- app/schemas/types.py | 7 + app/startup/agent_initializer.py | 181 ++++- docs/rules/05-architecture.md | 5 +- scripts/perf/README.md | 56 ++ scripts/perf/instrument/sitecustomize.py | 260 ++++++- scripts/perf/moviepilot_docker_ab.py | 381 +++++++++- scripts/perf/test_scenarios.py | 444 +++++++++++- tests/test_agent_api_lazy_imports.py | 460 ++++++++++++ tests/test_agent_background_output.py | 35 +- tests/test_agent_cancellation.py | 4 + tests/test_agent_doctor_tool.py | 17 +- tests/test_agent_graph_cache.py | 2 +- tests/test_agent_image_capability.py | 21 +- tests/test_agent_image_support.py | 36 +- tests/test_agent_interaction.py | 28 +- tests/test_agent_lazy_initializer.py | 245 +++++++ tests/test_agent_lazy_runtime_boundary.py | 322 +++++++++ tests/test_agent_lifecycle.py | 177 +++++ tests/test_agent_message_routing.py | 57 +- tests/test_agent_protocol_lifecycle.py | 216 ++++++ tests/test_agent_recognize_captcha_tool.py | 17 +- tests/test_agent_resource_flow_permissions.py | 24 +- tests/test_agent_runtime_loader.py | 674 ++++++++++++++++++ tests/test_agent_scheduled_tasks.py | 23 +- tests/test_agent_session_status.py | 19 +- tests/test_agent_system_settings_tools.py | 12 +- tests/test_agent_tool_streaming.py | 4 +- tests/test_architecture_dependencies.py | 10 + tests/test_search_ai_recommend.py | 8 + tests/test_telegram_typing_lifecycle.py | 22 +- tests/test_transfer_failed_retry_buttons.py | 44 +- tests/test_web_agent_stream.py | 77 +- 56 files changed, 5059 insertions(+), 628 deletions(-) create mode 100644 app/agent/capabilities/__init__.py create mode 100644 app/agent/capabilities/adapter.py create mode 100644 app/agent/capabilities/manager/capability.toml create mode 100644 app/agent/capabilities/moviepilot_type/capability.toml create mode 100644 app/agent/capabilities/service/capability.toml create mode 100644 app/agent/capabilities/tool_factory/capability.toml create mode 100644 app/agent/contracts.py create mode 100644 app/agent/runtime_loader.py create mode 100644 tests/test_agent_api_lazy_imports.py create mode 100644 tests/test_agent_lazy_initializer.py create mode 100644 tests/test_agent_lazy_runtime_boundary.py create mode 100644 tests/test_agent_protocol_lifecycle.py create mode 100644 tests/test_agent_runtime_loader.py diff --git a/app/agent/capabilities/__init__.py b/app/agent/capabilities/__init__.py new file mode 100644 index 000000000..fe4aa18e2 --- /dev/null +++ b/app/agent/capabilities/__init__.py @@ -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" diff --git a/app/agent/capabilities/adapter.py b/app/agent/capabilities/adapter.py new file mode 100644 index 000000000..d6b160394 --- /dev/null +++ b/app/agent/capabilities/adapter.py @@ -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"])) diff --git a/app/agent/capabilities/manager/capability.toml b/app/agent/capabilities/manager/capability.toml new file mode 100644 index 000000000..fe26d9e38 --- /dev/null +++ b/app/agent/capabilities/manager/capability.toml @@ -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 = [] diff --git a/app/agent/capabilities/moviepilot_type/capability.toml b/app/agent/capabilities/moviepilot_type/capability.toml new file mode 100644 index 000000000..663dbc87a --- /dev/null +++ b/app/agent/capabilities/moviepilot_type/capability.toml @@ -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 = [] diff --git a/app/agent/capabilities/service/capability.toml b/app/agent/capabilities/service/capability.toml new file mode 100644 index 000000000..99b16c3c9 --- /dev/null +++ b/app/agent/capabilities/service/capability.toml @@ -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" diff --git a/app/agent/capabilities/tool_factory/capability.toml b/app/agent/capabilities/tool_factory/capability.toml new file mode 100644 index 000000000..7982e7429 --- /dev/null +++ b/app/agent/capabilities/tool_factory/capability.toml @@ -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 = [] diff --git a/app/agent/contracts.py b/app/agent/contracts.py new file mode 100644 index 000000000..242465691 --- /dev/null +++ b/app/agent/contracts.py @@ -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"] diff --git a/app/agent/llm/__init__.py b/app/agent/llm/__init__.py index 488aba3e1..32c2be776 100644 --- a/app/agent/llm/__init__.py +++ b/app/agent/llm/__init__.py @@ -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", diff --git a/app/agent/orchestrator.py b/app/agent/orchestrator.py index 8bf21d07a..f66aa2fcb 100644 --- a/app/agent/orchestrator.py +++ b/app/agent/orchestrator.py @@ -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, diff --git a/app/agent/runtime_loader.py b/app/agent/runtime_loader.py new file mode 100644 index 000000000..1006970d8 --- /dev/null +++ b/app/agent/runtime_loader.py @@ -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") diff --git a/app/agent/tools/base.py b/app/agent/tools/base.py index 8769b7f51..7b3230f19 100644 --- a/app/agent/tools/base.py +++ b/app/agent/tools/base.py @@ -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"} +) diff --git a/app/agent/tools/manager.py b/app/agent/tools/manager.py index 5e3420884..8be289458 100644 --- a/app/agent/tools/manager.py +++ b/app/agent/tools/manager.py @@ -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, ) diff --git a/app/api/endpoints/agent.py b/app/api/endpoints/agent.py index fb53ecd2b..d77504d93 100644 --- a/app/api/endpoints/agent.py +++ b/app/api/endpoints/agent.py @@ -20,10 +20,13 @@ from sqlalchemy.ext.asyncio import AsyncSession from app import schemas from app.api.response import ResponseAPIRouter -from app.agent.callback import StreamingHandler -from app.agent.orchestrator import MoviePilotAgent, ReplyMode, agent_manager +from app.agent.contracts import ReplyMode, build_display_message from app.agent.llm.capability import AgentCapabilityManager from app.agent.mcp import agent_mcp_manager +from app.agent.runtime_loader import ( + get_moviepilot_agent_type, + get_running_agent_manager, +) from app.chain.message import MessageChain from app.command import Command from app.runtime.config import global_vars, settings @@ -254,7 +257,7 @@ async def test_agent_mcp_server( ) -class _WebAgentStreamingHandler(StreamingHandler): +class _WebAgentStreamingHandlerMixin: """ Web 前端专用流式处理器,将工具提示和文本统一回调给 SSE。 """ @@ -342,7 +345,28 @@ class _WebAgentStreamingHandler(StreamingHandler): return True -class _WebAgentMoviePilotAgent(MoviePilotAgent): +def _get_web_agent_streaming_handler_type() -> type: + """首次构造 Web Agent 时才解析完整流式处理器实现。""" + global _WEB_AGENT_STREAMING_HANDLER_TYPE + if _WEB_AGENT_STREAMING_HANDLER_TYPE is not None: + return _WEB_AGENT_STREAMING_HANDLER_TYPE + with _WEB_AGENT_STREAMING_HANDLER_TYPE_LOCK: + if _WEB_AGENT_STREAMING_HANDLER_TYPE is None: + from app.agent.callback import StreamingHandler + + _WEB_AGENT_STREAMING_HANDLER_TYPE = type( + "_RuntimeWebAgentStreamingHandler", + (_WebAgentStreamingHandlerMixin, StreamingHandler), + {"__module__": __name__}, + ) + return _WEB_AGENT_STREAMING_HANDLER_TYPE + + +_WEB_AGENT_STREAMING_HANDLER_TYPE_LOCK = Lock() +_WEB_AGENT_STREAMING_HANDLER_TYPE: Optional[type] = None + + +class _WebAgentMoviePilotAgentMixin: """ Web 前端专用 Agent,强制使用流式推理。 """ @@ -355,7 +379,9 @@ class _WebAgentMoviePilotAgent(MoviePilotAgent): ) -> None: super().__init__(*args, **kwargs) self._notification_callback = notification_callback - self.stream_handler = _WebAgentStreamingHandler(self._emit_output) + self.stream_handler = _get_web_agent_streaming_handler_type()( + self._emit_output + ) def _should_stream(self) -> bool: """Web 对话实时输出,复用会话执行后台任务时改用非流式广播。""" @@ -381,7 +407,9 @@ class _WebAgentMoviePilotAgent(MoviePilotAgent): :param output_callback: 当前请求的输出回调 """ self.output_callback = output_callback - if output_callback and isinstance(self.stream_handler, _WebAgentStreamingHandler): + if output_callback and isinstance( + self.stream_handler, _WebAgentStreamingHandlerMixin + ): self.stream_handler.set_emit_callback(self._emit_output) async def _is_system_admin_context(self) -> bool: @@ -420,6 +448,30 @@ class _WebAgentMoviePilotAgent(MoviePilotAgent): logger.debug(f"Web智能体输出回调失败: {e}") +def _build_web_agent_type(agent_base_type: type) -> type: + """为 Web 通道组合唯一的运行时 Agent 类型。""" + return type( + "_RuntimeWebAgentMoviePilotAgent", + (_WebAgentMoviePilotAgentMixin, agent_base_type), + {"__module__": __name__}, + ) + + +_WEB_AGENT_TYPE_LOCK = Lock() +_WEB_AGENT_TYPE: Optional[type] = None + + +def _get_web_agent_type() -> type: + """在真实 Web Agent 调用边界 single-flight 解析运行时类型。""" + global _WEB_AGENT_TYPE + if _WEB_AGENT_TYPE is not None: + return _WEB_AGENT_TYPE + with _WEB_AGENT_TYPE_LOCK: + if _WEB_AGENT_TYPE is None: + _WEB_AGENT_TYPE = _build_web_agent_type(get_moviepilot_agent_type()) + return _WEB_AGENT_TYPE + + def _build_web_agent_session_id(user: User, session_id: Optional[str]) -> str: """ 构建前端 Agent 会话 ID。 @@ -1131,7 +1183,7 @@ def _build_web_agent_display_message_from_events( :param events: 已转换的 WebAgent SSE 事件列表 :return: 可持久化的助手展示消息 """ - message = MoviePilotAgent.build_display_message( + message = build_display_message( role="assistant", status="streaming", ) @@ -1725,7 +1777,8 @@ async def get_agent_chat_session( if server_session_id != session_id: chat = await _get_accessible_agent_chat(oper, server_session_id, current_user) if not chat: - if agent_manager.is_session_busy(server_session_id): + manager = get_running_agent_manager() + if manager and manager.is_session_busy(server_session_id): return schemas.Response( success=True, data={ @@ -1737,7 +1790,10 @@ async def get_agent_chat_session( ) return schemas.Response(success=False, message="会话不存在或无权访问") data = AgentChatOper.to_detail(chat) - data["is_processing"] = agent_manager.is_session_busy(chat.session_id) + manager = get_running_agent_manager() + data["is_processing"] = bool( + manager and manager.is_session_busy(chat.session_id) + ) return schemas.Response(success=True, data=data) @@ -1836,7 +1892,8 @@ async def stop_web_agent_session_task( if chat and not _can_access_agent_chat(chat, current_user): return schemas.Response(success=False, message="会话不存在或无权访问") - stopped = await agent_manager.stop_current_task(server_session_id) + manager = get_running_agent_manager() + stopped = await manager.stop_current_task(server_session_id) if manager else False return schemas.Response( success=True, data={"stopped": stopped}, @@ -1881,7 +1938,8 @@ async def web_agent_stream( ) is_secret_confirmation_control = ( is_secret_confirmation_candidate - and agent_manager.matches_secret_confirmation( + and (manager := get_running_agent_manager()) is not None + and manager.matches_secret_confirmation( session_id, str(current_user.id), channel=MessageChannel.WebAgent.value, @@ -1943,7 +2001,7 @@ async def web_agent_stream( display_messages = [] if payload.echo_user: display_messages.append( - MoviePilotAgent.build_display_message( + build_display_message( role="user", content=display_prompt or prompt, attachments=user_attachments, @@ -2038,6 +2096,19 @@ async def web_agent_stream( media_type="text/event-stream", ) + manager = get_running_agent_manager() + if manager is None: + return StreamingResponse( + iter([ + _build_web_agent_sse( + "error", + {"message": "智能助手服务尚未就绪,请稍后重试。"}, + locale=locale, + ) + ]), + media_type="text/event-stream", + ) + transcript = _transcribe_web_agent_audio_refs(payload.audio_refs or []) prompt = _merge_web_agent_prompt_with_transcript(prompt, transcript) display_prompt = _merge_web_agent_prompt_with_transcript(display_prompt, transcript) @@ -2077,7 +2148,7 @@ async def web_agent_stream( ) display_messages = [] if payload.echo_user and not is_secret_confirmation_control: - user_display_message = MoviePilotAgent.build_display_message( + user_display_message = build_display_message( role="user", content=display_prompt or prompt, attachments=user_attachments, @@ -2085,7 +2156,7 @@ async def web_agent_stream( if payload.choice_selection: user_display_message["choice_selection"] = payload.choice_selection display_messages.append(user_display_message) - assistant_display_message = MoviePilotAgent.build_display_message( + assistant_display_message = build_display_message( role="assistant", status="streaming", ) @@ -2132,7 +2203,10 @@ async def web_agent_stream( async def run_agent() -> None: """后台执行 Agent,并将结果写入事件队列。""" try: - await agent_manager.process_message( + runtime_manager = get_running_agent_manager() + if runtime_manager is None: + raise RuntimeError("智能助手服务尚未就绪,请稍后重试。") + await runtime_manager.process_message( session_id=session_id, user_id=str(current_user.id), message=prompt, @@ -2151,9 +2225,12 @@ async def web_agent_stream( else None ), notification_callback=notification_callback, - agent_factory=_WebAgentMoviePilotAgent, + agent_factory=_get_web_agent_type(), wait_for_completion=True, ) + except asyncio.CancelledError: + # 显式停止会话沿用正常终止语义;服务关闭会由 manager 的稳定异常分支处理。 + pass except Exception as err: logger.error(f"Web智能助手执行失败: {str(err)}") error_event = { diff --git a/app/api/endpoints/anthropic.py b/app/api/endpoints/anthropic.py index 2a9d619a0..f18cbaf97 100644 --- a/app/api/endpoints/anthropic.py +++ b/app/api/endpoints/anthropic.py @@ -9,16 +9,17 @@ from fastapi.responses import JSONResponse, StreamingResponse from app import schemas from app.api.endpoints.openai import ( MODEL_ID, - _CollectingMoviePilotAgent, + _is_manager_unavailable, + _run_managed_agent, ) from app.api.openai_utils import ( build_anthropic_messages, build_prompt, build_session_id, ) +from app.agent.runtime_loader import get_running_agent_manager from app.runtime.config import settings from app.application.security.access import anthropic_api_key_header -from app.schemas.types import MessageChannel ANTHROPIC_ERROR_RESPONSES = { 400: {"model": schemas.AnthropicErrorResponse, "description": "请求格式错误"}, @@ -60,19 +61,31 @@ def _check_auth(api_key: Optional[str]) -> Optional[JSONResponse]: async def _stream_anthropic_response( - agent: _CollectingMoviePilotAgent, + manager, + session_id: str, + user_id: str, prompt: str, images: List[str], ) -> AsyncIterator[str]: event_queue: asyncio.Queue = asyncio.Queue() - if hasattr(agent.stream_handler, "bind_queue"): - agent.stream_handler.bind_queue(event_queue) message_id = f"msg_{uuid.uuid4().hex}" async def _run_agent(): try: - await agent.process(prompt, images=images, files=None) + await _run_managed_agent( + manager=manager, + session_id=session_id, + user_id=user_id, + username="anthropic-client", + source="anthropic", + prompt=prompt, + images=images, + stream_mode=True, + event_queue=event_queue, + ) + except asyncio.CancelledError: + await event_queue.put({"error": "MoviePilot AI agent is unavailable."}) except Exception as exc: await event_queue.put({"error": str(exc)}) finally: @@ -87,7 +100,12 @@ async def _stream_anthropic_response( if item is None: break if isinstance(item, dict) and item.get("error"): - raise RuntimeError(str(item["error"])) + yield ( + "event: error\n" + f"data: {json.dumps({'type': 'error', 'error': {'type': 'api_error', 'message': str(item['error'])}}, ensure_ascii=False)}\n\n" + ) + yield f"event: message_stop\ndata: {json.dumps({'type': 'message_stop'}, ensure_ascii=False)}\n\n" + return text = str(item or "") if not text: continue @@ -96,6 +114,7 @@ async def _stream_anthropic_response( yield f"event: message_delta\ndata: {json.dumps({'type': 'message_delta', 'delta': {'stop_reason': 'end_turn', 'stop_sequence': None}, 'usage': {'output_tokens': 0}}, ensure_ascii=False)}\n\n" yield f"event: message_stop\ndata: {json.dumps({'type': 'message_stop'}, ensure_ascii=False)}\n\n" finally: + await manager.clear_session(session_id=session_id, user_id=user_id) if not task.done(): task.cancel() try: @@ -132,6 +151,13 @@ async def messages( 503, error_type="api_error", ) + manager = get_running_agent_manager() + if manager is None: + return _anthropic_error_response( + "MoviePilot AI agent is unavailable.", + 503, + error_type="api_error", + ) normalized_messages = build_anthropic_messages(payload.system, payload.messages) try: @@ -141,19 +167,15 @@ async def messages( session_seed = anthropic_version or "anthropic" session_id = build_session_id(f"{session_seed}:{uuid.uuid4().hex}", SESSION_PREFIX) - # 兼容接口的 API_TOKEN 客户端按管理员级 MoviePilot Agent 集成处理。 - agent = _CollectingMoviePilotAgent( - session_id=session_id, - user_id=session_id, - channel=MessageChannel.Web.value, - source="anthropic", - username="anthropic-client", - stream_mode=payload.stream, - ) - if payload.stream: return StreamingResponse( - _stream_anthropic_response(agent=agent, prompt=prompt, images=images), + _stream_anthropic_response( + manager=manager, + session_id=session_id, + user_id=session_id, + prompt=prompt, + images=images, + ), media_type="text/event-stream", headers={ "Cache-Control": "no-cache", @@ -162,14 +184,32 @@ async def messages( }, ) + collected_messages = [] try: - result = await agent.process(prompt, images=images, files=None) + result, collected_messages = await _run_managed_agent( + manager=manager, + session_id=session_id, + user_id=session_id, + username="anthropic-client", + source="anthropic", + prompt=prompt, + images=images, + stream_mode=False, + ) except Exception as exc: + if _is_manager_unavailable(exc): + return _anthropic_error_response( + "MoviePilot AI agent is unavailable.", + 503, + error_type="api_error", + ) return _anthropic_error_response(str(exc), 500, error_type="api_error") + finally: + await manager.clear_session(session_id=session_id, user_id=session_id) content = "\n\n".join( message.strip() - for message in agent.collected_messages + for message in collected_messages if message and message.strip() ).strip() if not content and result: diff --git a/app/api/endpoints/history.py b/app/api/endpoints/history.py index b6f1f5560..d14cdb13d 100644 --- a/app/api/endpoints/history.py +++ b/app/api/endpoints/history.py @@ -9,7 +9,8 @@ from sqlalchemy.orm import Session from app import schemas from app.api.response import ResponseAPIRouter -from app.agent.orchestrator import ReplyMode, agent_manager +from app.agent.contracts import ReplyMode +from app.agent.runtime_loader import get_running_agent_manager from app.agent.prompt.transfer_redo import ( build_batch_manual_redo_prompt, build_manual_redo_prompt, @@ -31,6 +32,7 @@ from app.runtime.progress import ProgressHelper from app.application.history import clear_transfer_failures from app.schemas.types import EventType from app.foundation.text import cut as jieba_cut +from app.runtime.log import logger router = ResponseAPIRouter() @@ -58,7 +60,11 @@ def _start_ai_redo_task(history_id: int, prompt: str, progress_key: str): async def runner(): try: - await agent_manager.run_background_prompt( + manager = get_running_agent_manager() + if manager is None: + logger.warning("智能助手服务未运行,跳过单条整理历史 AI 重做") + raise RuntimeError("智能助手服务未运行") + await manager.run_background_prompt( message=prompt, session_prefix=f"__agent_manual_redo_{history_id}", output_callback=update_output, @@ -103,7 +109,11 @@ def _start_batch_ai_redo_task( async def runner(): try: - await agent_manager.run_background_prompt( + manager = get_running_agent_manager() + if manager is None: + logger.warning("智能助手服务未运行,跳过批量整理历史 AI 重做") + raise RuntimeError("智能助手服务未运行") + await manager.run_background_prompt( message=prompt, session_prefix="__agent_manual_redo_batch", output_callback=update_output, diff --git a/app/api/endpoints/llm.py b/app/api/endpoints/llm.py index 33eeb0057..711332e2d 100644 --- a/app/api/endpoints/llm.py +++ b/app/api/endpoints/llm.py @@ -5,13 +5,19 @@ from fastapi.responses import HTMLResponse from app import schemas from app.api.response import ResponseAPIRouter -from app.agent.llm import LLMProviderManager, render_auth_result_html from app.db.models import User from app.api.deps import get_current_active_superuser_async router = ResponseAPIRouter() +def _get_llm_provider_manager_type() -> type: + """在真实管理请求边界解析 provider 运行时。""" + from app.agent.llm.provider import LLMProviderManager + + return LLMProviderManager + + @router.post( "/manage", summary="LLM提供商统一管理", @@ -37,7 +43,7 @@ async def manage_provider( "callback_url", str(request.url_for("llm_provider_auth_callback", provider_id=payload.target)), ) - result = await LLMProviderManager().provider_manage( + result = await _get_llm_provider_manager_type()().provider_manage( payload.target, payload.action, **params ) return schemas.Response( @@ -70,11 +76,13 @@ async def llm_provider_auth_callback( """ 处理需要浏览器回跳的 OAuth provider。 """ - success, message = await LLMProviderManager().handle_chatgpt_callback( + success, message = await _get_llm_provider_manager_type()().handle_chatgpt_callback( provider_id, code, state, error, error_description, ) + from app.agent.llm.provider import render_auth_result_html + return HTMLResponse(content=render_auth_result_html(success, message)) diff --git a/app/api/endpoints/openai.py b/app/api/endpoints/openai.py index 18458ead6..22f13eb6e 100644 --- a/app/api/endpoints/openai.py +++ b/app/api/endpoints/openai.py @@ -2,6 +2,7 @@ import asyncio import json import time import uuid +from threading import Lock from typing import AsyncIterator, List, Optional, Tuple from fastapi import APIRouter, Request, Security @@ -15,8 +16,11 @@ from app.api.openai_utils import ( build_responses_input, build_session_id, ) -from app.agent.callback import StreamingHandler -from app.agent.orchestrator import MoviePilotAgent +from app.agent.runtime_loader import ( + get_moviepilot_agent_type, + get_running_agent_manager, +) +from app.agent.contracts import ReplyMode from app.runtime.config import settings from app.application.security.access import openai_bearer_scheme from app.schemas.types import MessageChannel @@ -35,7 +39,7 @@ MODEL_ID = "moviepilot-agent" SESSION_PREFIX = "openai:" -class _CollectingMoviePilotAgent(MoviePilotAgent): +class _CollectingMoviePilotAgentMixin: """ 捕获 Agent 最终输出,避免再通过消息渠道二次发送。 """ @@ -45,11 +49,38 @@ class _CollectingMoviePilotAgent(MoviePilotAgent): self.collected_messages: List[str] = [] self.stream_mode = stream_mode if stream_mode: - self.stream_handler = _OpenAIStreamingHandler() + self.stream_handler = _get_openai_streaming_handler_type()() def _should_stream(self) -> bool: return self.stream_mode + def configure_protocol_request( + self, + *, + stream_mode: bool, + event_queue: Optional[asyncio.Queue], + ) -> None: + """切换请求级输出目标,并保持已编译工具引用的 handler identity。""" + self.collected_messages = [] + self.stream_mode = stream_mode + if isinstance(self.stream_handler, _OpenAIStreamingHandlerMixin): + self.stream_handler.bind_queue(event_queue if stream_mode else None) + return + if not stream_mode: + return + self.stream_handler = _get_openai_streaming_handler_type()() + self.stream_handler.bind_queue(event_queue) + # 已编译工具持有旧 handler;identity 变化时必须重建图和工具目录。 + self._compiled_agent_bundle = None + + def release_protocol_request( + self, + event_queue: Optional[asyncio.Queue], + ) -> None: + """释放已结束请求的输出队列,不影响同会话已重绑的新请求。""" + if isinstance(self.stream_handler, _OpenAIStreamingHandlerMixin): + self.stream_handler.unbind_queue(event_queue) + async def send_agent_message(self, message: str, title: str = ""): text = (message or "").strip() if title and text: @@ -62,7 +93,7 @@ class _CollectingMoviePilotAgent(MoviePilotAgent): self.stream_handler.emit(text) -class _OpenAIStreamingHandler(StreamingHandler): +class _OpenAIStreamingHandlerMixin: """ 将 Agent 流式输出转发到 OpenAI SSE 队列,不向站内消息系统落消息。 """ @@ -71,9 +102,15 @@ class _OpenAIStreamingHandler(StreamingHandler): super().__init__() self._event_queue: Optional[asyncio.Queue] = None - def bind_queue(self, queue: asyncio.Queue): + def bind_queue(self, queue: Optional[asyncio.Queue]): + """绑定当前协议请求的输出队列。""" self._event_queue = queue + def unbind_queue(self, queue: Optional[asyncio.Queue]) -> None: + """仅当仍指向该请求时解除绑定,避免清掉已排队的新请求。""" + if self._event_queue is queue: + self._event_queue = None + def emit(self, token: str): emitted = super().emit(token) if emitted and self._event_queue is not None: @@ -121,18 +158,67 @@ class _OpenAIStreamingHandler(StreamingHandler): return True, final_text +def _get_openai_streaming_handler_type() -> type: + """首次兼容协议调用时才解析完整流式处理器。""" + global _OPENAI_STREAMING_HANDLER_TYPE + if _OPENAI_STREAMING_HANDLER_TYPE is not None: + return _OPENAI_STREAMING_HANDLER_TYPE + with _OPENAI_STREAMING_HANDLER_TYPE_LOCK: + if _OPENAI_STREAMING_HANDLER_TYPE is None: + from app.agent.callback import StreamingHandler + + _OPENAI_STREAMING_HANDLER_TYPE = type( + "_RuntimeOpenAIStreamingHandler", + (_OpenAIStreamingHandlerMixin, StreamingHandler), + {"__module__": __name__}, + ) + return _OPENAI_STREAMING_HANDLER_TYPE + + +_OPENAI_STREAMING_HANDLER_TYPE_LOCK = Lock() +_OPENAI_STREAMING_HANDLER_TYPE: Optional[type] = None + + +def _build_collecting_agent_type(agent_base_type: type) -> type: + """为 OpenAI 与 Anthropic 兼容协议组合唯一的运行时类型。""" + return type( + "_RuntimeCollectingMoviePilotAgent", + (_CollectingMoviePilotAgentMixin, agent_base_type), + {"__module__": __name__}, + ) + + +_COLLECTING_AGENT_TYPE_LOCK = Lock() +_COLLECTING_AGENT_TYPE: Optional[type] = None + + +def _get_collecting_agent_type() -> type: + """在首个真实兼容协议请求边界 single-flight 解析 Agent 类型。""" + global _COLLECTING_AGENT_TYPE + if _COLLECTING_AGENT_TYPE is not None: + return _COLLECTING_AGENT_TYPE + with _COLLECTING_AGENT_TYPE_LOCK: + if _COLLECTING_AGENT_TYPE is None: + _COLLECTING_AGENT_TYPE = _build_collecting_agent_type( + get_moviepilot_agent_type() + ) + return _COLLECTING_AGENT_TYPE + + def _sse_payload(data: dict) -> str: return f"data: {json.dumps(data, ensure_ascii=False)}\n\n" async def _stream_response( - agent: _CollectingMoviePilotAgent, + manager, + session_id: str, + user_id: str, + username: str, prompt: str, images: List[str], + cleanup_session: bool, ) -> AsyncIterator[str]: event_queue: asyncio.Queue = asyncio.Queue() - if isinstance(agent.stream_handler, _OpenAIStreamingHandler): - agent.stream_handler.bind_queue(event_queue) created = int(time.time()) completion_id = f"chatcmpl-{uuid.uuid4().hex}" @@ -140,7 +226,19 @@ async def _stream_response( async def _run_agent(): try: - await agent.process(prompt, images=images, files=None) + await _run_managed_agent( + manager=manager, + session_id=session_id, + user_id=user_id, + username=username, + source="openai", + prompt=prompt, + images=images, + stream_mode=True, + event_queue=event_queue, + ) + except asyncio.CancelledError: + await event_queue.put({"error": "MoviePilot AI agent is unavailable."}) except Exception as exc: await event_queue.put({"error": str(exc)}) finally: @@ -170,7 +268,17 @@ async def _stream_response( if item is None: break if isinstance(item, dict) and item.get("error"): - raise RuntimeError(str(item["error"])) + yield _sse_payload( + { + "error": { + "message": str(item["error"]), + "type": "server_error", + "code": "agent_execution_failed", + } + } + ) + yield "data: [DONE]\n\n" + return text = str(item or "") if not text: continue @@ -208,6 +316,10 @@ async def _stream_response( ) yield "data: [DONE]\n\n" finally: + if cleanup_session: + await manager.clear_session(session_id=session_id, user_id=user_id) + elif not task.done(): + await manager.stop_current_task(session_id) if not task.done(): task.cancel() try: @@ -218,6 +330,57 @@ async def _stream_response( await task +def _is_manager_unavailable(error: BaseException) -> bool: + """识别 manager acceptance gate 的稳定错误,不导入完整编排模块。""" + return getattr(error, "code", None) == "agent_manager_unavailable" + + +async def _run_managed_agent( + *, + manager, + session_id: str, + user_id: str, + username: str, + source: str, + prompt: str, + images: List[str], + stream_mode: bool, + event_queue: Optional[asyncio.Queue] = None, +) -> tuple[str, List[str]]: + """通过 AgentManager 执行协议请求,并在 worker 内配置请求级输出。""" + agent_holder = {} + + def configure_agent(agent) -> None: + agent.configure_protocol_request( + stream_mode=stream_mode, + event_queue=event_queue, + ) + agent_holder["agent"] = agent + + try: + result = await manager.process_message( + session_id=session_id, + user_id=user_id, + message=prompt, + images=images, + files=None, + channel=MessageChannel.Web.value, + source=source, + username=username, + reply_mode=ReplyMode.CAPTURE_ONLY, + allow_message_tools=True, + agent_factory=_get_collecting_agent_type(), + agent_setup=configure_agent, + wait_for_completion=True, + ) + agent = agent_holder.get("agent") + return result, list(agent.collected_messages if agent else []) + finally: + agent = agent_holder.get("agent") + if agent is not None: + agent.release_protocol_request(event_queue) + + def _error_response( message: str, status_code: int, @@ -310,6 +473,14 @@ async def chat_completions( error_type="server_error", code="ai_agent_disabled", ) + manager = get_running_agent_manager() + if manager is None: + return _error_response( + "MoviePilot AI agent is unavailable.", + 503, + error_type="server_error", + code="ai_agent_unavailable", + ) if not payload.messages: return _error_response( @@ -337,19 +508,17 @@ async def chat_completions( session_id = build_session_id(session_key, SESSION_PREFIX) username = str(payload.user or "openai-client") - # 兼容接口的 API_TOKEN 客户端按管理员级 MoviePilot Agent 集成处理。 - agent = _CollectingMoviePilotAgent( - session_id=session_id, - user_id=session_key, - channel=MessageChannel.Web.value, - source="openai", - username=username, - stream_mode=payload.stream, - ) - if payload.stream: return StreamingResponse( - _stream_response(agent=agent, prompt=prompt, images=images), + _stream_response( + manager=manager, + session_id=session_id, + user_id=session_key, + username=username, + prompt=prompt, + images=images, + cleanup_session=not use_server_session, + ), media_type="text/event-stream", headers={ "Cache-Control": "no-cache", @@ -358,19 +527,39 @@ async def chat_completions( }, ) + collected_messages = [] try: - result = await agent.process(prompt, images=images, files=None) + result, collected_messages = await _run_managed_agent( + manager=manager, + session_id=session_id, + user_id=session_key, + username=username, + source="openai", + prompt=prompt, + images=images, + stream_mode=False, + ) except Exception as exc: + if _is_manager_unavailable(exc): + return _error_response( + "MoviePilot AI agent is unavailable.", + 503, + error_type="server_error", + code="ai_agent_unavailable", + ) return _error_response( str(exc), 500, error_type="server_error", code="agent_execution_failed", ) + finally: + if not use_server_session: + await manager.clear_session(session_id=session_id, user_id=session_key) content = "\n\n".join( message.strip() - for message in agent.collected_messages + for message in collected_messages if message and message.strip() ).strip() if not content and result: @@ -403,6 +592,14 @@ async def responses( error_type="server_error", code="ai_agent_disabled", ) + manager = get_running_agent_manager() + if manager is None: + return _error_response( + "MoviePilot AI agent is unavailable.", + 503, + error_type="server_error", + code="ai_agent_unavailable", + ) if payload.stream: return _error_response( @@ -430,29 +627,39 @@ async def responses( session_key = str(payload.user or uuid.uuid4()) session_id = build_session_id(session_key, SESSION_PREFIX) - # 兼容接口的 API_TOKEN 客户端按管理员级 MoviePilot Agent 集成处理。 - agent = _CollectingMoviePilotAgent( - session_id=session_id, - user_id=session_key, - channel=MessageChannel.Web.value, - source="openai.responses", - username=str(payload.user or "openai-client"), - stream_mode=False, - ) - + collected_messages = [] try: - result = await agent.process(prompt, images=images, files=None) + result, collected_messages = await _run_managed_agent( + manager=manager, + session_id=session_id, + user_id=session_key, + username=str(payload.user or "openai-client"), + source="openai.responses", + prompt=prompt, + images=images, + stream_mode=False, + ) except Exception as exc: + if _is_manager_unavailable(exc): + return _error_response( + "MoviePilot AI agent is unavailable.", + 503, + error_type="server_error", + code="ai_agent_unavailable", + ) return _error_response( str(exc), 500, error_type="server_error", code="agent_execution_failed", ) + finally: + if not payload.user: + await manager.clear_session(session_id=session_id, user_id=session_key) content = "\n\n".join( message.strip() - for message in agent.collected_messages + for message in collected_messages if message and message.strip() ).strip() if not content and result: diff --git a/app/application/agent.py b/app/application/agent.py index cab4fd58f..efec398d1 100644 --- a/app/application/agent.py +++ b/app/application/agent.py @@ -1,26 +1,46 @@ """Agent 编排服务门面。 chain 层需要触发 Agent 后台任务、渲染提示词、查询模型能力时统一经本模块调用。 -具体实现由 app.agent 在启动时注册,形成依赖倒置: +具体实现由 startup 组合根注册,形成依赖倒置: - chain -> application.agent <- agent(startup 在导入期注册) + chain -> application.agent <- startup -> agent -静态依赖图上 application 不依赖 agent,agent 作为入口层向 application -注册实现,从而拆除 chain <-> agent 的互指环。 - -注意:本模块禁止静态导入 app.agent 下的任何模块(含函数内导入), -否则会形成 agent -> chain -> application -> agent 的新环。 -未注册时的兜底注册由 startup/agent_initializer 在导入期完成。 +门面保存 provider 而非重量级实现对象,注册本身不会物化 Agent、LLM 或工具树。 +本模块禁止静态或函数内导入 app.agent,否则会重新形成跨层循环依赖。 """ from typing import Any, Callable, Optional -# 注册表:启动期由 startup/agent_initializer 填充。 -_agent_manager: Any = None -_prompt_manager: Any = None -_agent_capability_manager: Any = None -_llm_helper: Any = None -_manual_redo_prompt_builder: Optional[Callable[[Any], str]] = None +Provider = Callable[[], Any] + +# provider 注册表由 startup/agent_initializer 在组合根装配。 +_agent_manager_provider: Optional[Provider] = None +_running_agent_manager_provider: Optional[Provider] = None +_prompt_manager_provider: Optional[Provider] = None +_agent_capability_manager_provider: Optional[Provider] = None +_llm_helper_provider: Optional[Provider] = None +_manual_redo_prompt_builder_provider: Optional[Provider] = None + + +def register_agent_service_providers( + *, + agent_manager_provider: Provider, + running_agent_manager_provider: Provider, + prompt_manager_provider: Provider, + capability_manager_provider: Provider, + llm_helper_provider: Provider, + manual_redo_prompt_builder_provider: Provider, +) -> None: + """注册 Agent 服务 provider,保持组合根装配阶段零重量实现导入。""" + global _agent_manager_provider, _running_agent_manager_provider + global _prompt_manager_provider, _agent_capability_manager_provider + global _llm_helper_provider, _manual_redo_prompt_builder_provider + _agent_manager_provider = agent_manager_provider + _running_agent_manager_provider = running_agent_manager_provider + _prompt_manager_provider = prompt_manager_provider + _agent_capability_manager_provider = capability_manager_provider + _llm_helper_provider = llm_helper_provider + _manual_redo_prompt_builder_provider = manual_redo_prompt_builder_provider def register_agent_services( @@ -30,38 +50,40 @@ def register_agent_services( llm_helper: Any, manual_redo_prompt_builder: Optional[Callable[[Any], str]] = None, ) -> None: - """注册 Agent 服务实现(由 startup 组合根在导入期调用)。""" - global _agent_manager, _prompt_manager, _agent_capability_manager, _llm_helper - global _manual_redo_prompt_builder - _agent_manager = agent_manager - _prompt_manager = prompt_manager - _agent_capability_manager = capability_manager - _llm_helper = llm_helper - _manual_redo_prompt_builder = manual_redo_prompt_builder + """兼容直接对象注入;生产组合根应注册惰性 provider。""" + register_agent_service_providers( + agent_manager_provider=lambda: agent_manager, + running_agent_manager_provider=lambda: agent_manager, + prompt_manager_provider=lambda: prompt_manager, + capability_manager_provider=lambda: capability_manager, + llm_helper_provider=lambda: llm_helper, + manual_redo_prompt_builder_provider=lambda: manual_redo_prompt_builder, + ) -def _ensure_registered() -> None: - """校验 Agent 服务已注册。 - - 正常启动路径由 startup/agent_initializer 在导入期注册;未注册时 - 直接抛出带指引的错误,避免在此处静态导入 app.agent 破坏依赖方向。 - """ - if _agent_manager is None: +def _resolve(provider: Optional[Provider], service_name: str) -> Any: + """解析已注册服务;缺少组合根装配时给出稳定错误。""" + if provider is None: raise RuntimeError( - "Agent 服务未注册:请先导入 app.startup.agent_initializer 完成组合根装配" + f"Agent 服务 {service_name} 未注册:" + "请先导入 app.startup.agent_initializer 完成组合根装配" ) + return provider() def get_agent_manager() -> Any: - """返回 AgentManager 单例。""" - _ensure_registered() - return _agent_manager + """返回 canonical AgentManager;调用可能触发实现物化。""" + return _resolve(_agent_manager_provider, "agent_manager") + + +def get_running_agent_manager() -> Any | None: + """返回已进入 RUNNING 的 AgentManager,不触发实现物化。""" + return _resolve(_running_agent_manager_provider, "running_agent_manager") def get_prompt_manager() -> Any: - """返回提示词管理器。""" - _ensure_registered() - return _prompt_manager + """按需返回提示词管理器。""" + return _resolve(_prompt_manager_provider, "prompt_manager") def supports_image_input( @@ -71,8 +93,8 @@ def supports_image_input( base_url_preset: Optional[str] = None, ) -> bool: """判断当前模型是否启用了图片输入能力。""" - _ensure_registered() - return _llm_helper.supports_image_input( + llm_helper = _resolve(_llm_helper_provider, "llm_helper") + return llm_helper.supports_image_input( provider=provider, model=model, base_url=base_url, @@ -82,19 +104,28 @@ def supports_image_input( def is_audio_input_available() -> bool: """判断语音输入能力是否可用。""" - _ensure_registered() - return _agent_capability_manager.is_audio_input_available() + capability_manager = _resolve( + _agent_capability_manager_provider, + "agent_capability_manager", + ) + return capability_manager.is_audio_input_available() def transcribe_audio(content: bytes, filename: str = "input.ogg") -> Optional[str]: """把音频内容转写为文本。""" - _ensure_registered() - return _agent_capability_manager.transcribe_audio(content, filename=filename) + capability_manager = _resolve( + _agent_capability_manager_provider, + "agent_capability_manager", + ) + return capability_manager.transcribe_audio(content, filename=filename) def build_manual_redo_prompt(history: Any) -> str: """构造整理记录 AI 重新整理提示词(builder 由 agent 层注册)。""" - _ensure_registered() - if _manual_redo_prompt_builder is None: + builder = _resolve( + _manual_redo_prompt_builder_provider, + "manual_redo_prompt_builder", + ) + if builder is None: raise RuntimeError("整理记录重新整理提示词构建器未注册") - return _manual_redo_prompt_builder(history) + return builder(history) diff --git a/app/application/transfer.py b/app/application/transfer.py index 6bdbdf50f..0554169b7 100644 --- a/app/application/transfer.py +++ b/app/application/transfer.py @@ -24,7 +24,7 @@ from pydantic import BaseModel, ConfigDict from app import schemas from app.adapters.system.host import SystemUtils -from app.application.agent import get_agent_manager, get_prompt_manager +from app.application.agent import get_prompt_manager, get_running_agent_manager from app.domain.context import MediaInfo, MusicInfo from app.domain.media import normalize_music_type from app.domain.meta.metabase import MetaBase @@ -959,7 +959,11 @@ class FailedRetryScheduler: ) try: - await get_agent_manager().run_background_prompt( + manager = get_running_agent_manager() + if manager is None: + logger.warning("智能助手服务未运行,跳过整理失败自动重试") + return + await manager.run_background_prompt( message=self._build_retry_transfer_prompt(history_ids), session_prefix="__agent_retry_transfer_batch", reply_mode=ReplyMode.DISPATCH, @@ -972,4 +976,3 @@ class FailedRetryScheduler: f"智能体重试整理失败 (IDs=[{ids_str}], group={group_key}): {err}" ) - diff --git a/app/chain/_transfer.py b/app/chain/_transfer.py index d780b4124..063490b7c 100644 --- a/app/chain/_transfer.py +++ b/app/chain/_transfer.py @@ -14,7 +14,7 @@ from typing import Any, Callable, Dict, List, Optional, Tuple, Union from app import schemas from app.adapters.system.host import SystemUtils -from app.application.agent import build_manual_redo_prompt, get_agent_manager +from app.application.agent import build_manual_redo_prompt, get_running_agent_manager from app.application.formatting import EpisodeFormatRuleHelper from app.application.history import clear_transfer_failures, resolve_history from app.application.transfer import TransferTask, job_lock @@ -1418,7 +1418,10 @@ class FailedRetryMixin: final_output = text_output or "" try: - await get_agent_manager().run_background_prompt( + manager = get_running_agent_manager() + if manager is None: + raise RuntimeError("智能助手服务未运行") + await manager.run_background_prompt( message=redo_prompt, session_prefix=f"__agent_manual_redo_{history_id}", output_callback=_capture_output, diff --git a/app/chain/message.py b/app/chain/message.py index 15ee57d73..6a9d237c0 100644 --- a/app/chain/message.py +++ b/app/chain/message.py @@ -12,7 +12,7 @@ from typing import Any, Optional, Dict, Union, List, Tuple from urllib.parse import unquote, urlparse from app.application.agent import ( - get_agent_manager, + get_running_agent_manager, is_audio_input_available, supports_image_input, transcribe_audio, @@ -70,9 +70,14 @@ class MessageChain(ChainBase): """ if not session_id: return + manager = get_running_agent_manager() + if manager is None: + return clear_task = None try: - clear_task = get_agent_manager().clear_session(session_id=session_id, user_id=str(userid)) + clear_task = manager.clear_session( + session_id=session_id, user_id=str(userid) + ) asyncio.run_coroutine_threadsafe( clear_task, global_vars.loop, @@ -350,7 +355,8 @@ class MessageChain(ChainBase): if not session_info: return False session_id, _ = session_info - if not get_agent_manager().matches_secret_confirmation( + manager = get_running_agent_manager() + if manager is None or not manager.matches_secret_confirmation( session_id, str(userid), channel=channel.value, @@ -968,19 +974,21 @@ class MessageChain(ChainBase): # 如果有会话ID,同时清除智能体的会话记忆 if session_id: + manager = get_running_agent_manager() clear_task = None - try: - clear_task = get_agent_manager().clear_session( - session_id=session_id, user_id=str(userid) - ) - asyncio.run_coroutine_threadsafe( - clear_task, - global_vars.loop, - ) - except Exception as e: - if clear_task: - clear_task.close() - logger.warning(f"清除智能体会话记忆失败: {e}") + if manager is not None: + try: + clear_task = manager.clear_session( + session_id=session_id, user_id=str(userid) + ) + asyncio.run_coroutine_threadsafe( + clear_task, + global_vars.loop, + ) + except Exception as e: + if clear_task: + clear_task.close() + logger.warning(f"清除智能体会话记忆失败: {e}") self.post_message( Notification( @@ -1017,12 +1025,16 @@ class MessageChain(ChainBase): session_info = self._user_sessions.get(userid) if session_info: session_id, _ = session_info + manager = get_running_agent_manager() try: - future = asyncio.run_coroutine_threadsafe( - get_agent_manager().stop_current_task(session_id=session_id), - global_vars.loop, - ) - stopped = future.result(timeout=10) + if manager is None: + stopped = False + else: + future = asyncio.run_coroutine_threadsafe( + manager.stop_current_task(session_id=session_id), + global_vars.loop, + ) + stopped = future.result(timeout=10) except Exception as e: logger.warning(f"停止Agent推理失败: {e}") stopped = False @@ -1184,7 +1196,19 @@ class MessageChain(ChainBase): return session_id, _ = session_info - status = get_agent_manager().get_session_status(session_id=session_id) + manager = get_running_agent_manager() + if manager is None: + self.post_message( + Notification( + channel=channel, + source=source, + title="您当前没有活跃的智能体会话", + userid=userid, + save_history=False, + ) + ) + return + status = manager.get_session_status(session_id=session_id) self.post_message( Notification( channel=channel, @@ -1229,6 +1253,20 @@ class MessageChain(ChainBase): ) return False + manager = get_running_agent_manager() + if manager is None: + self.post_message( + Notification( + channel=channel, + source=source, + userid=userid, + username=username, + title="MoviePilot智能助手服务尚未就绪,请稍后重试", + save_history=False, + ) + ) + return False + images = CommingMessage.MessageImage.normalize_list(images) # 提取用户消息 @@ -1337,7 +1375,7 @@ class MessageChain(ChainBase): process_kwargs["has_audio_input"] = True # 在事件循环中处理 asyncio.run_coroutine_threadsafe( - get_agent_manager().process_message(**process_kwargs), + manager.process_message(**process_kwargs), global_vars.loop, ) return True @@ -1854,4 +1892,4 @@ class MessageChain(ChainBase): return base64.b64decode(payload) except Exception as e: logger.error(e) - return None \ No newline at end of file + return None diff --git a/app/chain/search.py b/app/chain/search.py index 9e87768bd..df4cbb434 100644 --- a/app/chain/search.py +++ b/app/chain/search.py @@ -509,7 +509,7 @@ class SearchChain(ChainBase): """ 通过统一后台提示词机制执行资源推荐。 """ - from app.application.agent import get_agent_manager, get_prompt_manager + from app.application.agent import get_prompt_manager, get_running_agent_manager from app.schemas.agent import ReplyMode prompt = get_prompt_manager().render_system_task_message( @@ -521,7 +521,11 @@ class SearchChain(ChainBase): def on_output(text: str): full_output[0] = text - await get_agent_manager().run_background_prompt( + manager = get_running_agent_manager() + if manager is None: + logger.warning("智能助手服务未运行,跳过搜索结果 AI 推荐") + raise RuntimeError("智能助手服务未运行") + await manager.run_background_prompt( message=prompt, session_prefix="__agent_search_recommend", output_callback=on_output, diff --git a/app/scheduler.py b/app/scheduler.py index a0b2811ba..40b1232ee 100644 --- a/app/scheduler.py +++ b/app/scheduler.py @@ -1221,10 +1221,14 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass): :param trigger_source: 触发入口,scheduled-自动调度,manual-显式立即执行 :return: 执行是否成功及结果摘要 """ - from app.agent.orchestrator import agent_manager + from app.agent.runtime_loader import get_running_agent_manager try: - return await agent_manager.execute_scheduled_task( + manager = get_running_agent_manager() + if manager is None: + logger.warning("智能助手服务未运行,跳过 Agent 定时任务") + return False, "智能助手服务未运行" + return await manager.execute_scheduled_task( task_id, trigger_source=trigger_source, ) @@ -1537,9 +1541,13 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass): """ 智能体心跳唤醒:检查并执行待处理的定时任务 """ - from app.agent.orchestrator import agent_manager + from app.agent.runtime_loader import get_running_agent_manager - await agent_manager.heartbeat_check_jobs() + manager = get_running_agent_manager() + if manager is None: + logger.debug("智能助手服务未运行,跳过心跳任务") + return + await manager.heartbeat_check_jobs() def user_auth(self): """ diff --git a/app/schemas/agent.py b/app/schemas/agent.py index 447791bf6..65f5cdab8 100644 --- a/app/schemas/agent.py +++ b/app/schemas/agent.py @@ -1,20 +1,13 @@ """AI智能体相关数据模型""" from datetime import datetime -from enum import Enum from typing import Any, List, Literal, Optional, Union from langchain_core.messages import BaseMessage from pydantic import BaseModel, Field, ConfigDict, field_serializer from app.schemas.common import JsonData - - -class ReplyMode(str, Enum): - """Agent 最终回复处理模式(chain 与 agent 层共享的值域)。""" - - DISPATCH = "dispatch" - CAPTURE_ONLY = "capture_only" +from app.schemas.types import ReplyMode class ConversationMemory(BaseModel): diff --git a/app/schemas/types.py b/app/schemas/types.py index d46efbdf4..57f4c0e23 100644 --- a/app/schemas/types.py +++ b/app/schemas/types.py @@ -22,6 +22,13 @@ MUSIC_SUBSCRIBABLE_TYPES = frozenset({ MUSIC_ENTITY_ALBUM, }) + +class ReplyMode(str, Enum): + """Agent 最终回复的投递策略,供编排层与调用层共享。""" + + DISPATCH = "dispatch" + CAPTURE_ONLY = "capture_only" + # ListenBrainz 音乐探索能力的参数取值域契约,供入口层校验、链层与模块实现共用 # ListenBrainz 全站统计支持的周期,取值与官方统计页面完全一致 LISTENBRAINZ_CHART_RANGES = ( diff --git a/app/startup/agent_initializer.py b/app/startup/agent_initializer.py index f56f4c39d..fc9f51889 100644 --- a/app/startup/agent_initializer.py +++ b/app/startup/agent_initializer.py @@ -1,20 +1,81 @@ -from app.agent.llm import AgentCapabilityManager, LLMHelper -from app.agent.orchestrator import agent_manager -from app.agent.prompt import prompt_manager -from app.agent.prompt.transfer_redo import build_manual_redo_prompt -from app.application.agent import register_agent_services -from app.runtime.config import settings -from app.runtime.log import logger +from typing import Any -# 导入期即向 application 门面注册实现,保证任何先于 initialize 的 -# 链层调用都能通过门面取到 Agent 服务对象。 -register_agent_services( - agent_manager=agent_manager, - prompt_manager=prompt_manager, - capability_manager=AgentCapabilityManager, - llm_helper=LLMHelper, - manual_redo_prompt_builder=build_manual_redo_prompt, +from app.agent.runtime_loader import ( + activate_agent_service, + begin_agent_shutdown, + get_agent_manager as get_runtime_agent_manager, + get_running_agent_manager as get_runtime_running_agent_manager, + is_tool_factory_materialized, + reconcile_agent_service, ) +from app.application.agent import register_agent_service_providers +from app.runtime.config import settings +from app.runtime.events import Event, eventmanager +from app.runtime.log import logger +from app.schemas.types import EventType + + +# 嵌入式启动器可显式注入 manager;常规进程使用 Capability Runtime。 +agent_manager: Any = None + + +def _event_changed_keys(event: Event | None) -> set[str]: + """兼容对象和 dict 两种配置事件载荷。""" + if event is None: + return set() + event_data = event.event_data + if isinstance(event_data, dict): + keys = event_data.get("key", set()) + else: + keys = getattr(event_data, "key", set()) + if isinstance(keys, str): + return {keys} + return {str(key) for key in (keys or set())} + + +def _get_agent_manager() -> Any: + """兼容显式注入对象,否则按需解析 canonical manager。""" + return agent_manager if agent_manager is not None else get_runtime_agent_manager() + + +def _get_running_agent_manager() -> Any | None: + """只返回已运行实例,状态探测不得触发 Agent 物化。""" + if agent_initializer._compat_injected: + return agent_initializer._manager + return get_runtime_running_agent_manager() + + +def _get_prompt_manager() -> Any: + """首个提示词调用才导入模板管理器。""" + from app.agent.prompt import prompt_manager + + return prompt_manager + + +def _get_capability_manager() -> Any: + """首个多模态调用才导入 Agent 能力管理器。""" + from app.agent.llm import AgentCapabilityManager + + return AgentCapabilityManager + + +def _get_llm_helper() -> Any: + """首个模型能力查询才导入 LLM helper。""" + from app.agent.llm import LLMHelper + + return LLMHelper + + +def _get_manual_redo_prompt_builder() -> Any: + """首个整理接管请求才导入对应提示词构建器。""" + from app.agent.prompt.transfer_redo import build_manual_redo_prompt + + return build_manual_redo_prompt + + +async def _handle_agent_config_changed(event: Event) -> None: + """把配置事件交给当前全局 initializer,避免监听器持有过期实例。""" + await agent_initializer.handle_config_changed(event) class AgentInitializer: @@ -24,17 +85,33 @@ class AgentInitializer: def __init__(self): self._initialized = False + self._manager: Any = None + self._compat_injected = False + self._shutdown_complete = False + eventmanager.add_event_listener( + EventType.ConfigChanged, + _handle_agent_config_changed, + ) async def initialize(self) -> bool: """ 初始化AI智能体管理器 """ try: - if not settings.AI_AGENT_ENABLE: - logger.info("AI智能体功能未启用") - return True - - await agent_manager.initialize() + self._shutdown_complete = False + if agent_manager is not None: + if not settings.AI_AGENT_ENABLE: + logger.info("AI智能体功能未启用") + return True + self._manager = agent_manager + self._compat_injected = True + await agent_manager.initialize() + else: + self._manager = await activate_agent_service() + self._compat_injected = False + if self._manager is None: + logger.info("AI智能体功能未启用") + return True self._initialized = True logger.info("AI智能体管理器初始化成功") return True @@ -43,16 +120,38 @@ class AgentInitializer: logger.error(f"AI智能体管理器初始化失败: {e}") return False - async def cleanup(self) -> None: - """ - 清理AI智能体管理器 - """ + async def handle_config_changed(self, event: Event) -> None: + """仅在 manifest watch 命中时协调 service,关闭态保持 fail closed。""" + changed_keys = _event_changed_keys(event) + if not changed_keys or self._compat_injected or self._shutdown_complete: + return try: - if not self._initialized: - return - await agent_manager.close() + self._manager = await reconcile_agent_service( + reason="agent_service_config_changed", + changed_keys=changed_keys, + retry=True, + ) + self._initialized = self._manager is not None + except Exception as error: + self._manager = None self._initialized = False - logger.info("AI智能体管理器已关闭") + logger.debug(f"配置变更协调AI智能体失败: {error}") + + async def cleanup(self) -> None: + """清理 initializer 引用;显式注入对象同时在此关闭。""" + try: + manager = self._manager + compat_injected = self._compat_injected + if manager is None: + return + try: + if compat_injected: + await manager.close() + logger.info("AI智能体管理器已关闭") + finally: + self._initialized = False + self._manager = None + self._compat_injected = False except Exception as e: logger.debug(f"关闭AI智能体管理器时发生错误: {e}") @@ -61,16 +160,22 @@ class AgentInitializer: # 全局AI智能体初始化器实例 agent_initializer = AgentInitializer() +# application 门面仅保存 provider;下列注册不会导入 Agent 实现。 +register_agent_service_providers( + agent_manager_provider=_get_agent_manager, + running_agent_manager_provider=_get_running_agent_manager, + prompt_manager_provider=_get_prompt_manager, + capability_manager_provider=_get_capability_manager, + llm_helper_provider=_get_llm_helper, + manual_redo_prompt_builder_provider=_get_manual_redo_prompt_builder, +) + async def init_agent() -> bool: """ 在应用事件循环中初始化AI智能体。 """ try: - if not settings.AI_AGENT_ENABLE: - logger.info("AI智能体功能未启用") - return True - return await agent_initializer.initialize() except Exception as e: @@ -83,6 +188,16 @@ async def stop_agent(): 停止AI智能体(异步版本,用于在应用关闭时调用) """ try: - await agent_initializer.cleanup() + if not agent_initializer._shutdown_complete: + if agent_initializer._compat_injected: + await agent_initializer.cleanup() + else: + await begin_agent_shutdown() + await agent_initializer.cleanup() + agent_initializer._shutdown_complete = True + if is_tool_factory_materialized(): + from app.agent.tools.base import shutdown_blocking_executors + + shutdown_blocking_executors(cancel_futures=True) except Exception as e: logger.error(f"停止AI智能体时发生错误: {e}") diff --git a/docs/rules/05-architecture.md b/docs/rules/05-architecture.md index 637f9ad98..37cdcd663 100644 --- a/docs/rules/05-architecture.md +++ b/docs/rules/05-architecture.md @@ -340,7 +340,7 @@ policy. `app/db` therefore has no dependency on `app/domain`. |---|---| | `entrypoint -> chain / application / Oper` | Allowed according to workflow complexity | | `chain -> module (only via run_module dispatch) / application / Oper / canonical capability` | Allowed; direct `chain -> module` imports forbidden | -| `chain -> agent implementation` | Forbidden; chains reach Agent runtime only through `app/application/agent.py`, whose implementations are registered by `app/startup/agent_initializer.py` at import time | +| `chain -> agent implementation` | Forbidden; chains reach Agent runtime only through `app/application/agent.py`; `app/startup/agent_initializer.py` registers lightweight providers at import time, and implementations are materialized only when the capability is enabled or first used | | `agent.tools -> api / scheduler / command` | Forbidden; tools use `app/application/plugins.py`, `scheduling.py` and `commands.py` facades | | `api -> factory` | Forbidden; the FastAPI instance is injected into `app/application/plugins.py` by the composition root after creation | | `application -> domain / runtime / adapter / Oper` | Allowed | @@ -357,7 +357,8 @@ policy. `app/db` therefore has no dependency on `app/domain`. | Path | Purpose | |---|---| -| `app/application/agent.py` | Agent orchestration facade (`get_agent_manager` / `get_prompt_manager` / capability queries / prompt builders); Agent implementations register through `app/startup/agent_initializer.py`, no static `application -> agent` edge | +| `app/application/agent.py` | Agent orchestration facade (`get_agent_manager` / `get_prompt_manager` / capability queries / prompt builders); lightweight providers register through `app/startup/agent_initializer.py`, with no static `application -> agent` edge | +| `app/agent/runtime_loader.py` | Agent-specific capability discovery and canonical entrypoint/service materialization; reuses the generic Capability Runtime while keeping Agent ownership under `app/agent/` | | `app/application/plugins.py` | Plugin API dynamic route registration/removal; the FastAPI instance is injected by `app/factory.py` after creation | | `app/application/scheduling.py` | Runtime scheduler facade for Agent tools and endpoints; `Scheduler` class registered by `app/startup/scheduler_initializer.py` | | `app/application/commands.py` | Command registry facade for Agent tools and endpoints; `Command` class registered by `app/startup/command_initializer.py` | diff --git a/scripts/perf/README.md b/scripts/perf/README.md index a2b074069..11d29fd26 100644 --- a/scripts/perf/README.md +++ b/scripts/perf/README.md @@ -81,6 +81,62 @@ Xvfb,因此不能用同一个 `0 → 0` / `0 → 1` 不变量衡量。三轮 B - 非默认场景结果保存在 `samples//-/`,可与同 campaign 的 idle 样本并存, Markdown 中位数会按场景分组,不会混算。 +## Agent 惰性物化场景 + +PERF-003 在既有 `AI_AGENT_ENABLE=false` 固定配置下增加两个 After-only 场景。探针只向主 MoviePilot +Python 进程发送信号;OpenAPI 生成和工具目录构造均发生在该解释器内,不通过 `docker exec` 启动 +第二个 Python,也不调用真实 Agent、LLM provider 或外部 MCP。 + +先以 `f2e548e1` 冻结 Before,候选提交完成后把 `AFTER_COMMIT` 替换为其精确 commit: + +```bash +../.venv/bin/python scripts/perf/moviepilot_docker_ab.py \ + --campaign v3-perf-003 \ + build --before-ref f2e548e1 --after-ref AFTER_COMMIT + +../.venv/bin/python scripts/perf/moviepilot_docker_ab.py \ + --campaign v3-perf-003 \ + seed --browser-source-volume mp-perf-v3-browser-seed --replace +``` + +正式 idle-default 三组 A/B 仍使用原 `run` 合同;下面两个动作场景在同一 build/seed 后单独采 After, +不会覆盖 idle 结果: + +```bash +../.venv/bin/python scripts/perf/moviepilot_docker_ab.py \ + --campaign v3-perf-003 \ + run --before-ref f2e548e1 --after-ref AFTER_COMMIT \ + --browser-source-volume mp-perf-v3-browser-seed \ + --points 1,5,10,30 --replace --keep-resources + +../.venv/bin/python scripts/perf/moviepilot_docker_ab.py \ + --campaign v3-perf-003 \ + sample --variant after --index 1 --scenario agent-disabled-router --points 1,5,10,30 + +../.venv/bin/python scripts/perf/moviepilot_docker_ab.py \ + --campaign v3-perf-003 \ + sample --variant after --index 2 --scenario agent-tool-catalog --points 1,5,10,30 +``` + +- `agent-disabled-router`:直接从主进程 FastAPI app 生成完整 OpenAPI,确认 Agent、LLM、MCP、OpenAI、 + Anthropic 路由在禁用态仍存在,同时 callback、LLM helper、工具域、orchestrator、LangGraph 和 provider SDK + 前后保持 0,工具工厂不物化; +- `agent-tool-catalog`:通过主进程已有的 `moviepilot_tool_manager.list_tools()` 首次构建现有工具目录和 JSON Schema, + 要求动作前工具域未物化,动作后仅工具 base/catalog/factory/impl 物化;目录还必须无身份碰撞、Schema + digest 完整,重复读取复用同一 snapshot/revision。结果记录工具数、Schema 摘要、plugin revision 与 + factory revision; +- 固定哨兵覆盖 `app.agent.orchestrator`、`app.agent.callback`、`app.agent.llm.helper`、工具 + `base/catalog/factory/impl`、`langgraph`、`langchain`、`langchain_core`、`openai`、`anthropic`、 + `google.genai`、`boto3`、`botocore`。其中 `langchain/langchain_core` 可能由完整 Schema 聚合形成既有 + 基线,只记录数量与变化,不作为禁用态归零门禁; +- JSON 保留动作前后 Engine、PSS/USS、线程、完整 `sys.modules`、materialization observation、revision、 + 网络累计值和浏览器卷指纹;动作前后容器网络收发必须为 0,Markdown 另汇总 Agent 场景与各定时点的 + 模块哨兵峰值; +- 启用态 Agent 生命周期不会在该无凭据场景中伪造。现有 `get_running_agent_manager()` 是严格只读、 + non-materializing 的运行态 getter,`begin_agent_shutdown()` 也只是关闭轴;二者都不是安全启用入口。 + 启用态必须由正式 startup/service lifecycle 驱动,只有宿主形成明确不创建 provider/client、不会外联的 + 公共初始化合同后,才适合加入同一测量门禁。 + ## 完整三组 A/B ```bash diff --git a/scripts/perf/instrument/sitecustomize.py b/scripts/perf/instrument/sitecustomize.py index 2fabc4f58..3fa40a18e 100644 --- a/scripts/perf/instrument/sitecustomize.py +++ b/scripts/perf/instrument/sitecustomize.py @@ -12,6 +12,20 @@ import sys _OUTPUT_DIR = os.environ.get("MP_PERF_OUTPUT_DIR") _SCENARIO = os.environ.get("MP_PERF_SCENARIO", "idle-default") _ACTIVATION_TIMEOUT = float(os.environ.get("MP_PERF_ACTIVATION_TIMEOUT", "120")) +_AGENT_SCENARIOS = {"agent-disabled-router", "agent-tool-catalog"} +_AGENT_HEAVY_MODULE_PREFIXES = tuple( + prefix + for prefix in os.environ.get( + "MP_PERF_AGENT_MODULE_PREFIXES", + ( + "app.agent.orchestrator,app.agent.callback,app.agent.llm.helper," + "app.agent.tools.base,app.agent.tools.catalog," + "app.agent.tools.factory,app.agent.tools.impl,langgraph,langchain," + "langchain_core,openai,anthropic,google.genai,boto3,botocore" + ), + ).split(",") + if prefix +) _snapshot_index = 0 _activation_started = False _browser_resources: list[object] = [] @@ -112,6 +126,244 @@ def _enum_value(value): return getattr(value, "value", value) +def _stable_digest(value: object) -> str: + """计算不依赖对象地址的 JSON 摘要。""" + import hashlib + import json + + content = json.dumps( + value, + ensure_ascii=False, + allow_nan=False, + sort_keys=True, + separators=(",", ":"), + ) + return hashlib.sha256(content.encode("utf-8")).hexdigest() + + +def _agent_module_observation() -> dict[str, object]: + """记录 Agent 重模块在目标解释器中的精确加载状态。""" + prefix_counts = { + prefix: sum( + 1 + for module_name in sys.modules + if module_name == prefix or module_name.startswith(f"{prefix}.") + ) + for prefix in _AGENT_HEAVY_MODULE_PREFIXES + } + matching_modules = sorted( + module_name + for module_name in sys.modules + if any( + module_name == prefix or module_name.startswith(f"{prefix}.") + for prefix in _AGENT_HEAVY_MODULE_PREFIXES + ) + ) + return { + "total_modules": len(sys.modules), + "prefix_counts": prefix_counts, + "matching_modules": matching_modules, + "matching_sha256": _stable_digest(matching_modules), + } + + +def _read_agent_runtime() -> dict[str, object]: + """读取轻量 Agent loader 的公开只读状态,不触发 capability 首用。""" + try: + from app.agent.runtime_loader import is_tool_factory_materialized + + return { + "available": True, + "tool_factory_materialized": is_tool_factory_materialized(), + } + except Exception as error: # pragma: no cover - 候选未就绪或真实 runtime 错误 + return { + "available": False, + "error_type": type(error).__name__, + "error": str(error), + } + + +def _probe_router_openapi(app_instance=None, settings_object=None) -> dict[str, object]: + """在主进程中生成 OpenAPI,并验证禁用态 Agent 路由仍完整存在。""" + if app_instance is None: + from app.factory import app as app_instance + if settings_object is None: + from app.runtime.config import settings as settings_object + + required_paths = ( + "/api/v1/message/agent/stream", + "/api/v1/message/agent/sessions", + "/api/v1/openai/v1/chat/completions", + "/api/v1/openai/v1/responses", + "/api/v1/anthropic/v1/messages", + "/api/v1/llm/manage", + "/api/v1/mcp", + "/api/v1/mcp/tools", + ) + schema = app_instance.openapi() + route_paths = sorted( + { + str(route.path) + for route in app_instance.routes + if getattr(route, "path", None) + } + ) + openapi_paths = sorted((schema.get("paths") or {}).keys()) + missing_routes = [path for path in required_paths if path not in route_paths] + missing_openapi_paths = [ + path for path in required_paths if path not in openapi_paths + ] + agent_enabled = bool(settings_object.AI_AGENT_ENABLE) + return { + "success": not agent_enabled + and not missing_routes + and not missing_openapi_paths, + "ai_agent_enable": agent_enabled, + "required_paths": list(required_paths), + "missing_routes": missing_routes, + "missing_openapi_paths": missing_openapi_paths, + "route_count": len(route_paths), + "openapi_path_count": len(openapi_paths), + "openapi_sha256": _stable_digest(schema), + "openapi_title": (schema.get("info") or {}).get("title"), + "openapi_version": (schema.get("info") or {}).get("version"), + } + + +def _probe_tool_catalog(manager=None) -> dict[str, object]: + """通过稳定工具管理入口首次生成目录与 JSON Schema。""" + if manager is None: + from app.agent.tools.manager import moviepilot_tool_manager + + manager = moviepilot_tool_manager + definitions = manager.list_tools() + catalog = manager.catalog + serialized_definitions = [ + { + "name": definition.name, + "input_schema": definition.input_schema, + } + for definition in definitions + ] + schema_count = sum( + isinstance(definition.input_schema, dict) for definition in definitions + ) + entries = catalog.entries if catalog is not None else () + collisions = catalog.collisions if catalog is not None else {} + source_counts: dict[str, int] = {} + serialized_entries = [] + for entry in entries: + source_counts[entry.source] = source_counts.get(entry.source, 0) + 1 + serialized_entries.append( + { + "name": entry.name, + "source": entry.source, + "schema_digest": entry.schema_digest, + } + ) + first_catalog_sha256 = _stable_digest(serialized_entries) + first_schemas_sha256 = _stable_digest(serialized_definitions) + repeated_definitions = manager.list_tools() + repeated_catalog = manager.catalog + repeated_serialized_definitions = [ + { + "name": definition.name, + "input_schema": definition.input_schema, + } + for definition in repeated_definitions + ] + repeated_entries = repeated_catalog.entries if repeated_catalog is not None else () + repeated_serialized_entries = [ + { + "name": entry.name, + "source": entry.source, + "schema_digest": entry.schema_digest, + } + for entry in repeated_entries + ] + repeated_catalog_sha256 = _stable_digest(repeated_serialized_entries) + repeated_schemas_sha256 = _stable_digest(repeated_serialized_definitions) + schema_digests_complete = all( + isinstance(entry.schema_digest, str) and len(entry.schema_digest) == 64 + for entry in entries + ) + repeat_revision_unchanged = bool( + catalog is not None + and repeated_catalog is not None + and repeated_catalog.plugin_revision == catalog.plugin_revision + and repeated_catalog.factory_revision == catalog.factory_revision + ) + repeat_stable = bool( + repeated_catalog is catalog + and len(repeated_definitions) == len(definitions) + and repeated_catalog_sha256 == first_catalog_sha256 + and repeated_schemas_sha256 == first_schemas_sha256 + and repeat_revision_unchanged + ) + return { + "success": bool(definitions) + and catalog is not None + and len(entries) == len(definitions) + and schema_count == len(definitions) + and not collisions + and schema_digests_complete + and repeat_stable, + "tool_count": len(definitions), + "schema_count": schema_count, + "catalog_entry_count": len(entries), + "collision_names": sorted(collisions), + "plugin_revision": catalog.plugin_revision if catalog is not None else None, + "factory_revision": catalog.factory_revision if catalog is not None else None, + "schemas_sha256": first_schemas_sha256, + "catalog_sha256": first_catalog_sha256, + "source_counts": source_counts, + "schema_digests_complete": schema_digests_complete, + "repeat_tool_count": len(repeated_definitions), + "repeat_catalog_same_object": repeated_catalog is catalog, + "repeat_catalog_sha256": repeated_catalog_sha256, + "repeat_schemas_sha256": repeated_schemas_sha256, + "repeat_revision_unchanged": repeat_revision_unchanged, + "repeat_stable": repeat_stable, + } + + +def _activate_agent_scenario( + scenario: str, + *, + app_instance=None, + settings_object=None, + tool_manager=None, + runtime_reader=None, +) -> dict[str, object]: + """执行 Agent 禁用态路由或首次工具目录的进程内场景。""" + if scenario not in _AGENT_SCENARIOS: + raise ValueError(f"场景不支持 Agent 激活:{scenario}") + runtime_reader = runtime_reader or _read_agent_runtime + modules_before = _agent_module_observation() + runtime_before = runtime_reader() + + if scenario == "agent-disabled-router": + action = _probe_router_openapi( + app_instance=app_instance, + settings_object=settings_object, + ) + else: + action = _probe_tool_catalog(manager=tool_manager) + + modules_after = _agent_module_observation() + runtime_after = runtime_reader() + return { + "requested": True, + "action": scenario.removeprefix("agent-"), + "success": bool(action.get("success")), + "modules": {"before": modules_before, "after": modules_after}, + "observations": {"before": runtime_before, "after": runtime_after}, + "router_openapi": action if scenario == "agent-disabled-router" else None, + "tool_catalog": action if scenario == "agent-tool-catalog" else None, + } + + def _read_display_runtime() -> dict[str, object]: """读取 host.display 的只读状态和观测,不触发资源激活。""" try: @@ -338,8 +590,12 @@ def _run_activation() -> None: "started_at": _utc_now(), } try: - result["browser"] = _activate_browser_scenario(_SCENARIO) - result["success"] = bool(result["browser"]["success"]) + if _SCENARIO in _AGENT_SCENARIOS: + result["agent"] = _activate_agent_scenario(_SCENARIO) + result["success"] = bool(result["agent"]["success"]) + else: + result["browser"] = _activate_browser_scenario(_SCENARIO) + result["success"] = bool(result["browser"]["success"]) except Exception as error: # pragma: no cover - 真实集成错误由 marker 保存 result.update( { diff --git a/scripts/perf/moviepilot_docker_ab.py b/scripts/perf/moviepilot_docker_ab.py index 5a2a97829..ee667a708 100644 --- a/scripts/perf/moviepilot_docker_ab.py +++ b/scripts/perf/moviepilot_docker_ab.py @@ -32,7 +32,9 @@ DEFAULT_SUBSTRATE = ( ) DEFAULT_BROWSER_SOURCE_VOLUME = "mp-perf-v3-browser-seed" DEFAULT_SCENARIO = "idle-default" -SCENARIOS = (DEFAULT_SCENARIO, "browser-headless", "browser-headed") +BROWSER_SCENARIOS = ("browser-headless", "browser-headed") +AGENT_SCENARIOS = ("agent-disabled-router", "agent-tool-catalog") +SCENARIOS = (DEFAULT_SCENARIO, *BROWSER_SCENARIOS, *AGENT_SCENARIOS) CAMPAIGN_LABEL = "org.moviepilot.perf.campaign" ROLE_LABEL = "org.moviepilot.perf.role" SOURCE_LABEL = "org.moviepilot.perf.source-commit" @@ -43,6 +45,35 @@ CRITICAL_SUBSTRATE_PATHS = ( "scripts/uv-pip-compat.sh", ) SEED_COMPATIBILITY_PATHS = ("database/versions",) +AGENT_HEAVY_MODULE_PREFIXES = ( + "app.agent.orchestrator", + "app.agent.callback", + "app.agent.llm.helper", + "app.agent.tools.base", + "app.agent.tools.catalog", + "app.agent.tools.factory", + "app.agent.tools.impl", + "langgraph", + "langchain", + "langchain_core", + "openai", + "anthropic", + "google.genai", + "boto3", + "botocore", +) +AGENT_SCHEMA_BASELINE_PREFIXES = ("langchain", "langchain_core") +AGENT_NONMATERIALIZATION_PREFIXES = tuple( + prefix + for prefix in AGENT_HEAVY_MODULE_PREFIXES + if prefix not in AGENT_SCHEMA_BASELINE_PREFIXES +) +AGENT_TOOL_CATALOG_PREFIXES = ( + "app.agent.tools.base", + "app.agent.tools.catalog", + "app.agent.tools.factory", + "app.agent.tools.impl", +) MODULE_PREFIXES = ( "lark_oapi", "slack_bolt", @@ -50,12 +81,10 @@ MODULE_PREFIXES = ( "discord", "plexapi", "telebot", - "langgraph", - "langchain", "app.agent", - "app.agent.orchestrator", "app.agent.tools", "app.modules", + *AGENT_HEAVY_MODULE_PREFIXES, ) BALANCED_RUN_ORDER = ( ("before", 1), @@ -593,6 +622,7 @@ def fixed_environment(args: argparse.Namespace, instrument: bool) -> dict[str, s "MP_PERF_ACTIVATION_TIMEOUT": str( getattr(args, "activation_timeout", 180) ), + "MP_PERF_AGENT_MODULE_PREFIXES": ",".join(AGENT_HEAVY_MODULE_PREFIXES), } ) return environment @@ -1133,7 +1163,7 @@ def capture_activation_snapshot( output_dir: Path, phase: str, ) -> dict[str, Any]: - """采集浏览器激活边界的 Engine、进程和进程内 import 状态。""" + """采集场景动作边界的 Engine、进程和进程内 import 状态。""" engine = capture_engine_stats(container) processes = capture_processes(container) modules = capture_modules(container, output_dir, processes["main_python"]) @@ -1248,17 +1278,195 @@ def evaluate_browser_activation( } -def activate_browser_scenario( +def _agent_prefix_counts(snapshot: dict[str, Any]) -> dict[str, int]: + """从模块快照提取 PERF-003 Agent 重模块哨兵。""" + counts = snapshot["modules"].get("prefix_counts") or {} + return { + prefix: int(counts.get(prefix) or 0) for prefix in AGENT_HEAVY_MODULE_PREFIXES + } + + +def evaluate_agent_activation( + scenario: str, + pre: dict[str, Any], + post: dict[str, Any], + marker: dict[str, Any], + expected_pid: Optional[int] = None, +) -> dict[str, Any]: + """验证禁用态路由与首次工具目录的惰性物化不变量。""" + agent = marker.get("agent") or {} + observations = agent.get("observations") or {} + runtime_before = observations.get("before") or {} + runtime_after = observations.get("after") or {} + prefix_before = _agent_prefix_counts(pre) + prefix_after = _agent_prefix_counts(post) + forbidden_before = { + prefix: prefix_before[prefix] + for prefix in AGENT_NONMATERIALIZATION_PREFIXES + if prefix_before[prefix] + } + pre_xvfb = pre["processes"]["xvfb"] + post_xvfb = post["processes"]["xvfb"] + network_delta = { + "rx_bytes": int(post["engine"]["network_rx_bytes"]) + - int(pre["engine"]["network_rx_bytes"]), + "tx_bytes": int(post["engine"]["network_tx_bytes"]) + - int(pre["engine"]["network_tx_bytes"]), + } + errors: list[str] = [] + + if marker.get("scenario") != scenario: + errors.append("进程内 marker 的场景与采集请求不一致") + if expected_pid is not None and marker.get("pid") != expected_pid: + errors.append("进程内 marker 不是目标 MoviePilot Python 进程写出") + if not marker.get("success") or not agent.get("success"): + errors.append("主 MoviePilot Python 进程未完成 Agent 场景动作") + if forbidden_before: + errors.append("Agent 场景动作前已经加载必须延迟物化的模块") + if pre_xvfb["count"] != 0 or post_xvfb["count"] != 0: + errors.append("Agent 场景不得物化 Xvfb") + if not runtime_before.get("available") or not runtime_after.get("available"): + errors.append("主进程未提供轻量 Agent runtime 只读观测") + if runtime_before.get("tool_factory_materialized") is not False: + errors.append("Agent 场景动作前工具工厂必须未物化") + if any(network_delta.values()): + errors.append("Agent 场景动作产生了容器网络收发") + + revision = {"plugin": None, "factory": None} + action_summary: dict[str, Any] + if scenario == "agent-disabled-router": + router = agent.get("router_openapi") or {} + if router.get("ai_agent_enable") is not False: + errors.append("router/OpenAPI 场景必须运行在 AI_AGENT_ENABLE=false") + if router.get("missing_routes") or router.get("missing_openapi_paths"): + errors.append("禁用态缺少 Agent 相关 router 或 OpenAPI path") + forbidden_after = { + prefix: prefix_after[prefix] + for prefix in AGENT_NONMATERIALIZATION_PREFIXES + if prefix_after[prefix] + } + if forbidden_after: + errors.append("生成完整 OpenAPI 后加载了必须延迟物化的模块") + if runtime_after.get("tool_factory_materialized") is not False: + errors.append("生成完整 OpenAPI 不得物化工具工厂") + action_summary = { + "route_count": router.get("route_count"), + "openapi_path_count": router.get("openapi_path_count"), + "openapi_sha256": router.get("openapi_sha256"), + } + elif scenario == "agent-tool-catalog": + catalog = agent.get("tool_catalog") or {} + if prefix_after["app.agent.tools.factory"] < 1: + errors.append("首次工具目录动作后未加载工具工厂") + if prefix_after["app.agent.tools.impl"] < 1: + errors.append("首次工具目录动作后未加载工具实现") + allowed_prefixes = { + *AGENT_TOOL_CATALOG_PREFIXES, + *AGENT_SCHEMA_BASELINE_PREFIXES, + } + unexpected_prefixes = { + prefix: count + for prefix, count in prefix_after.items() + if prefix not in allowed_prefixes and count + } + if unexpected_prefixes: + errors.append("首次工具目录动作加载了非目录所需的 Agent/provider 重模块") + if runtime_after.get("tool_factory_materialized") is not True: + errors.append("首次工具目录动作后工具工厂未标记为已物化") + if ( + not catalog.get("success") + or not catalog.get("tool_count") + or catalog.get("schema_count") != catalog.get("tool_count") + or catalog.get("catalog_entry_count") != catalog.get("tool_count") + or catalog.get("collision_names") + or not catalog.get("schema_digests_complete") + or not catalog.get("repeat_stable") + or catalog.get("repeat_tool_count") != catalog.get("tool_count") + ): + errors.append("工具目录、JSON Schema 或重复读取稳定性不满足合同") + if catalog.get("plugin_revision") is None or not catalog.get( + "factory_revision" + ): + errors.append("工具目录缺少 plugin/factory revision") + revision = { + "plugin": catalog.get("plugin_revision"), + "factory": catalog.get("factory_revision"), + } + action_summary = { + "tool_count": catalog.get("tool_count"), + "schema_count": catalog.get("schema_count"), + "schemas_sha256": catalog.get("schemas_sha256"), + "collision_names": catalog.get("collision_names") or [], + "repeat_stable": catalog.get("repeat_stable"), + } + else: + errors.append(f"未知 Agent 场景:{scenario}") + action_summary = {} + + return { + "passed": not errors, + "errors": errors, + "expected": ( + "router/OpenAPI 完整且必须延迟物化的模块保持 0" + if scenario == "agent-disabled-router" + else "首次工具目录后仅物化工具域及 Schema 基线" + ), + "observed": { + "pre_xvfb_count": pre_xvfb["count"], + "post_xvfb_count": post_xvfb["count"], + "prefix_before": prefix_before, + "prefix_after": prefix_after, + "network_delta": network_delta, + "tool_factory_materialized_before": runtime_before.get( + "tool_factory_materialized" + ), + "tool_factory_materialized_after": runtime_after.get( + "tool_factory_materialized" + ), + }, + "action": action_summary, + "revision": revision, + } + + +def evaluate_scenario_activation( + scenario: str, + pre: dict[str, Any], + post: dict[str, Any], + marker: dict[str, Any], + expected_pid: Optional[int] = None, +) -> dict[str, Any]: + """按场景族分派外部采样验收。""" + if scenario in BROWSER_SCENARIOS: + return evaluate_browser_activation( + scenario, + pre, + post, + marker, + expected_pid=expected_pid, + ) + if scenario in AGENT_SCENARIOS: + return evaluate_agent_activation( + scenario, + pre, + post, + marker, + expected_pid=expected_pid, + ) + raise HarnessError(f"未知激活场景:{scenario}") + + +def activate_sample_scenario( container, output_dir: Path, scenario: str, timeout: float, ) -> dict[str, Any]: - """通过 SIGUSR2 让目标 MoviePilot 解释器执行场景激活并回收 marker。""" + """通过 SIGUSR2 让目标 MoviePilot 解释器执行动作并回收 marker。""" pre = capture_activation_snapshot(container, output_dir, "pre-activation") main_python = pre["processes"]["main_python"] if not main_python: - raise HarnessError("未找到主 Python 进程,无法触发浏览器场景") + raise HarnessError("未找到主 Python 进程,无法触发测量场景") marker_path = output_dir / "modules" / f"activation-{main_python['pid']}.json" marker_path.unlink(missing_ok=True) @@ -1274,12 +1482,12 @@ def activate_browser_scenario( raise HarnessError("等待场景激活 marker 时容器提前退出") time.sleep(0.05) if not marker_path.exists(): - raise HarnessError(f"浏览器场景激活在 {timeout:.0f}s 内未完成") + raise HarnessError(f"测量场景动作在 {timeout:.0f}s 内未完成") marker_received_at = time.monotonic() marker = json.loads(marker_path.read_text(encoding="utf-8")) post = capture_activation_snapshot(container, output_dir, "post-activation") - validation = evaluate_browser_activation( + validation = evaluate_scenario_activation( scenario, pre, post, @@ -1304,7 +1512,7 @@ def command_sample(args: argparse.Namespace) -> dict[str, Any]: """执行一个隔离样本并在约定时间点采集完整指标。""" scenario = getattr(args, "scenario", DEFAULT_SCENARIO) if scenario != DEFAULT_SCENARIO and args.variant != "after": - raise HarnessError("浏览器激活场景只用于验证包含 app.sdk.browser 的 After 候选") + raise HarnessError("非默认场景只用于验证包含候选公共 API 的 After 版本") client = require_docker_client() build = load_build_manifest(args) config_seed, browser_seed = require_seed_volumes(client, args) @@ -1391,7 +1599,7 @@ def command_sample(args: argparse.Namespace) -> dict[str, Any]: measurement_origin_at = settled_at if scenario != DEFAULT_SCENARIO: - activation = activate_browser_scenario( + activation = activate_sample_scenario( container, output_dir, scenario, @@ -1614,8 +1822,13 @@ def build_markdown_report( ) lines.append("| " + " | ".join(row) + " |") - activated_samples = [sample for sample in samples if sample.get("activation")] - if activated_samples: + browser_activated_samples = [ + sample + for sample in samples + if sample.get("activation") + and sample.get("scenario", DEFAULT_SCENARIO) in BROWSER_SCENARIOS + ] + if browser_activated_samples: activation_headers = [ "场景", "版本", @@ -1644,7 +1857,7 @@ def build_markdown_report( ] ) for sample in sorted( - activated_samples, + browser_activated_samples, key=lambda item: ( item.get("scenario", DEFAULT_SCENARIO), variant_order.get(item["variant"], 99), @@ -1692,6 +1905,144 @@ def build_markdown_report( ] lines.append("| " + " | ".join(activation_row) + " |") + agent_activated_samples = [ + sample + for sample in samples + if sample.get("activation") + and sample.get("scenario", DEFAULT_SCENARIO) in AGENT_SCENARIOS + ] + if agent_activated_samples: + agent_headers = [ + "场景", + "样本", + "动作(s)", + "Pre/Post WS(MiB)", + "Pre/Post Python PSS(MiB)", + "Pre/Post sys.modules", + "Factory observation", + "模块哨兵 Pre", + "模块哨兵 Post", + "Router/OpenAPI 或 Tools/Schemas", + "Plugin/Factory revision", + "Action RX/TX Δ(KiB)", + "验收", + ] + lines.extend( + [ + "", + "## Agent 场景动作", + "", + "| " + " | ".join(agent_headers) + " |", + "| " + " | ".join(["---"] * len(agent_headers)) + " |", + ] + ) + + def format_prefix_counts(counts: dict[str, int]) -> str: + """仅展开已加载前缀,全部未加载时输出明确零状态。""" + loaded = [f"{prefix}={count}" for prefix, count in counts.items() if count] + return ", ".join(loaded) if loaded else "全部 0" + + for sample in sorted( + agent_activated_samples, + key=lambda item: ( + item.get("scenario", DEFAULT_SCENARIO), + item["sample_index"], + ), + ): + activation = sample["activation"] + pre = activation["pre"] + post = activation["post"] + validation = activation["validation"] + observed = validation["observed"] + action = validation["action"] + revision = validation["revision"] + pre_python = pre["processes"].get("main_python") or {} + post_python = post["processes"].get("main_python") or {} + if sample.get("scenario") == "agent-disabled-router": + action_result = ( + f"{action.get('route_count')}/{action.get('openapi_path_count')}" + ) + else: + action_result = ( + f"{action.get('tool_count')}/{action.get('schema_count')}; " + f"repeat={'Y' if action.get('repeat_stable') else 'N'}; " + f"collision={len(action.get('collision_names') or [])}" + ) + factory_revision = str(revision.get("factory") or "") + revision_result = ( + f"{revision.get('plugin')}/{factory_revision[:12]}" + if factory_revision + else "不适用" + ) + agent_row = [ + sample.get("scenario", DEFAULT_SCENARIO), + str(sample["sample_index"]), + f"{float(activation.get('worker_elapsed_seconds') or 0):.2f}", + f"{format_mib(pre['engine']['working_set_bytes'])}/" + f"{format_mib(post['engine']['working_set_bytes'])}", + f"{format_kib_as_mib(pre_python.get('pss_kib'))}/" + f"{format_kib_as_mib(post_python.get('pss_kib'))}", + f"{pre['modules'].get('count')}/{post['modules'].get('count')}", + ( + f"{observed.get('tool_factory_materialized_before')}→" + f"{observed.get('tool_factory_materialized_after')}" + ), + format_prefix_counts(observed.get("prefix_before") or {}), + format_prefix_counts(observed.get("prefix_after") or {}), + action_result, + revision_result, + ( + f"{format_bytes_as_kib(post['engine']['network_rx_bytes'] - pre['engine']['network_rx_bytes'])}/" + f"{format_bytes_as_kib(post['engine']['network_tx_bytes'] - pre['engine']['network_tx_bytes'])}" + ), + "通过" if validation["passed"] else "失败", + ] + lines.append("| " + " | ".join(agent_row) + " |") + + sentinel_samples = [sample for sample in samples if sample.get("measurements")] + if sentinel_samples: + lines.extend( + [ + "", + "## Agent 模块哨兵", + "", + "每行记录该样本所有定时采样点的最大模块数;精确时间点数据保留在 JSON。", + "`langchain` 与 `langchain_core` 只记录 Schema 基线,不参与归零门禁。", + "", + "| 场景 | 版本 | 样本 | 重模块峰值 |", + "| --- | --- | --- | --- |", + ] + ) + for sample in sorted( + sentinel_samples, + key=lambda item: ( + item.get("scenario", DEFAULT_SCENARIO), + variant_order.get(item["variant"], 99), + item["sample_index"], + ), + ): + peaks = { + prefix: max( + int( + measurement.get("modules", {}) + .get("prefix_counts", {}) + .get(prefix, 0) + ) + for measurement in sample["measurements"] + ) + for prefix in AGENT_HEAVY_MODULE_PREFIXES + } + peak_text = ( + ", ".join( + f"{prefix}={count}" for prefix, count in peaks.items() if count + ) + or "全部 0" + ) + lines.append( + f"| {sample.get('scenario', DEFAULT_SCENARIO)} | " + f"{sample['variant']} | {sample['sample_index']} | {peak_text} |" + ) + lines.extend(["", "## 中位数对照", ""]) for scenario in scenarios: scenario_samples = [ diff --git a/scripts/perf/test_scenarios.py b/scripts/perf/test_scenarios.py index 013952e2f..f111bcc40 100644 --- a/scripts/perf/test_scenarios.py +++ b/scripts/perf/test_scenarios.py @@ -36,6 +36,29 @@ def snapshot(xvfb_count: int, xvfb_pss_kib: int = 0) -> dict: } +def agent_snapshot(prefix_counts: dict[str, int], xvfb_count: int = 0) -> dict: + """构造包含 Agent 模块哨兵的场景边界快照。""" + return { + "engine": { + "working_set_bytes": 500 * 1024 * 1024, + "network_rx_bytes": 1024, + "network_tx_bytes": 512, + }, + "processes": { + "main_python": { + "pss_kib": 400 * 1024, + "uss_kib": 390 * 1024, + "threads": 8, + }, + "xvfb": {"count": xvfb_count, "pss_kib": 0}, + }, + "modules": { + "count": 3000, + "prefix_counts": prefix_counts, + }, + } + + def managed_resource(before_generation: int, after_generation: int) -> dict: """构造 host.display single-flight 观测。""" observations = [] @@ -141,12 +164,54 @@ def test_browser_scenario_uses_isolated_resource_and_result_names( ) -def test_browser_scenario_rejects_before_without_touching_docker() -> None: - """旧基线不具备 SDK/display 冷启动不变量,非默认场景只接受 After。""" +def test_agent_scenarios_are_explicit_and_keep_idle_prefix_contract() -> None: + """PERF-003 暴露完整哨兵,并把 Schema 基线排除在归零门禁外。""" + harness = load_module( + "moviepilot_perf_agent_cli", + PERF_DIR / "moviepilot_docker_ab.py", + ) + expected_prefixes = { + "app.agent.orchestrator", + "app.agent.callback", + "app.agent.llm.helper", + "app.agent.tools.base", + "app.agent.tools.catalog", + "app.agent.tools.factory", + "app.agent.tools.impl", + "langgraph", + "langchain", + "langchain_core", + "openai", + "anthropic", + "google.genai", + "boto3", + "botocore", + } + + assert set(harness.AGENT_SCENARIOS) == { + "agent-disabled-router", + "agent-tool-catalog", + } + assert set(harness.AGENT_HEAVY_MODULE_PREFIXES) == expected_prefixes + assert expected_prefixes.issubset(harness.MODULE_PREFIXES) + assert set(harness.AGENT_SCHEMA_BASELINE_PREFIXES) == { + "langchain", + "langchain_core", + } + assert not set(harness.AGENT_SCHEMA_BASELINE_PREFIXES).intersection( + harness.AGENT_NONMATERIALIZATION_PREFIXES + ) + + +@pytest.mark.parametrize("scenario", ["browser-headless", "agent-disabled-router"]) +def test_non_default_scenario_rejects_before_without_touching_docker( + scenario: str, +) -> None: + """旧基线不具备候选公共合同,所有非默认场景只接受 After。""" harness = load_module( "moviepilot_perf_after_only", PERF_DIR / "moviepilot_docker_ab.py" ) - args = argparse.Namespace(scenario="browser-headless", variant="before") + args = argparse.Namespace(scenario=scenario, variant="before") with pytest.raises(harness.HarnessError, match="After"): harness.command_sample(args) @@ -222,6 +287,132 @@ def test_activation_validation_enforces_headless_and_headed_invariants() -> None assert invalid["single_flight"]["passed"] is False +def test_agent_activation_validation_enforces_lazy_boundaries() -> None: + """禁用态延迟重 Agent 域,首次目录只允许工具域物化。""" + harness = load_module( + "moviepilot_perf_agent_validation", + PERF_DIR / "moviepilot_docker_ab.py", + ) + zero = {prefix: 0 for prefix in harness.AGENT_HEAVY_MODULE_PREFIXES} + catalog_loaded = dict(zero) + catalog_loaded["app.agent.tools.base"] = 1 + catalog_loaded["app.agent.tools.catalog"] = 1 + catalog_loaded["app.agent.tools.factory"] = 1 + catalog_loaded["app.agent.tools.impl"] = 82 + schema_baseline = dict(zero) + schema_baseline["langchain_core"] = 5 + router_marker = { + "scenario": "agent-disabled-router", + "pid": 42, + "success": True, + "agent": { + "success": True, + "observations": { + "before": { + "available": True, + "tool_factory_materialized": False, + }, + "after": { + "available": True, + "tool_factory_materialized": False, + }, + }, + "router_openapi": { + "success": True, + "ai_agent_enable": False, + "missing_routes": [], + "missing_openapi_paths": [], + "route_count": 200, + "openapi_path_count": 180, + "openapi_sha256": "schema", + }, + }, + } + catalog_marker = { + "scenario": "agent-tool-catalog", + "pid": 42, + "success": True, + "agent": { + "success": True, + "observations": { + "before": { + "available": True, + "tool_factory_materialized": False, + }, + "after": { + "available": True, + "tool_factory_materialized": True, + }, + }, + "tool_catalog": { + "success": True, + "tool_count": 82, + "schema_count": 82, + "catalog_entry_count": 82, + "collision_names": [], + "plugin_revision": 0, + "factory_revision": "factory-revision", + "schemas_sha256": "schemas", + "schema_digests_complete": True, + "repeat_tool_count": 82, + "repeat_stable": True, + }, + }, + } + + router = harness.evaluate_agent_activation( + "agent-disabled-router", + agent_snapshot(schema_baseline), + agent_snapshot(schema_baseline), + router_marker, + expected_pid=42, + ) + catalog = harness.evaluate_agent_activation( + "agent-tool-catalog", + agent_snapshot(zero), + agent_snapshot(catalog_loaded), + catalog_marker, + expected_pid=42, + ) + invalid_loaded = dict(catalog_loaded) + invalid_loaded["app.agent.orchestrator"] = 1 + invalid = harness.evaluate_agent_activation( + "agent-tool-catalog", + agent_snapshot(zero), + agent_snapshot(invalid_loaded), + catalog_marker, + expected_pid=42, + ) + callback_loaded = dict(catalog_loaded) + callback_loaded["app.agent.callback"] = 1 + invalid_callback = harness.evaluate_agent_activation( + "agent-tool-catalog", + agent_snapshot(zero), + agent_snapshot(callback_loaded), + catalog_marker, + expected_pid=42, + ) + network_post = agent_snapshot(catalog_loaded) + network_post["engine"]["network_tx_bytes"] += 1 + invalid_network = harness.evaluate_agent_activation( + "agent-tool-catalog", + agent_snapshot(zero), + network_post, + catalog_marker, + expected_pid=42, + ) + + assert router["passed"] is True + assert router["action"]["openapi_path_count"] == 180 + assert catalog["passed"] is True + assert catalog["revision"]["factory"] == "factory-revision" + assert invalid["passed"] is False + assert any("非目录" in error for error in invalid["errors"]) + assert invalid_callback["passed"] is False + assert invalid_network["passed"] is False + assert any("网络" in error for error in invalid_network["errors"]) + + def test_sitecustomize_acquires_headed_display_concurrently_in_same_process() -> None: """headed probe 并发走公开 SDK 冷启动,并只保留一个上下文。""" probe = load_module( @@ -283,6 +474,161 @@ def test_sitecustomize_headless_uses_one_headless_context() -> None: assert result["single_flight_probe"]["requested"] is False +def test_sitecustomize_router_probe_generates_complete_openapi_without_http() -> None: + """禁用态探针直接读取主进程 app,不发起 HTTP 或外部请求。""" + probe = load_module( + "moviepilot_perf_sitecustomize_router", + PERF_DIR / "instrument" / "sitecustomize.py", + ) + required_paths = [ + "/api/v1/message/agent/stream", + "/api/v1/message/agent/sessions", + "/api/v1/openai/v1/chat/completions", + "/api/v1/openai/v1/responses", + "/api/v1/anthropic/v1/messages", + "/api/v1/llm/manage", + "/api/v1/mcp", + "/api/v1/mcp/tools", + ] + + class FakeApp: + """只实现 Router/OpenAPI 探针使用的 FastAPI 合同。""" + + routes = [SimpleNamespace(path=path) for path in required_paths] + + @staticmethod + def openapi() -> dict: + return { + "info": {"title": "MoviePilot", "version": "v3"}, + "paths": {path: {"get": {}} for path in required_paths}, + } + + result = probe._probe_router_openapi( + app_instance=FakeApp(), + settings_object=SimpleNamespace(AI_AGENT_ENABLE=False), + ) + + assert result["success"] is True + assert result["route_count"] == len(required_paths) + assert result["openapi_path_count"] == len(required_paths) + assert result["missing_routes"] == [] + assert result["missing_openapi_paths"] == [] + + +def test_sitecustomize_tool_catalog_probe_records_schema_and_revisions() -> None: + """首次目录探针保留工具数、Schema 摘要和双 revision。""" + probe = load_module( + "moviepilot_perf_sitecustomize_catalog", + PERF_DIR / "instrument" / "sitecustomize.py", + ) + definitions = [ + SimpleNamespace( + name="query_media", + input_schema={"type": "object", "properties": {}}, + ), + SimpleNamespace( + name="add_subscribe", + input_schema={"type": "object", "properties": {"title": {}}}, + ), + ] + catalog = SimpleNamespace( + entries=( + SimpleNamespace( + name="query_media", + source="builtin", + schema_digest="a" * 64, + ), + SimpleNamespace( + name="add_subscribe", + source="builtin", + schema_digest="b" * 64, + ), + ), + collisions={}, + plugin_revision=7, + factory_revision="factory-revision", + ) + + class FakeManager: + """按真实管理器合同在 list_tools 后发布 catalog。""" + + def __init__(self) -> None: + self.catalog = None + + def list_tools(self): + self.catalog = catalog + return definitions + + result = probe._probe_tool_catalog(manager=FakeManager()) + + assert result["success"] is True + assert result["tool_count"] == 2 + assert result["schema_count"] == 2 + assert result["plugin_revision"] == 7 + assert result["factory_revision"] == "factory-revision" + assert len(result["schemas_sha256"]) == 64 + assert len(result["catalog_sha256"]) == 64 + assert result["source_counts"] == {"builtin": 2} + assert result["schema_digests_complete"] is True + assert result["repeat_catalog_same_object"] is True + assert result["repeat_revision_unchanged"] is True + assert result["repeat_stable"] is True + + +def test_sitecustomize_agent_scenario_records_before_and_after_observations( + monkeypatch, +) -> None: + """Agent 场景在同一目标解释器内记录模块与 materialization 边界。""" + probe = load_module( + "moviepilot_perf_sitecustomize_agent", + PERF_DIR / "instrument" / "sitecustomize.py", + ) + module_observations = iter( + [ + {"total_modules": 100, "prefix_counts": {}, "matching_modules": []}, + { + "total_modules": 190, + "prefix_counts": { + "app.agent.tools.factory": 1, + "app.agent.tools.impl": 82, + }, + "matching_modules": ["app.agent.tools.factory"], + }, + ] + ) + runtime_observations = iter( + [ + {"available": True, "tool_factory_materialized": False}, + {"available": True, "tool_factory_materialized": True}, + ] + ) + monkeypatch.setattr( + probe, + "_agent_module_observation", + lambda: next(module_observations), + ) + monkeypatch.setattr( + probe, + "_probe_tool_catalog", + lambda manager=None: { + "success": True, + "tool_count": 82, + "schema_count": 82, + }, + ) + + result = probe._activate_agent_scenario( + "agent-tool-catalog", + runtime_reader=lambda: next(runtime_observations), + ) + + assert result["success"] is True + assert result["observations"]["before"]["tool_factory_materialized"] is False + assert result["observations"]["after"]["tool_factory_materialized"] is True + assert result["modules"]["before"]["total_modules"] == 100 + assert result["modules"]["after"]["total_modules"] == 190 + + def test_sitecustomize_serializes_managed_resource_facade(monkeypatch) -> None: """进程探针按公开只读 facade 记录 generation 与 activate observation。""" observation = SimpleNamespace( @@ -357,6 +703,28 @@ def test_sitecustomize_signal_worker_publishes_atomic_marker(tmp_path: Path) -> assert not list(tmp_path.glob("*.tmp")) +def test_sitecustomize_signal_worker_dispatches_agent_scenario(tmp_path: Path) -> None: + """SIGUSR2 worker 对 Agent 场景也在当前 PID 发布完整 marker。""" + probe = load_module( + "moviepilot_perf_sitecustomize_agent_marker", + PERF_DIR / "instrument" / "sitecustomize.py", + ) + probe._OUTPUT_DIR = str(tmp_path) + probe._SCENARIO = "agent-disabled-router" + probe._activate_agent_scenario = lambda scenario: { + "success": scenario == "agent-disabled-router" + } + + probe._run_activation() + + marker_path = tmp_path / f"activation-{os.getpid()}.json" + payload = json.loads(marker_path.read_text(encoding="utf-8")) + assert payload["pid"] == os.getpid() + assert payload["scenario"] == "agent-disabled-router" + assert payload["agent"]["success"] is True + assert "browser" not in payload + + def test_markdown_reports_activation_and_keeps_scenario_medians_separate() -> None: """非默认场景报告包含激活证据,并按场景隔离中位数。""" harness = load_module( @@ -428,3 +796,73 @@ def test_markdown_reports_activation_and_keeps_scenario_medians_separate() -> No assert "Single-flight" in report assert "### `browser-headed`" in report assert "1.25" in report + + +def test_markdown_reports_agent_observation_revision_and_sentinel() -> None: + """Agent 场景报告展示物化边界、revision 与定时哨兵峰值。""" + harness = load_module( + "moviepilot_perf_agent_report", + PERF_DIR / "moviepilot_docker_ab.py", + ) + zero = {prefix: 0 for prefix in harness.AGENT_HEAVY_MODULE_PREFIXES} + loaded = dict(zero) + loaded["app.agent.tools.factory"] = 1 + loaded["app.agent.tools.impl"] = 82 + pre = agent_snapshot(zero) + post = agent_snapshot(loaded) + activation = { + "worker_elapsed_seconds": 2.5, + "pre": pre, + "post": post, + "marker": {"success": True}, + "validation": { + "passed": True, + "observed": { + "prefix_before": zero, + "prefix_after": loaded, + "tool_factory_materialized_before": False, + "tool_factory_materialized_after": True, + }, + "action": { + "tool_count": 82, + "schema_count": 82, + "repeat_stable": True, + "collision_names": [], + }, + "revision": {"plugin": 7, "factory": "1234567890abcdef"}, + }, + } + sample = { + "scenario": "agent-tool-catalog", + "variant": "after", + "sample_index": 1, + "http_ready_seconds": 7.0, + "activation": activation, + "measurements": [ + { + "target_minute": 1.0, + "engine": post["engine"], + "processes": post["processes"], + "modules": { + "count": 3082, + "prefix_counts": loaded, + }, + } + ], + } + build = { + "campaign": "fake", + "platform": "linux/arm64", + "before_commit": "before", + "after_commit": "after", + "substrate": {"reference": "frozen"}, + } + + report = harness.build_markdown_report(build, None, [sample]) + + assert "## Agent 场景动作" in report + assert "False→True" in report + assert "82/82; repeat=Y; collision=0" in report + assert "7/1234567890ab" in report + assert "## Agent 模块哨兵" in report + assert "app.agent.tools.impl=82" in report diff --git a/tests/test_agent_api_lazy_imports.py b/tests/test_agent_api_lazy_imports.py new file mode 100644 index 000000000..c3f5eb23e --- /dev/null +++ b/tests/test_agent_api_lazy_imports.py @@ -0,0 +1,460 @@ +"""Agent API 路由与禁用响应的延迟加载合同。""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +from pathlib import Path + + +def _run_isolated(script: str, config_dir: Path) -> dict: + """在隔离解释器中执行路由探针,并返回末行 JSON 结果。""" + env = os.environ.copy() + env.update( + { + "AI_AGENT_ENABLE": "false", + "API_TOKEN": "test-agent-api-token-1234", + "CONFIG_DIR": str(config_dir), + "PYTHONDONTWRITEBYTECODE": "1", + } + ) + completed = subprocess.run( + [sys.executable, "-c", script], + check=True, + capture_output=True, + text=True, + env=env, + ) + lines = [line for line in completed.stdout.splitlines() if line.strip()] + return json.loads(lines[-1]) + + +def test_full_api_openapi_keeps_agent_runtime_cold(tmp_path: Path) -> None: + """完整路由与 OpenAPI 注册不得物化 Agent、工具或模型运行时。""" + result = _run_isolated( + r''' +import json +import socket +import sys +import types + +network_attempts = [] + +def block_network(*args, **kwargs): + network_attempts.append(repr(args[:2])) + raise AssertionError("router import attempted network access") + +socket.create_connection = block_network +socket.getaddrinfo = block_network +socket.socket.connect = block_network + +sites = types.ModuleType("app.application.site.sites") +sites.SitesHelper = type("SitesHelper", (), {}) +sites.__file__ = "" +sys.modules["app.application.site.sites"] = sites + +from fastapi import FastAPI +from app.startup.routers_initializer import init_routers + +app = FastAPI() +init_routers(app) +paths = set(app.openapi()["paths"]) +required_paths = { + "/api/v1/message/agent/stream", + "/api/v1/message/agent/sessions", + "/api/v1/openai/v1/chat/completions", + "/api/v1/openai/v1/responses", + "/api/v1/anthropic/v1/messages", + "/api/v1/llm/manage", + "/api/v1/mcp", + "/api/v1/mcp/tools", +} +forbidden = ( + "app.agent.callback", + "app.agent.llm.helper", + "app.agent.orchestrator", + "app.agent.tools.base", + "app.agent.tools.catalog", + "app.agent.tools.factory", + "app.agent.tools.impl", + "langgraph", +) +loaded = sorted( + name + for name in sys.modules + if any(name == prefix or name.startswith(prefix + ".") for prefix in forbidden) +) +print(json.dumps({ + "loaded": loaded, + "missing_paths": sorted(required_paths - paths), + "network_attempts": network_attempts, +})) +''', + tmp_path / "router-import", + ) + + assert result == { + "loaded": [], + "missing_paths": [], + "network_attempts": [], + } + + +def test_disabled_protocol_requests_preserve_503_without_runtime_load( + tmp_path: Path, +) -> None: + """禁用态兼容协议保持 503,并且不会因构造响应加载 Agent。""" + result = _run_isolated( + r''' +import asyncio +import json +import socket +import sys +import types +from types import SimpleNamespace + +network_attempts = [] + +def block_network(*args, **kwargs): + network_attempts.append(repr(args[:2])) + raise AssertionError("disabled request attempted network access") + +socket.create_connection = block_network +socket.getaddrinfo = block_network +socket.socket.connect = block_network + +sites = types.ModuleType("app.application.site.sites") +sites.SitesHelper = type("SitesHelper", (), {}) +sites.__file__ = "" +sys.modules["app.application.site.sites"] = sites + +from fastapi.security import HTTPAuthorizationCredentials +from app import schemas +from app.api.endpoints.anthropic import messages as anthropic_messages +from app.api.endpoints.openai import chat_completions, responses +from app.runtime.config import settings + +credentials = HTTPAuthorizationCredentials( + scheme="Bearer", + credentials=settings.API_TOKEN, +) +request = SimpleNamespace(headers={}) + +async def run_requests(): + chat_response = await chat_completions( + payload=schemas.OpenAIChatCompletionsRequest( + messages=[schemas.OpenAIChatMessage(role="user", content="hello")] + ), + request=request, + credentials=credentials, + ) + responses_response = await responses( + payload=schemas.OpenAIResponsesRequest(input="hello"), + credentials=credentials, + ) + anthropic_response = await anthropic_messages( + payload=schemas.AnthropicMessagesRequest( + messages=[schemas.AnthropicMessage(role="user", content="hello")] + ), + x_api_key=settings.API_TOKEN, + ) + return chat_response, responses_response, anthropic_response + +protocol_responses = asyncio.run(run_requests()) +forbidden = ( + "app.agent.callback", + "app.agent.llm.helper", + "app.agent.orchestrator", + "app.agent.tools.base", + "app.agent.tools.catalog", + "app.agent.tools.factory", + "app.agent.tools.impl", + "langgraph", +) +loaded = sorted( + name + for name in sys.modules + if any(name == prefix or name.startswith(prefix + ".") for prefix in forbidden) +) +print(json.dumps({ + "loaded": loaded, + "network_attempts": network_attempts, + "status_codes": [response.status_code for response in protocol_responses], + "bodies": [json.loads(response.body) for response in protocol_responses], +}, ensure_ascii=False)) +''', + tmp_path / "disabled-requests", + ) + + assert result["loaded"] == [] + assert result["network_attempts"] == [] + assert result["status_codes"] == [503, 503, 503] + assert result["bodies"][0]["error"]["code"] == "ai_agent_disabled" + assert result["bodies"][1]["error"]["code"] == "ai_agent_disabled" + assert result["bodies"][2]["error"]["type"] == "api_error" + + +def test_runtime_agent_type_factories_are_single_flight(tmp_path: Path) -> None: + """并发首次解析必须返回同一 class,避免会话复用误判构造器已变化。""" + result = _run_isolated( + r''' +import json +import sys +import threading +import time +import types + +sites = types.ModuleType("app.application.site.sites") +sites.SitesHelper = type("SitesHelper", (), {}) +sites.__file__ = "" +sys.modules["app.application.site.sites"] = sites + +from app.api.endpoints import agent, openai + +def exercise(module, factory_name, getter_name): + calls = [] + call_lock = threading.Lock() + start = threading.Barrier(8) + + class RuntimeAgent: + pass + + def get_runtime_type(): + with call_lock: + calls.append(1) + time.sleep(0.02) + return RuntimeAgent + + setattr(module, getter_name, get_runtime_type) + factory = getattr(module, factory_name) + results = [] + + def resolve(): + start.wait() + results.append(factory()) + + threads = [threading.Thread(target=resolve) for _ in range(8)] + for thread in threads: + thread.start() + for thread in threads: + thread.join() + return len(calls), all(result is results[0] for result in results) + +web_calls, web_identity = exercise( + agent, + "_get_web_agent_type", + "get_moviepilot_agent_type", +) +collecting_calls, collecting_identity = exercise( + openai, + "_get_collecting_agent_type", + "get_moviepilot_agent_type", +) +print(json.dumps({ + "web_calls": web_calls, + "web_identity": web_identity, + "collecting_calls": collecting_calls, + "collecting_identity": collecting_identity, +})) +''', + tmp_path / "agent-type-single-flight", + ) + + assert result == { + "web_calls": 1, + "web_identity": True, + "collecting_calls": 1, + "collecting_identity": True, + } + + +def test_persistent_protocol_agent_rebinds_stream_queue_without_stale_output( + tmp_path: Path, +) -> None: + """稳定协议会话复用 Agent 时必须保留 handler identity 并切换请求队列。""" + result = _run_isolated( + r''' +import asyncio +import json +import sys +import types + +sites = types.ModuleType("app.application.site.sites") +sites.SitesHelper = type("SitesHelper", (), {}) +sites.__file__ = "" +sys.modules["app.application.site.sites"] = sites + +from app.api.endpoints import openai + +class RuntimeAgent: + def __init__(self, **kwargs): + self.__dict__.update(kwargs) + self.stream_handler = object() + self._compiled_agent_bundle = object() + +class TestStreamingHandler(openai._OpenAIStreamingHandlerMixin): + pass + +openai._get_openai_streaming_handler_type = lambda: TestStreamingHandler +agent_type = openai._build_collecting_agent_type(RuntimeAgent) +agent = agent_type(session_id="stable", user_id="api") +first_queue = asyncio.Queue() +second_queue = asyncio.Queue() + +agent.configure_protocol_request(stream_mode=True, event_queue=first_queue) +handler = agent.stream_handler +handler._event_queue.put_nowait("first") +compiled_bundle = object() +agent._compiled_agent_bundle = compiled_bundle + +agent.configure_protocol_request(stream_mode=True, event_queue=second_queue) +agent.release_protocol_request(first_queue) +handler._event_queue.put_nowait("second") +agent.release_protocol_request(second_queue) + +print(json.dumps({ + "same_handler": agent.stream_handler is handler, + "same_bundle": agent._compiled_agent_bundle is compiled_bundle, + "first": first_queue.get_nowait(), + "first_empty": first_queue.empty(), + "second": second_queue.get_nowait(), + "second_empty": second_queue.empty(), + "released": handler._event_queue is None, +})) +''', + tmp_path / "protocol-stream-rebind", + ) + + assert result == { + "same_handler": True, + "same_bundle": True, + "first": "first", + "first_empty": True, + "second": "second", + "second_empty": True, + "released": True, + } + + +def test_protocol_routes_follow_agent_service_lifecycle(tmp_path: Path) -> None: + """服务未运行时返回 503,运行态仍执行原有兼容协议响应流程。""" + result = _run_isolated( + r''' +import asyncio +import json +import socket +import sys +import types +from types import SimpleNamespace + +network_attempts = [] + +def block_network(*args, **kwargs): + network_attempts.append(repr(args[:2])) + raise AssertionError("protocol lifecycle test attempted network access") + +socket.create_connection = block_network +socket.getaddrinfo = block_network +socket.socket.connect = block_network + +sites = types.ModuleType("app.application.site.sites") +sites.SitesHelper = type("SitesHelper", (), {}) +sites.__file__ = "" +sys.modules["app.application.site.sites"] = sites + +from fastapi.security import HTTPAuthorizationCredentials +from app import schemas +from app.api.endpoints import anthropic, openai +from app.runtime.config import settings + +settings.AI_AGENT_ENABLE = True +credentials = HTTPAuthorizationCredentials( + scheme="Bearer", + credentials=settings.API_TOKEN, +) +request = SimpleNamespace(headers={}) +chat_payload = schemas.OpenAIChatCompletionsRequest( + messages=[schemas.OpenAIChatMessage(role="user", content="hello")] +) +anthropic_payload = schemas.AnthropicMessagesRequest( + messages=[schemas.AnthropicMessage(role="user", content="hello")] +) + +async def run_unavailable(): + return ( + await openai.chat_completions(chat_payload, request, credentials), + await anthropic.messages( + anthropic_payload, + x_api_key=settings.API_TOKEN, + ), + ) + +unavailable = asyncio.run(run_unavailable()) + +class RuntimeAgent: + def __init__(self, **kwargs): + self.__dict__.update(kwargs) + self.stream_handler = object() + self._compiled_agent_bundle = None + + async def process(self, _prompt, **_kwargs): + return "runtime reply" + +class RunningManager: + async def process_message(self, **kwargs): + agent = kwargs["agent_factory"]( + session_id=kwargs["session_id"], + user_id=kwargs["user_id"], + channel=kwargs["channel"], + source=kwargs["source"], + username=kwargs["username"], + ) + kwargs["agent_setup"](agent) + return await agent.process( + kwargs["message"], + images=kwargs["images"], + files=kwargs["files"], + ) + + async def clear_session(self, **_kwargs): + return None + +running_manager = RunningManager() +openai.get_running_agent_manager = lambda: running_manager +anthropic.get_running_agent_manager = lambda: running_manager +openai.get_moviepilot_agent_type = lambda: RuntimeAgent + +async def run_available(): + return ( + await openai.chat_completions(chat_payload, request, credentials), + await anthropic.messages( + anthropic_payload, + x_api_key=settings.API_TOKEN, + ), + ) + +available = asyncio.run(run_available()) +openai_body = json.loads(available[0].body) +print(json.dumps({ + "unavailable_status": [response.status_code for response in unavailable], + "unavailable_codes": [ + json.loads(unavailable[0].body)["error"]["code"], + json.loads(unavailable[1].body)["error"]["type"], + ], + "available_openai": openai_body["choices"][0]["message"]["content"], + "available_anthropic": available[1].content[0].text, + "network_attempts": network_attempts, +}, ensure_ascii=False)) +''', + tmp_path / "protocol-service-lifecycle", + ) + + assert result == { + "unavailable_status": [503, 503], + "unavailable_codes": ["ai_agent_unavailable", "api_error"], + "available_openai": "runtime reply", + "available_anthropic": "runtime reply", + "network_attempts": [], + } diff --git a/tests/test_agent_background_output.py b/tests/test_agent_background_output.py index 736a55225..bed906a57 100644 --- a/tests/test_agent_background_output.py +++ b/tests/test_agent_background_output.py @@ -407,7 +407,7 @@ class AgentBackgroundOutputTest(unittest.IsolatedAsyncioTestCase): patch("app.agent.orchestrator.prompt_manager.get_agent_prompt", return_value="PROMPT"), patch("app.agent.orchestrator.create_subagent_middlewares", return_value=([], [])), patch( - "app.agent.orchestrator.MoviePilotToolFactory.get_tool_selector_always_include_names", + "app.agent.tools.factory.MoviePilotToolFactory.get_tool_selector_always_include_names", return_value=[], ), patch( @@ -462,7 +462,7 @@ class AgentBackgroundOutputTest(unittest.IsolatedAsyncioTestCase): patch("app.agent.orchestrator.prompt_manager.get_agent_prompt", return_value="PROMPT"), patch("app.agent.orchestrator.create_subagent_middlewares", return_value=([], [])), patch( - "app.agent.orchestrator.MoviePilotToolFactory.get_tool_selector_always_include_names", + "app.agent.tools.factory.MoviePilotToolFactory.get_tool_selector_always_include_names", return_value=[], ), patch( @@ -514,7 +514,7 @@ class AgentBackgroundOutputTest(unittest.IsolatedAsyncioTestCase): patch("app.agent.orchestrator.prompt_manager.get_agent_prompt", return_value="PROMPT"), patch("app.agent.orchestrator.create_subagent_middlewares", return_value=([], [])), patch( - "app.agent.orchestrator.MoviePilotToolFactory.get_tool_selector_always_include_names", + "app.agent.tools.factory.MoviePilotToolFactory.get_tool_selector_always_include_names", return_value=[], ), patch( @@ -603,7 +603,7 @@ class AgentBackgroundOutputTest(unittest.IsolatedAsyncioTestCase): patch("app.agent.orchestrator.prompt_manager.get_agent_prompt", return_value="PROMPT"), patch("app.agent.orchestrator.create_subagent_middlewares", return_value=([], [])), patch( - "app.agent.orchestrator.MoviePilotToolFactory.get_tool_selector_always_include_names", + "app.agent.tools.factory.MoviePilotToolFactory.get_tool_selector_always_include_names", return_value=[], ), patch( @@ -667,7 +667,7 @@ class AgentBackgroundOutputTest(unittest.IsolatedAsyncioTestCase): ), ), patch( - "app.agent.orchestrator.MoviePilotToolFactory.get_tool_selector_always_include_names", + "app.agent.tools.factory.MoviePilotToolFactory.get_tool_selector_always_include_names", return_value=[], ), patch( @@ -718,7 +718,7 @@ class AgentBackgroundOutputTest(unittest.IsolatedAsyncioTestCase): patch("app.agent.orchestrator.prompt_manager.get_agent_prompt", return_value="PROMPT"), patch("app.agent.orchestrator.create_subagent_middlewares", return_value=([], [])), patch( - "app.agent.orchestrator.MoviePilotToolFactory.get_tool_selector_always_include_names", + "app.agent.tools.factory.MoviePilotToolFactory.get_tool_selector_always_include_names", return_value=[], ), patch( @@ -766,23 +766,24 @@ class AgentBackgroundOutputTest(unittest.IsolatedAsyncioTestCase): async def test_run_background_prompt_forces_disable_message_tools_when_capture_only(self): captured = {} + manager = AgentManager() - async def fake_process(self, message, images=None, files=None): - captured["message"] = message - captured["reply_mode"] = self.reply_mode - captured["allow_message_tools"] = self.allow_message_tools - captured["user_id"] = self.user_id + async def fake_process(task): + captured["message"] = task.message + captured["reply_mode"] = task.reply_mode + captured["allow_message_tools"] = task.allow_message_tools + captured["user_id"] = task.user_id - with ( - patch.object(MoviePilotAgent, "process", new=fake_process), - patch.object(MoviePilotAgent, "cleanup", new=AsyncMock()), - patch.object(memory_manager, "clear_memory"), - ): - await AgentManager.run_background_prompt( + manager._process_message_internal = fake_process + await manager.initialize() + try: + await manager.run_background_prompt( message="background task", reply_mode=ReplyMode.CAPTURE_ONLY, allow_message_tools=True, ) + finally: + await manager.close() self.assertEqual("background task", captured["message"]) self.assertEqual(ReplyMode.CAPTURE_ONLY, captured["reply_mode"]) diff --git a/tests/test_agent_cancellation.py b/tests/test_agent_cancellation.py index 4ac905288..f8aa80828 100644 --- a/tests/test_agent_cancellation.py +++ b/tests/test_agent_cancellation.py @@ -40,6 +40,7 @@ def test_stop_current_task_cancels_waiters_and_allows_next_message(): async def _run_scenario(): manager = AgentManager() + await manager.initialize() started = asyncio.Event() async def _block_current_task(_task): @@ -96,6 +97,7 @@ def test_stop_current_task_cancels_waiters_and_allows_next_message(): second_waiter, return_exceptions=True, ) + await manager.close() asyncio.run(_run_scenario()) @@ -105,6 +107,7 @@ def test_stop_queues_new_message_until_cancellation_cleanup_finishes(): async def _run_scenario(): manager = AgentManager() + await manager.initialize() current_started = asyncio.Event() cancellation_cleanup_started = asyncio.Event() release_cleanup = asyncio.Event() @@ -149,5 +152,6 @@ def test_stop_queues_new_message_until_cancellation_cleanup_finishes(): assert await asyncio.wait_for(next_waiter, timeout=1) == "next-completed" with pytest.raises(asyncio.CancelledError): await current_waiter + await manager.close() asyncio.run(_run_scenario()) diff --git a/tests/test_agent_doctor_tool.py b/tests/test_agent_doctor_tool.py index b747729b9..830b0c000 100644 --- a/tests/test_agent_doctor_tool.py +++ b/tests/test_agent_doctor_tool.py @@ -3,6 +3,7 @@ import json from datetime import datetime from unittest.mock import patch +from app.agent.tools.catalog import ToolCatalogSnapshot from app.agent.tools.factory import MoviePilotToolFactory from app.agent.tools.impl.query_doctor_report import QueryDoctorReportTool from app.agent.tools.manager import MoviePilotToolsManager @@ -97,14 +98,20 @@ def test_query_doctor_report_compact_mode_omits_details(): def test_mcp_tool_manager_exposes_doctor_report_tool(): """MCP 工具管理器应暴露 doctor 诊断报告工具。""" tool = QueryDoctorReportTool(session_id="doctor-session", user_id="10001") + catalog = ToolCatalogSnapshot.from_tools( + [tool], plugin_revision=0, factory_revision="test" + ) - with patch( - "app.agent.tools.manager.MoviePilotToolFactory.create_tools", - return_value=[tool], - ): + with patch.object( + MoviePilotToolFactory, + "create_catalog", + return_value=catalog, + ) as create_catalog: manager = MoviePilotToolsManager(is_admin=True) + create_catalog.assert_not_called() + tool_definitions = manager.list_tools() + create_catalog.assert_called_once() - tool_definitions = manager.list_tools() assert [item.name for item in tool_definitions] == ["query_doctor_report"] schema = tool_definitions[0].input_schema assert "deep" in schema["properties"] diff --git a/tests/test_agent_graph_cache.py b/tests/test_agent_graph_cache.py index ab957e0a3..3561db338 100644 --- a/tests/test_agent_graph_cache.py +++ b/tests/test_agent_graph_cache.py @@ -434,7 +434,7 @@ async def test_graph_keeps_mcp_first_winner_and_catalogs_all_collisions( side_effect=_capture_subagents, ), patch( - "app.agent.orchestrator.MoviePilotToolFactory.get_tool_selector_always_include_names", + "app.agent.tools.factory.MoviePilotToolFactory.get_tool_selector_always_include_names", return_value=[], ), patch( diff --git a/tests/test_agent_image_capability.py b/tests/test_agent_image_capability.py index b749576b7..a36d0b0ca 100644 --- a/tests/test_agent_image_capability.py +++ b/tests/test_agent_image_capability.py @@ -1,18 +1,3 @@ -# 把真实 Agent 服务注册进 application 门面(幂等),供测试 patch 门面背后的单例方法。 -from app.agent.llm import AgentCapabilityManager, LLMHelper -from app.agent.orchestrator import agent_manager -from app.agent.prompt import prompt_manager -from app.agent.prompt.transfer_redo import build_manual_redo_prompt -from app.application.agent import register_agent_services - -register_agent_services( - agent_manager=agent_manager, - prompt_manager=prompt_manager, - capability_manager=AgentCapabilityManager, - llm_helper=LLMHelper, - manual_redo_prompt_builder=build_manual_redo_prompt, -) - from unittest.mock import AsyncMock, patch from app.agent import MoviePilotAgent @@ -84,11 +69,13 @@ def test_handle_ai_message_routes_text_only_model_images_to_files(monkeypatch): } ], ) as prepare_files, patch( - "app.application.agent._agent_manager.process_message", new_callable=AsyncMock - ) as process_message, patch( + "app.chain.message.get_running_agent_manager" + ) as get_running_manager, patch( "app.chain.message.asyncio.run_coroutine_threadsafe", side_effect=lambda coro, _loop: coro.close(), ): + process_message = AsyncMock() + get_running_manager.return_value.process_message = process_message chain._handle_ai_message( text="/ai 帮我看看这张图", channel=MessageChannel.Telegram, diff --git a/tests/test_agent_image_support.py b/tests/test_agent_image_support.py index 913b6db0d..2d10ca787 100644 --- a/tests/test_agent_image_support.py +++ b/tests/test_agent_image_support.py @@ -1,18 +1,3 @@ -# 把真实 Agent 服务注册进 application 门面(幂等),供测试 patch 门面背后的单例方法。 -from app.agent.llm import AgentCapabilityManager, LLMHelper -from app.agent.orchestrator import agent_manager -from app.agent.prompt import prompt_manager -from app.agent.prompt.transfer_redo import build_manual_redo_prompt -from app.application.agent import register_agent_services - -register_agent_services( - agent_manager=agent_manager, - prompt_manager=prompt_manager, - capability_manager=AgentCapabilityManager, - llm_helper=LLMHelper, - manual_redo_prompt_builder=build_manual_redo_prompt, -) - import asyncio import base64 import json @@ -301,15 +286,14 @@ class AgentImageSupportTest(unittest.TestCase): "feishu://file/om_audio/file_audio/voice.opus", ] - with patch.object( - AgentCapabilityManager, "is_audio_input_available", return_value=True + with patch( + "app.chain.message.is_audio_input_available", return_value=True ), patch.object( chain, "run_module", side_effect=[b"slack", b"discord", b"qq", b"vocechat", b"synology", b"feishu"], - ) as run_module, patch.object( - AgentCapabilityManager, - "transcribe_audio", + ) as run_module, patch( + "app.chain.message.transcribe_audio", side_effect=[ "slack text", "discord text", @@ -466,11 +450,13 @@ class AgentImageSupportTest(unittest.TestCase): } ], ) as prepare_files, patch( - "app.application.agent._agent_manager.process_message", new_callable=AsyncMock - ) as process_message, patch( + "app.chain.message.get_running_agent_manager" + ) as get_running_manager, patch( "app.chain.message.asyncio.run_coroutine_threadsafe", side_effect=lambda coro, _loop: coro.close(), ) as run_coroutine_threadsafe: + process_message = AsyncMock() + get_running_manager.return_value.process_message = process_message chain._handle_ai_message( text="/ai 帮我看看这张图", channel=MessageChannel.Telegram, @@ -499,11 +485,13 @@ class AgentImageSupportTest(unittest.TestCase): with patch.object(settings, "AI_AGENT_ENABLE", True), patch.object( chain, "_get_or_create_session_id", return_value="session-1" ), patch( - "app.application.agent._agent_manager.process_message", new_callable=AsyncMock - ) as process_message, patch( + "app.chain.message.get_running_agent_manager" + ) as get_running_manager, patch( "app.chain.message.asyncio.run_coroutine_threadsafe", side_effect=lambda coro, _loop: coro.close(), ): + process_message = AsyncMock() + get_running_manager.return_value.process_message = process_message chain._handle_ai_message( text="帮我推荐一部电影", channel=MessageChannel.Telegram, diff --git a/tests/test_agent_interaction.py b/tests/test_agent_interaction.py index 40c123c43..9e21e7ef0 100644 --- a/tests/test_agent_interaction.py +++ b/tests/test_agent_interaction.py @@ -1,18 +1,3 @@ -# 把真实 Agent 服务注册进 application 门面(幂等),供测试 patch 门面背后的单例方法。 -from app.agent.llm import AgentCapabilityManager, LLMHelper -from app.agent.orchestrator import agent_manager -from app.agent.prompt import prompt_manager -from app.agent.prompt.transfer_redo import build_manual_redo_prompt -from app.application.agent import register_agent_services - -register_agent_services( - agent_manager=agent_manager, - prompt_manager=prompt_manager, - capability_manager=AgentCapabilityManager, - llm_helper=LLMHelper, - manual_redo_prompt_builder=build_manual_redo_prompt, -) - import asyncio import unittest from datetime import datetime @@ -210,12 +195,13 @@ class TestAgentInteraction(unittest.TestCase): ) as message_add, patch.object( chain, "edit_message", return_value=True ) as edit_message, patch( - "app.application.agent._agent_manager.process_message", - new_callable=AsyncMock, - ) as process_message, patch( + "app.chain.message.get_running_agent_manager" + ) as get_running_manager, patch( "app.chain.message.asyncio.run_coroutine_threadsafe", side_effect=lambda coro, _loop: (coro.close(), Mock())[1], ): + process_message = AsyncMock() + get_running_manager.return_value.process_message = process_message handled = chain._handle_callback( callback_data=f"agent_interaction:choice:{request.request_id}:1", context=InteractionContext( @@ -282,9 +268,11 @@ class TestAgentInteraction(unittest.TestCase): try: for channel in (MessageChannel.Telegram, MessageChannel.Feishu): + manager = Mock() + manager.matches_secret_confirmation.return_value = True with patch( - "app.application.agent._agent_manager.matches_secret_confirmation", - return_value=True, + "app.chain.message.get_running_agent_manager", + return_value=manager, ), patch.object( chain, "_handle_ai_message", diff --git a/tests/test_agent_lazy_initializer.py b/tests/test_agent_lazy_initializer.py new file mode 100644 index 000000000..f9d8bdd65 --- /dev/null +++ b/tests/test_agent_lazy_initializer.py @@ -0,0 +1,245 @@ +from __future__ import annotations + +import sys +import types +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from app.runtime.capabilities.errors import CapabilityRuntimeClosedError +from app.startup import agent_initializer + + +@pytest.mark.anyio +async def test_disabled_initializer_does_not_materialize_manager(monkeypatch) -> None: + """功能关闭时启动阶段不得解析完整 Agent 模块。""" + activate = AsyncMock(return_value=None) + monkeypatch.setattr( + agent_initializer, + "activate_agent_service", + activate, + ) + initializer = agent_initializer.AgentInitializer() + + assert await initializer.initialize() is True + assert initializer._initialized is False + activate.assert_awaited_once_with() + + +@pytest.mark.anyio +async def test_cleanup_without_initialized_manager_does_not_query(monkeypatch) -> None: + """清理空状态只能关闭已持有资源,不能为清理而触发首次导入。""" + activate = AsyncMock(side_effect=AssertionError("service activated")) + monkeypatch.setattr(agent_initializer, "activate_agent_service", activate) + + await agent_initializer.AgentInitializer().cleanup() + + activate.assert_not_awaited() + + +@pytest.mark.anyio +async def test_failed_initialize_keeps_manager_for_shutdown_cleanup( + monkeypatch, +) -> None: + """初始化中途失败时仍须保留实际 manager,供应用关闭释放部分资源。""" + manager = AsyncMock() + manager.initialize.side_effect = RuntimeError("partial initialization") + monkeypatch.setattr(agent_initializer.settings, "AI_AGENT_ENABLE", True) + monkeypatch.setattr(agent_initializer, "agent_manager", manager) + initializer = agent_initializer.AgentInitializer() + + assert await initializer.initialize() is False + await initializer.cleanup() + + manager.close.assert_awaited_once_with() + assert initializer._manager is None + + +@pytest.mark.anyio +async def test_compat_stop_closes_injected_manager_without_building_runtime( + monkeypatch, +) -> None: + """显式注入对象由兼容路径关闭,不为其构建空 Capability Runtime。""" + events = [] + manager = AsyncMock() + manager.initialize.side_effect = lambda: events.append("initialize") + manager.close.side_effect = lambda: events.append("close") + monkeypatch.setattr(agent_initializer.settings, "AI_AGENT_ENABLE", True) + monkeypatch.setattr(agent_initializer, "agent_manager", manager) + shutdown = AsyncMock(side_effect=lambda: events.append("shutdown_gate")) + monkeypatch.setattr(agent_initializer, "begin_agent_shutdown", shutdown) + monkeypatch.setattr( + agent_initializer, + "agent_initializer", + agent_initializer.AgentInitializer(), + ) + + assert await agent_initializer.init_agent() is True + await agent_initializer.stop_agent() + + assert events == ["initialize", "close"] + manager.initialize.assert_awaited_once_with() + manager.close.assert_awaited_once_with() + shutdown.assert_not_awaited() + + +@pytest.mark.anyio +async def test_production_initializer_delegates_lifecycle_to_runtime( + monkeypatch, +) -> None: + """生产路径只协调 service,不得再次手工 initialize 或 close canonical manager。""" + manager = AsyncMock() + activate = AsyncMock(return_value=manager) + monkeypatch.setattr(agent_initializer, "agent_manager", None) + monkeypatch.setattr(agent_initializer, "activate_agent_service", activate) + initializer = agent_initializer.AgentInitializer() + + assert await initializer.initialize() is True + await initializer.cleanup() + + activate.assert_awaited_once_with() + manager.initialize.assert_not_awaited() + manager.close.assert_not_awaited() + assert initializer._manager is None + + +@pytest.mark.anyio +async def test_production_stop_seals_runtime_without_manually_closing_manager( + monkeypatch, +) -> None: + """生产关闭由 Runtime 关闸并释放 service,initializer 只清理自身引用。""" + manager = AsyncMock() + initializer = agent_initializer.AgentInitializer() + initializer._manager = manager + initializer._initialized = True + initializer._compat_injected = False + shutdown = AsyncMock() + monkeypatch.setattr(agent_initializer, "begin_agent_shutdown", shutdown) + monkeypatch.setattr(agent_initializer, "agent_initializer", initializer) + monkeypatch.setattr( + agent_initializer, + "is_tool_factory_materialized", + lambda: False, + ) + + await agent_initializer.stop_agent() + + shutdown.assert_awaited_once_with() + manager.close.assert_not_awaited() + assert initializer._manager is None + + +@pytest.mark.anyio +async def test_config_listener_delegates_watch_filter_to_runtime(monkeypatch) -> None: + """配置监听器只转交 changed keys,不维护第二份启用开关。""" + manager = AsyncMock() + reconcile = AsyncMock(return_value=manager) + monkeypatch.setattr(agent_initializer, "reconcile_agent_service", reconcile) + initializer = agent_initializer.AgentInitializer() + event = agent_initializer.Event( + agent_initializer.EventType.ConfigChanged, + {"key": {"AI_AGENT_ENABLE"}}, + ) + + await initializer.handle_config_changed(event) + + reconcile.assert_awaited_once_with( + reason="agent_service_config_changed", + changed_keys={"AI_AGENT_ENABLE"}, + retry=True, + ) + assert initializer._manager is manager + assert initializer._initialized is True + + +def test_config_listener_registration_is_idempotent_and_instance_free() -> None: + """重复构造 initializer 不得累积监听器或持有过期实例。""" + subscribers = getattr( + agent_initializer.eventmanager, + "_EventManager__broadcast_subscribers", + ) + AgentInitializer = agent_initializer.AgentInitializer + AgentInitializer() + AgentInitializer() + + listeners = tuple( + subscribers.get(agent_initializer.EventType.ConfigChanged, {}).values() + ) + matching = [ + listener + for listener in listeners + if listener is agent_initializer._handle_agent_config_changed + ] + assert len(matching) == 1 + assert agent_initializer._handle_agent_config_changed.__closure__ is None + + +@pytest.mark.anyio +async def test_config_event_after_shutdown_is_fail_closed(monkeypatch) -> None: + """关闭后的配置事件不得把 service 重新标为初始化成功。""" + reconcile = AsyncMock(side_effect=CapabilityRuntimeClosedError("closed")) + monkeypatch.setattr(agent_initializer, "reconcile_agent_service", reconcile) + initializer = agent_initializer.AgentInitializer() + initializer._shutdown_complete = True + event = agent_initializer.Event( + agent_initializer.EventType.ConfigChanged, + {"key": "AI_AGENT_ENABLE"}, + ) + + await initializer.handle_config_changed(event) + + reconcile.assert_not_awaited() + assert initializer._manager is None + assert initializer._initialized is False + + +@pytest.mark.anyio +async def test_stop_skips_tool_executor_cleanup_when_factory_is_unresolved( + monkeypatch, +) -> None: + """工具能力从未解析时,关闭路径不得为线程池清理导入工具基础模块。""" + fake_base = types.ModuleType("app.agent.tools.base") + cleanup = MagicMock() + fake_base.shutdown_blocking_executors = cleanup + monkeypatch.setitem(sys.modules, "app.agent.tools.base", fake_base) + monkeypatch.setattr(agent_initializer, "begin_agent_shutdown", AsyncMock()) + monkeypatch.setattr( + agent_initializer, + "is_tool_factory_materialized", + lambda: False, + ) + monkeypatch.setattr( + agent_initializer, + "agent_initializer", + agent_initializer.AgentInitializer(), + ) + + await agent_initializer.stop_agent() + + cleanup.assert_not_called() + + +@pytest.mark.anyio +async def test_stop_closes_tool_executor_after_factory_materialization( + monkeypatch, +) -> None: + """工具能力已解析时,应取消仍排队的阻塞工具任务。""" + fake_base = types.ModuleType("app.agent.tools.base") + cleanup = MagicMock() + fake_base.shutdown_blocking_executors = cleanup + monkeypatch.setitem(sys.modules, "app.agent.tools.base", fake_base) + monkeypatch.setattr(agent_initializer, "begin_agent_shutdown", AsyncMock()) + monkeypatch.setattr( + agent_initializer, + "is_tool_factory_materialized", + lambda: True, + ) + monkeypatch.setattr( + agent_initializer, + "agent_initializer", + agent_initializer.AgentInitializer(), + ) + + await agent_initializer.stop_agent() + + cleanup.assert_called_once_with(cancel_futures=True) diff --git a/tests/test_agent_lazy_runtime_boundary.py b/tests/test_agent_lazy_runtime_boundary.py new file mode 100644 index 000000000..40827576e --- /dev/null +++ b/tests/test_agent_lazy_runtime_boundary.py @@ -0,0 +1,322 @@ +"""Agent 工具与 LLM 入口的延迟加载合同测试。""" + +from __future__ import annotations + +import json +import subprocess +import sys +import threading +import time +from types import SimpleNamespace +from unittest.mock import patch + + +def _run_isolated(script: str) -> dict: + """在全新解释器中执行导入探针,避免当前 pytest 模块缓存干扰。""" + completed = subprocess.run( + [sys.executable, "-c", script], + check=True, + capture_output=True, + text=True, + ) + return json.loads(completed.stdout) + + +def test_mcp_router_import_keeps_agent_tool_runtime_cold() -> None: + """默认 API 路由加载不得提前物化工具目录或 Agent 编排。""" + result = _run_isolated( + """ +import json +import sys + +import app.api.endpoints.mcp + +forbidden = ( + "app.agent.callback", + "app.agent.orchestrator", + "app.agent.tools.base", + "app.agent.tools.catalog", + "app.agent.tools.factory", + "app.agent.tools.impl", + "anthropic", + "boto3", + "google.genai", + "langchain", + "langgraph", + "openai", +) +loaded = sorted( + name + for name in sys.modules + if any(name == prefix or name.startswith(prefix + ".") for prefix in forbidden) +) +print(json.dumps({"loaded": loaded})) +""" + ) + + assert result == {"loaded": []} + + +def test_agent_initializer_import_only_registers_lazy_providers() -> None: + """组合根导入只注册 provider,不得提前加载 Agent 重量实现。""" + result = _run_isolated( + """ +import json +import sys + +import app.startup.agent_initializer + +forbidden = ( + "app.agent.orchestrator", + "app.agent.llm.capability", + "app.agent.llm.helper", + "app.agent.llm.provider", + "app.agent.prompt", + "app.agent.tools.base", + "app.agent.tools.factory", + "app.agent.tools.impl", + "anthropic", + "langchain", + "langgraph", + "openai", +) +loaded = sorted( + name + for name in sys.modules + if any(name == prefix or name.startswith(prefix + ".") for prefix in forbidden) +) +print(json.dumps({"loaded": loaded})) +""" + ) + + assert result == {"loaded": []} + + +def test_manager_constructor_and_llm_facade_are_lightweight() -> None: + """构造全局 manager 与导入 LLM facade 都不加载真实目录或 provider。""" + result = _run_isolated( + """ +import json +import sys + +from app.agent.tools.manager import MoviePilotToolsManager +import app.agent.llm + +manager = MoviePilotToolsManager(session_id="lazy", user_id="api") +forbidden = ( + "app.agent.llm.capability", + "app.agent.llm.helper", + "app.agent.llm.provider", + "app.agent.tools.base", + "app.agent.tools.catalog", + "app.agent.tools.factory", + "app.agent.tools.impl", + "anthropic", + "boto3", + "google.genai", + "langchain", + "langchain_core", + "openai", +) +loaded = sorted( + name + for name in sys.modules + if any(name == prefix or name.startswith(prefix + ".") for prefix in forbidden) +) +print(json.dumps({ + "loaded": loaded, + "tools": manager.tools, + "catalog": manager.catalog, +})) +""" + ) + + assert result == {"loaded": [], "tools": [], "catalog": None} + + +def test_tool_catalog_materialization_does_not_load_streaming_callback() -> None: + """工具目录和 schema 首用不应加载仅在真实编排中需要的回调实现。""" + result = _run_isolated( + """ +import json +import sys +from typing import get_args, get_type_hints + +from app.testing.bootstrap import ensure_sites_stub + +ensure_sites_stub() +from app.agent.runtime_loader import get_tool_factory + +factory = get_tool_factory() +catalog = factory.create_catalog(session_id="lazy", user_id="api") +from app.agent.tools.base import MoviePilotTool + +hints = get_type_hints(MoviePilotTool.set_stream_handler) +handler_args = get_args(hints["stream_handler"]) +print(json.dumps({ + "callback_loaded": "app.agent.callback" in sys.modules, + "catalog_entries": len(catalog.entries), + "handler_types": [item.__name__ for item in handler_args], +})) +""" + ) + + assert result["callback_loaded"] is False + assert result["catalog_entries"] > 0 + assert result["handler_types"] == ["_StreamingHandlerProtocol", "NoneType"] + + +def test_legacy_streaming_handler_import_keeps_canonical_identity() -> None: + """历史显式与星号导入必须按需返回真实 callback 类。""" + result = _run_isolated( + """ +import json +import sys + +import app.agent.tools.base as base +cold_before_explicit = "app.agent.callback" not in sys.modules +from app.agent.tools.base import StreamingHandler +from app.agent.callback import StreamingHandler as CanonicalStreamingHandler + +namespace = {} +exec("from app.agent.tools.base import *", namespace) +print(json.dumps({ + "cold_before_explicit": cold_before_explicit, + "explicit_identity": StreamingHandler is CanonicalStreamingHandler, + "star_identity": namespace["StreamingHandler"] is CanonicalStreamingHandler, +})) +""" + ) + + assert result == { + "cold_before_explicit": True, + "explicit_identity": True, + "star_identity": True, + } + + +def test_manager_first_catalog_use_is_single_flight(monkeypatch) -> None: + """并发首次查询只能在 manager 锁内建立一次会话工具快照。""" + from app.agent import runtime_loader + from app.agent.tools.manager import MoviePilotToolsManager + + calls: list[tuple[str, str]] = [] + fake_tool = SimpleNamespace( + name="demo", + description="demo tool", + args_schema=None, + _require_admin=False, + ) + + class _Factory: + """记录目录构造次数的轻量工厂替身。""" + + @classmethod + def create_catalog(cls, **kwargs): + calls.append((kwargs["session_id"], kwargs["user_id"])) + time.sleep(0.05) + return SimpleNamespace(tools=[fake_tool], plugin_revision=0) + + monkeypatch.setattr(runtime_loader, "get_tool_factory", lambda: _Factory) + manager = MoviePilotToolsManager(session_id="session", user_id="user") + results: list[list[str]] = [] + + def _list_tools() -> None: + results.append([tool.name for tool in manager.list_tools()]) + + threads = [threading.Thread(target=_list_tools) for _ in range(2)] + for thread in threads: + thread.start() + for thread in threads: + thread.join() + + assert calls == [("session", "user")] + assert results == [["demo"], ["demo"]] + assert manager.tools == [fake_tool] + assert manager.catalog is not None + + +def test_legacy_explicit_tool_refresh_keeps_atomic_catalog_contract( + monkeypatch, +) -> None: + """插件显式刷新旧入口应继续发布同一次构造的完整目录快照。""" + from app.agent import runtime_loader + from app.agent.tools.manager import MoviePilotToolsManager + + calls: list[int] = [] + fake_tool = SimpleNamespace(name="plugin_tool") + fake_catalog = SimpleNamespace( + tools=[fake_tool], + plugin_revision=9, + ) + + class _Factory: + """提供固定 revision 快照的轻量工厂替身。""" + + @classmethod + def create_catalog(cls, **_kwargs): + calls.append(1) + return fake_catalog + + monkeypatch.setattr(runtime_loader, "get_tool_factory", lambda: _Factory) + manager = MoviePilotToolsManager(session_id="session", user_id="user") + + manager._load_tools() + + assert calls == [1] + assert manager.catalog is fake_catalog + assert manager.tools == [fake_tool] + assert manager._plugin_agent_tools_revision == 9 + + +def test_reply_mode_identity_and_display_message_contract() -> None: + """旧编排路径必须复用同一枚举,展示消息委托保持原有结构。""" + from app.agent.contracts import ReplyMode, build_display_message + from app.agent.orchestrator import MoviePilotAgent + from app.agent.orchestrator import ReplyMode as LegacyReplyMode + + assert LegacyReplyMode is ReplyMode + + contract_message = build_display_message( + role="assistant", + content="done", + attachments=[{"name": "report.txt"}], + status="streaming", + ) + legacy_message = MoviePilotAgent.build_display_message( + role="assistant", + content="done", + attachments=[{"name": "report.txt"}], + status="streaming", + ) + + for message in (contract_message, legacy_message): + assert message["id"].startswith("assistant-") + assert isinstance(message["createdAt"], int) + message.pop("id") + message.pop("createdAt") + assert legacy_message == contract_message + + +def test_llm_facade_resolves_only_requested_public_module() -> None: + """访问 capability 导出时不应顺带加载 helper 或 provider registry。""" + result = _run_isolated( + """ +import json +import sys + +import app.agent.llm as llm +capability = llm.AgentCapabilityManager +print(json.dumps({ + "module": capability.__module__, + "helper_loaded": "app.agent.llm.helper" in sys.modules, + "provider_loaded": "app.agent.llm.provider" in sys.modules, +})) +""" + ) + + assert result == { + "module": "app.agent.llm.capability", + "helper_loaded": False, + "provider_loaded": False, + } diff --git a/tests/test_agent_lifecycle.py b/tests/test_agent_lifecycle.py index a14dce458..39d61077e 100644 --- a/tests/test_agent_lifecycle.py +++ b/tests/test_agent_lifecycle.py @@ -5,6 +5,7 @@ import pytest import app.agent.orchestrator as agent_module from app.agent import AgentManager +from app.agent.orchestrator import AgentManagerUnavailableError from app.agent.memory import MemoryManager from app.startup import agent_initializer, modules_initializer @@ -154,3 +155,179 @@ async def test_disabled_agent_does_not_create_background_tasks(monkeypatch) -> N assert await agent_initializer.init_agent() is True manager.initialize.assert_not_awaited() + + +@pytest.mark.anyio +async def test_agent_manager_acceptance_gate_rejects_stale_references( + monkeypatch, +) -> None: + """未启动和关闭后的 manager 引用不得创建队列、worker 或 Agent。""" + manager = AgentManager() + memory_manager = MemoryManager() + monkeypatch.setattr(agent_module, "memory_manager", memory_manager) + + with pytest.raises(AgentManagerUnavailableError): + await manager.process_message("before-init", "1", "hello") + + await manager.initialize() + manager._process_message_internal = AsyncMock(return_value="accepted") + assert await manager.process_message( + "running", + "1", + "hello", + wait_for_completion=True, + ) == "accepted" + await manager.close() + + with pytest.raises(AgentManagerUnavailableError): + await manager.process_message("after-close", "1", "hello") + assert manager._session_queues == {} + assert manager._session_workers == {} + assert manager.active_agents == {} + + +@pytest.mark.anyio +async def test_agent_manager_close_serializes_racing_enqueue_and_clear( + monkeypatch, +) -> None: + """关闭、临时会话清理和迟到请求必须串行收口且只清理一次。""" + manager = AgentManager() + memory_manager = MemoryManager() + monkeypatch.setattr(agent_module, "memory_manager", memory_manager) + started = asyncio.Event() + cleanup_started = asyncio.Event() + release_cleanup = asyncio.Event() + created = [] + cleanup_calls = [] + + class BlockingAgent: + """用于放大 close 与请求级 clear 竞态窗口。""" + + def __init__(self, **kwargs): + self.__dict__.update(kwargs) + created.append(self) + + async def process(self, _message, **_kwargs): + started.set() + await asyncio.Event().wait() + + async def cleanup(self): + cleanup_calls.append(self) + cleanup_started.set() + await release_cleanup.wait() + + await manager.initialize() + waiter = asyncio.create_task( + manager.process_message( + "closing", + "1", + "hello", + agent_factory=BlockingAgent, + wait_for_completion=True, + ) + ) + await asyncio.wait_for(started.wait(), timeout=1) + close_task = asyncio.create_task(manager.close()) + await asyncio.wait_for(cleanup_started.wait(), timeout=1) + late_enqueue = asyncio.create_task( + manager.process_message("late", "1", "hello") + ) + request_clear = asyncio.create_task(manager.clear_session("closing", "1")) + await asyncio.sleep(0) + assert not late_enqueue.done() + assert not request_clear.done() + + release_cleanup.set() + await asyncio.wait_for(close_task, timeout=1) + with pytest.raises(AgentManagerUnavailableError): + await late_enqueue + await request_clear + with pytest.raises(AgentManagerUnavailableError): + await waiter + + assert len(created) == 1 + assert cleanup_calls == created + assert manager._session_queues == {} + assert manager._session_workers == {} + assert manager.active_agents == {} + + +@pytest.mark.anyio +async def test_clear_session_settles_current_and_queued_waiters(monkeypatch) -> None: + """清空会话必须同时结束正在执行和尚未执行的等待请求。""" + manager = AgentManager() + memory_manager = MemoryManager() + monkeypatch.setattr(agent_module, "memory_manager", memory_manager) + started = asyncio.Event() + + async def block_current(_task): + started.set() + await asyncio.Event().wait() + + manager._process_message_internal = block_current + await manager.initialize() + current_waiter = asyncio.create_task( + manager.process_message( + "session-with-queue", + "1", + "current", + wait_for_completion=True, + ) + ) + await asyncio.wait_for(started.wait(), timeout=1) + queued_waiter = asyncio.create_task( + manager.process_message( + "session-with-queue", + "1", + "queued", + wait_for_completion=True, + ) + ) + await asyncio.sleep(0) + + await asyncio.wait_for( + manager.clear_session("session-with-queue", "1"), + timeout=1, + ) + + with pytest.raises(asyncio.CancelledError): + await current_waiter + with pytest.raises(asyncio.CancelledError): + await asyncio.wait_for(queued_waiter, timeout=1) + assert "session-with-queue" not in manager._session_queues + assert "session-with-queue" not in manager._session_workers + await manager.close() + + +@pytest.mark.anyio +async def test_background_prompt_is_owned_and_cancelled_by_manager_close( + monkeypatch, +) -> None: + """后台 prompt 必须进入 manager worker,关闭时同步结束且不残留临时会话。""" + manager = AgentManager() + memory_manager = MemoryManager() + monkeypatch.setattr(agent_module, "memory_manager", memory_manager) + started = asyncio.Event() + + async def block_background(task): + assert task.session_id.startswith("__managed_background_") + started.set() + await asyncio.Event().wait() + + manager._process_message_internal = block_background + await manager.initialize() + execution = asyncio.create_task( + manager.run_background_prompt( + "background", + session_prefix="__managed_background", + ) + ) + await asyncio.wait_for(started.wait(), timeout=1) + assert len(manager._session_workers) == 1 + + await manager.close() + with pytest.raises(AgentManagerUnavailableError): + await execution + assert manager._session_queues == {} + assert manager._session_workers == {} + assert manager.active_agents == {} diff --git a/tests/test_agent_message_routing.py b/tests/test_agent_message_routing.py index b75d43c0c..cbd9d177e 100644 --- a/tests/test_agent_message_routing.py +++ b/tests/test_agent_message_routing.py @@ -1,18 +1,3 @@ -# 把真实 Agent 服务注册进 application 门面(幂等),供测试 patch 门面背后的单例方法。 -from app.agent.llm import AgentCapabilityManager, LLMHelper -from app.agent.orchestrator import agent_manager -from app.agent.prompt import prompt_manager -from app.agent.prompt.transfer_redo import build_manual_redo_prompt -from app.application.agent import register_agent_services - -register_agent_services( - agent_manager=agent_manager, - prompt_manager=prompt_manager, - capability_manager=AgentCapabilityManager, - llm_helper=LLMHelper, - manual_redo_prompt_builder=build_manual_redo_prompt, -) - import asyncio from unittest.mock import AsyncMock, Mock, patch @@ -78,13 +63,13 @@ def test_explicit_ai_message_bypasses_pending_media_interaction(): def test_explicit_ai_message_is_not_recorded_to_message_history(): """显式 /ai 消息不登记到数据库或实时消息队列。""" chain = MessageChain() + manager = Mock(process_message=AsyncMock()) with patch.object(settings, "AI_AGENT_ENABLE", True), patch.object( chain, "_record_user_message" ) as record_user_message, patch( - "app.application.agent._agent_manager.process_message", - new_callable=AsyncMock, - ) as process_message, patch( + "app.chain.message.get_running_agent_manager", return_value=manager + ), patch( "app.chain.message.asyncio.run_coroutine_threadsafe", side_effect=lambda coro, _loop: (coro.close(), Mock())[1], ): @@ -97,17 +82,17 @@ def test_explicit_ai_message_is_not_recorded_to_message_history(): ) record_user_message.assert_not_called() - process_message.assert_called_once() + manager.process_message.assert_called_once() def test_message_chain_passes_stable_channel_admin_principal_to_agent(): """消息链应将渠道适配器生成的管理员事实传给 Agent。""" chain = MessageChain() + manager = Mock(process_message=AsyncMock()) with patch.object(settings, "AI_AGENT_ENABLE", True), patch( - "app.application.agent._agent_manager.process_message", - new_callable=AsyncMock, - ) as process_message, patch( + "app.chain.message.get_running_agent_manager", return_value=manager + ), patch( "app.chain.message.asyncio.run_coroutine_threadsafe", side_effect=lambda coro, _loop: (coro.close(), Mock())[1], ): @@ -120,17 +105,17 @@ def test_message_chain_passes_stable_channel_admin_principal_to_agent(): text="/ai 检查系统状态", ) - assert process_message.call_args.kwargs["is_channel_admin"] is True + assert manager.process_message.call_args.kwargs["is_channel_admin"] is True def test_message_chain_does_not_trust_channel_display_username(): """消息链应保留适配器给出的明确非管理员结论。""" chain = MessageChain() + manager = Mock(process_message=AsyncMock()) with patch.object(settings, "AI_AGENT_ENABLE", True), patch( - "app.application.agent._agent_manager.process_message", - new_callable=AsyncMock, - ) as process_message, patch( + "app.chain.message.get_running_agent_manager", return_value=manager + ), patch( "app.chain.message.asyncio.run_coroutine_threadsafe", side_effect=lambda coro, _loop: (coro.close(), Mock())[1], ): @@ -143,17 +128,17 @@ def test_message_chain_does_not_trust_channel_display_username(): text="/ai 检查系统状态", ) - assert process_message.call_args.kwargs["is_channel_admin"] is False + assert manager.process_message.call_args.kwargs["is_channel_admin"] is False def test_message_chain_uses_same_admin_contract_for_slack(): """管理员事实透传应复用于其他消息渠道,而不是 Telegram 特判。""" chain = MessageChain() + manager = Mock(process_message=AsyncMock()) with patch.object(settings, "AI_AGENT_ENABLE", True), patch( - "app.application.agent._agent_manager.process_message", - new_callable=AsyncMock, - ) as process_message, patch( + "app.chain.message.get_running_agent_manager", return_value=manager + ), patch( "app.chain.message.asyncio.run_coroutine_threadsafe", side_effect=lambda coro, _loop: (coro.close(), Mock())[1], ): @@ -166,7 +151,7 @@ def test_message_chain_uses_same_admin_contract_for_slack(): text="/ai 检查系统状态", ) - assert process_message.call_args.kwargs["is_channel_admin"] is True + assert manager.process_message.call_args.kwargs["is_channel_admin"] is True def test_ask_user_choice_message_is_not_recorded_to_message_history(): @@ -267,6 +252,7 @@ def test_agent_choice_callback_is_not_recorded_to_message_history(): AgentInteractionOption(label="电视剧", value="我选择电视剧"), ], ) + manager = Mock(process_message=AsyncMock()) try: with patch.object(settings, "AI_AGENT_ENABLE", True), patch.object( @@ -274,9 +260,8 @@ def test_agent_choice_callback_is_not_recorded_to_message_history(): ) as record_user_message, patch.object( chain, "edit_message", return_value=True ), patch( - "app.application.agent._agent_manager.process_message", - new_callable=AsyncMock, - ) as process_message, patch( + "app.chain.message.get_running_agent_manager", return_value=manager + ), patch( "app.chain.message.asyncio.run_coroutine_threadsafe", side_effect=lambda coro, _loop: (coro.close(), Mock())[1], ): @@ -296,5 +281,5 @@ def test_agent_choice_callback_is_not_recorded_to_message_history(): agent_interaction_manager.clear() record_user_message.assert_not_called() - process_message.assert_called_once() - assert process_message.call_args.kwargs["is_channel_admin"] is False + manager.process_message.assert_called_once() + assert manager.process_message.call_args.kwargs["is_channel_admin"] is False diff --git a/tests/test_agent_protocol_lifecycle.py b/tests/test_agent_protocol_lifecycle.py new file mode 100644 index 000000000..e2475b368 --- /dev/null +++ b/tests/test_agent_protocol_lifecycle.py @@ -0,0 +1,216 @@ +"""兼容协议请求的 AgentManager ownership 与关闭竞态合同。""" + +import asyncio +from types import SimpleNamespace +from unittest.mock import patch + +from fastapi.security import HTTPAuthorizationCredentials + +from app import schemas +from app.api.endpoints import anthropic, openai +from app.runtime.config import settings + +_API_TOKEN = "test-agent-protocol-token" + + +class _ManagerClosedError(RuntimeError): + """模拟 enqueue 时 manager 已关闭的 acceptance gate 错误。""" + + code = "agent_manager_unavailable" + + +class _ClosingManager: + """拒绝新任务并记录请求级清理的 manager 替身。""" + + def __init__(self) -> None: + self.process_calls = [] + self.clear_calls = [] + + async def process_message(self, **kwargs): + self.process_calls.append(kwargs) + raise _ManagerClosedError("AgentManager 已关闭") + + async def clear_session(self, **kwargs): + self.clear_calls.append(kwargs) + + async def stop_current_task(self, _session_id): + return False + + +async def _collect(response) -> str: + """收集 StreamingResponse 的全部文本块。""" + chunks = [] + async for chunk in response.body_iterator: + chunks.append(chunk.decode("utf-8") if isinstance(chunk, bytes) else chunk) + return "".join(chunks) + + +def test_streaming_protocols_reject_config_disable_before_manager_lookup() -> None: + """配置关闭后流式请求保持 503,且不得接触运行态 manager。""" + credentials = HTTPAuthorizationCredentials( + scheme="Bearer", + credentials=_API_TOKEN, + ) + openai_payload = schemas.OpenAIChatCompletionsRequest( + messages=[schemas.OpenAIChatMessage(role="user", content="hello")], + stream=True, + ) + anthropic_payload = schemas.AnthropicMessagesRequest( + messages=[schemas.AnthropicMessage(role="user", content="hello")], + stream=True, + ) + + async def scenario(): + return ( + await openai.chat_completions( + openai_payload, + SimpleNamespace(headers={}), + credentials, + ), + await anthropic.messages( + anthropic_payload, + x_api_key=_API_TOKEN, + ), + ) + + with patch.object(settings, "AI_AGENT_ENABLE", False), patch.object( + settings, + "API_TOKEN", + _API_TOKEN, + ), patch.object( + openai, + "get_running_agent_manager", + ) as openai_manager, patch.object( + anthropic, + "get_running_agent_manager", + ) as anthropic_manager: + responses = asyncio.run(scenario()) + + assert [response.status_code for response in responses] == [503, 503] + openai_manager.assert_not_called() + anthropic_manager.assert_not_called() + + +def test_openai_stream_rejects_shutdown_race_and_cleans_request_session() -> None: + """随机 OpenAI 流在 enqueue 竞态失败时返回协议错误并清理临时会话。""" + manager = _ClosingManager() + credentials = HTTPAuthorizationCredentials( + scheme="Bearer", + credentials=_API_TOKEN, + ) + payload = schemas.OpenAIChatCompletionsRequest( + messages=[schemas.OpenAIChatMessage(role="user", content="hello")], + stream=True, + ) + + async def scenario() -> str: + response = await openai.chat_completions( + payload, + SimpleNamespace(headers={}), + credentials, + ) + return await _collect(response) + + with patch.object(settings, "AI_AGENT_ENABLE", True), patch.object( + settings, + "API_TOKEN", + _API_TOKEN, + ), patch.object( + openai, + "get_running_agent_manager", + return_value=manager, + ): + body = asyncio.run(scenario()) + + assert '"type": "server_error"' in body + assert "data: [DONE]" in body + assert len(manager.process_calls) == 1 + assert manager.process_calls[0]["wait_for_completion"] is True + assert callable(manager.process_calls[0]["agent_setup"]) + assert len(manager.clear_calls) == 1 + + +def test_anthropic_stream_rejects_shutdown_race_and_cleans_request_session() -> None: + """Anthropic 流在 enqueue 竞态失败时返回 error 终态并清理临时会话。""" + manager = _ClosingManager() + payload = schemas.AnthropicMessagesRequest( + messages=[schemas.AnthropicMessage(role="user", content="hello")], + stream=True, + ) + + async def scenario() -> str: + response = await anthropic.messages( + payload, + x_api_key=_API_TOKEN, + ) + return await _collect(response) + + with patch.object(settings, "AI_AGENT_ENABLE", True), patch.object( + settings, + "API_TOKEN", + _API_TOKEN, + ), patch.object( + anthropic, + "get_running_agent_manager", + return_value=manager, + ): + body = asyncio.run(scenario()) + + assert "event: error" in body + assert "event: message_stop" in body + assert len(manager.process_calls) == 1 + assert manager.process_calls[0]["wait_for_completion"] is True + assert callable(manager.process_calls[0]["agent_setup"]) + assert len(manager.clear_calls) == 1 + + +def test_managed_protocol_request_releases_its_stream_queue() -> None: + """协议请求完成后不应由持久会话 Agent 继续强引用请求队列。""" + event_queue = asyncio.Queue() + created_agents = [] + + class ProtocolAgent: + """记录请求绑定与释放的最小协议 Agent。""" + + def __init__(self, **_kwargs): + self.collected_messages = ["done"] + self.bound_queue = None + created_agents.append(self) + + def configure_protocol_request(self, *, stream_mode, event_queue): + assert stream_mode is True + self.bound_queue = event_queue + + def release_protocol_request(self, queue): + if self.bound_queue is queue: + self.bound_queue = None + + class RunningManager: + """在 worker 边界执行 agent_setup 的 manager 替身。""" + + async def process_message(self, **kwargs): + agent = kwargs["agent_factory"]() + kwargs["agent_setup"](agent) + return "done" + + async def scenario(): + with patch.object( + openai, + "_get_collecting_agent_type", + return_value=ProtocolAgent, + ): + return await openai._run_managed_agent( + manager=RunningManager(), + session_id="persistent", + user_id="1", + username="api", + source="openai", + prompt="hello", + images=[], + stream_mode=True, + event_queue=event_queue, + ) + + assert asyncio.run(scenario()) == ("done", ["done"]) + assert len(created_agents) == 1 + assert created_agents[0].bound_queue is None diff --git a/tests/test_agent_recognize_captcha_tool.py b/tests/test_agent_recognize_captcha_tool.py index 345156f08..3a386c5ce 100644 --- a/tests/test_agent_recognize_captcha_tool.py +++ b/tests/test_agent_recognize_captcha_tool.py @@ -3,6 +3,7 @@ import base64 import json from unittest.mock import patch +from app.agent.tools.catalog import ToolCatalogSnapshot from app.agent.tools.factory import MoviePilotToolFactory from app.agent.tools.impl.recognize_captcha import RecognizeCaptchaTool from app.agent.tools.manager import MoviePilotToolsManager @@ -45,14 +46,20 @@ def test_factory_registers_recognize_captcha_tool(): def test_mcp_tool_manager_exposes_recognize_captcha_schema(): """MCP 工具管理器应暴露验证码识别工具参数。""" tool = RecognizeCaptchaTool(session_id="captcha-session", user_id="10001") + catalog = ToolCatalogSnapshot.from_tools( + [tool], plugin_revision=0, factory_revision="test" + ) - with patch( - "app.agent.tools.manager.MoviePilotToolFactory.create_tools", - return_value=[tool], - ): + with patch.object( + MoviePilotToolFactory, + "create_catalog", + return_value=catalog, + ) as create_catalog: manager = MoviePilotToolsManager(is_admin=True) + create_catalog.assert_not_called() + tool_definitions = manager.list_tools() + create_catalog.assert_called_once() - tool_definitions = manager.list_tools() schema = tool_definitions[0].input_schema assert [item.name for item in tool_definitions] == ["recognize_captcha"] diff --git a/tests/test_agent_resource_flow_permissions.py b/tests/test_agent_resource_flow_permissions.py index 6f594677a..8df8b4040 100644 --- a/tests/test_agent_resource_flow_permissions.py +++ b/tests/test_agent_resource_flow_permissions.py @@ -5,6 +5,8 @@ import json from types import SimpleNamespace from unittest.mock import AsyncMock, patch +from app.agent.tools.catalog import ToolCatalogSnapshot +from app.agent.tools.factory import MoviePilotToolFactory from app.agent.tools.impl.edit_file import EditFileTool from app.agent.tools.impl.list_directory import ListDirectoryTool from app.agent.tools.impl.query_downloaders import QueryDownloadersTool @@ -28,10 +30,16 @@ def test_non_admin_manager_exposes_resource_flow_helper_tools(): """普通用户应能看到搜索、订阅、下载流程所需的辅助工具。""" site_tool = QuerySitesTool(session_id="session-1", user_id="10001") downloader_tool = QueryDownloadersTool(session_id="session-1", user_id="10001") + catalog = ToolCatalogSnapshot.from_tools( + [site_tool, downloader_tool], + plugin_revision=0, + factory_revision="test", + ) - with patch( - "app.agent.tools.manager.MoviePilotToolFactory.create_tools", - return_value=[site_tool, downloader_tool], + with patch.object( + MoviePilotToolFactory, + "create_catalog", + return_value=catalog, ): manager = MoviePilotToolsManager(is_admin=False) @@ -48,10 +56,14 @@ def test_non_admin_manager_exposes_restricted_file_tools(): EditFileTool(session_id="session-1", user_id="10001"), ListDirectoryTool(session_id="session-1", user_id="10001"), ] + catalog = ToolCatalogSnapshot.from_tools( + tools, plugin_revision=0, factory_revision="test" + ) - with patch( - "app.agent.tools.manager.MoviePilotToolFactory.create_tools", - return_value=tools, + with patch.object( + MoviePilotToolFactory, + "create_catalog", + return_value=catalog, ): manager = MoviePilotToolsManager(is_admin=False) diff --git a/tests/test_agent_runtime_loader.py b/tests/test_agent_runtime_loader.py new file mode 100644 index 000000000..2cb7fa57a --- /dev/null +++ b/tests/test_agent_runtime_loader.py @@ -0,0 +1,674 @@ +from __future__ import annotations + +import asyncio +import importlib +import sys +import threading +import types +from concurrent.futures import ThreadPoolExecutor + +import pytest + +from app.runtime.capabilities.errors import ( + CapabilityOperationError, + CapabilityRuntimeClosedError, +) +from app.runtime.capabilities.model import ( + CapabilityLifecycleState, + CapabilityMaterializationState, +) + + +@pytest.fixture +def runtime_loader(monkeypatch): + """为每个用例提供未构建、未关闭的 Agent Capability Runtime。""" + from app.agent import runtime_loader as module + + monkeypatch.setattr(module, "_agent_runtime", None) + for implementation_module in ( + "app.agent.orchestrator", + "app.agent.tools.factory", + ): + monkeypatch.delitem(sys.modules, implementation_module, raising=False) + return module + + +class _FakeManager: + """记录异步 service 生命周期,并支持测试控制初始化时序。""" + + def __init__(self) -> None: + self.initialize_calls = 0 + self.close_calls = 0 + self.fail_initialize = False + self.initialize_entered: asyncio.Event | None = None + self.initialize_release: asyncio.Event | None = None + + async def initialize(self) -> None: + self.initialize_calls += 1 + if self.initialize_entered is not None: + self.initialize_entered.set() + if self.initialize_release is not None: + await self.initialize_release.wait() + if self.fail_initialize: + raise RuntimeError("service initialization failed") + + async def close(self) -> None: + self.close_calls += 1 + + +def _fake_agent_modules(manager: object | None = None) -> dict[str, types.ModuleType]: + orchestrator = types.ModuleType("app.agent.orchestrator") + orchestrator.agent_manager = manager if manager is not None else object() + orchestrator.MoviePilotAgent = type("MoviePilotAgent", (), {}) + tools = types.ModuleType("app.agent.tools.factory") + tools.MoviePilotToolFactory = type("MoviePilotToolFactory", (), {}) + return { + orchestrator.__name__: orchestrator, + tools.__name__: tools, + } + + +def test_registry_discovery_does_not_import_agent_implementation( + runtime_loader, + monkeypatch, +) -> None: + """声明发现只能读取 TOML,不得导入 Agent、LLM 或工具实现。""" + imported = [] + monkeypatch.setattr( + "app.agent.capabilities.adapter.importlib.import_module", + lambda name: imported.append(name), + ) + + runtime = runtime_loader._ensure_runtime() + + assert {spec.id for spec in runtime.list_specs()} == { + "agent.manager", + "agent.moviepilot_type", + "agent.service", + "agent.tool_factory", + } + assert imported == [] + + +def test_concurrent_manager_first_use_is_single_flight( + runtime_loader, + monkeypatch, +) -> None: + """并发首用必须只导入一次并向全部调用者发布同一 canonical 对象。""" + modules = _fake_agent_modules() + import_calls = [] + import_entered = threading.Event() + import_release = threading.Event() + + def import_module(name: str): + import_calls.append(name) + import_entered.set() + assert import_release.wait(timeout=5) + monkeypatch.setitem(sys.modules, name, modules[name]) + return modules[name] + + monkeypatch.setattr( + "app.agent.capabilities.adapter.importlib.import_module", + import_module, + ) + with ThreadPoolExecutor(max_workers=8) as executor: + futures = [executor.submit(runtime_loader.get_agent_manager) for _ in range(8)] + assert import_entered.wait(timeout=5) + import_release.set() + results = [future.result(timeout=5) for future in futures] + + assert all( + result is modules["app.agent.orchestrator"].agent_manager for result in results + ) + assert ( + runtime_loader.get_moviepilot_agent_type() + is modules["app.agent.orchestrator"].MoviePilotAgent + ) + manager_snapshot = runtime_loader._agent_runtime.snapshot("agent.manager") + assert manager_snapshot.materialization is CapabilityMaterializationState.RESOLVED + assert manager_snapshot.lifecycle is CapabilityLifecycleState.DISCOVERED + assert manager_snapshot.visible is False + assert import_calls == ["app.agent.orchestrator"] + + +def test_tool_factory_has_independent_first_use_entrypoint( + runtime_loader, + monkeypatch, +) -> None: + """工具工厂可独立首用,不需要先解析完整 Agent 编排模块。""" + modules = _fake_agent_modules() + import_calls = [] + + def import_module(name: str): + import_calls.append(name) + monkeypatch.setitem(sys.modules, name, modules[name]) + return modules[name] + + monkeypatch.setattr( + "app.agent.capabilities.adapter.importlib.import_module", + import_module, + ) + + assert runtime_loader.is_tool_factory_materialized() is False + assert ( + runtime_loader.get_tool_factory() + is modules["app.agent.tools.factory"].MoviePilotToolFactory + ) + assert runtime_loader.is_tool_factory_materialized() is True + assert import_calls == ["app.agent.tools.factory"] + + +def test_materialization_query_does_not_construct_runtime( + runtime_loader, + monkeypatch, +) -> None: + """只读物化查询在 Runtime 未构建时必须直接返回 False。""" + build_calls = [] + monkeypatch.setattr( + runtime_loader, + "_build_agent_runtime", + lambda: build_calls.append(True), + ) + + assert runtime_loader.is_tool_factory_materialized() is False + assert runtime_loader.get_running_agent_manager() is None + assert build_calls == [] + + +@pytest.mark.anyio +async def test_service_first_and_entrypoint_first_share_canonical_identity( + runtime_loader, + monkeypatch, +) -> None: + """资源轴和兼容物化轴无论谁先解析,都必须共享 canonical manager。""" + manager = _FakeManager() + modules = _fake_agent_modules(manager) + monkeypatch.setattr( + "app.agent.capabilities.adapter.settings.AI_AGENT_ENABLE", + True, + ) + + def import_module(name: str): + monkeypatch.setitem(sys.modules, name, modules[name]) + return modules[name] + + monkeypatch.setattr( + "app.agent.capabilities.adapter.importlib.import_module", + import_module, + ) + + service_first = await runtime_loader.activate_agent_service() + compat_after = runtime_loader.get_agent_manager() + + assert service_first is manager + assert compat_after is manager + assert runtime_loader.get_running_agent_manager() is manager + assert manager.initialize_calls == 1 + snapshot = runtime_loader._agent_runtime.snapshot("agent.service") + assert snapshot.lifecycle is CapabilityLifecycleState.RUNNING + assert snapshot.visible is True + + await runtime_loader.begin_agent_shutdown() + + assert manager.close_calls == 1 + assert runtime_loader.get_running_agent_manager() is None + with pytest.raises(CapabilityRuntimeClosedError): + runtime_loader.get_agent_manager() + with pytest.raises(CapabilityRuntimeClosedError): + runtime_loader.get_moviepilot_agent_type() + with pytest.raises(CapabilityRuntimeClosedError): + runtime_loader.get_tool_factory() + with pytest.raises(CapabilityRuntimeClosedError): + await runtime_loader.activate_agent_service() + + +@pytest.mark.anyio +async def test_entrypoint_first_then_service_initializes_same_manager_once( + runtime_loader, + monkeypatch, +) -> None: + """兼容 getter 先物化时不初始化,随后 service 只初始化同一对象一次。""" + manager = _FakeManager() + modules = _fake_agent_modules(manager) + monkeypatch.setattr( + "app.agent.capabilities.adapter.settings.AI_AGENT_ENABLE", + True, + ) + + def import_module(name: str): + monkeypatch.setitem(sys.modules, name, modules[name]) + return modules[name] + + monkeypatch.setattr( + "app.agent.capabilities.adapter.importlib.import_module", + import_module, + ) + + assert runtime_loader.get_agent_manager() is manager + assert manager.initialize_calls == 0 + assert await runtime_loader.activate_agent_service() is manager + assert await runtime_loader.activate_agent_service() is manager + assert manager.initialize_calls == 1 + + +@pytest.mark.anyio +async def test_concurrent_service_first_use_initializes_once( + runtime_loader, + monkeypatch, +) -> None: + """并发 service 首启只能 initialize 一次并发布同一实例。""" + manager = _FakeManager() + manager.initialize_entered = asyncio.Event() + manager.initialize_release = asyncio.Event() + modules = _fake_agent_modules(manager) + monkeypatch.setattr( + "app.agent.capabilities.adapter.settings.AI_AGENT_ENABLE", + True, + ) + monkeypatch.setattr( + "app.agent.capabilities.adapter.importlib.import_module", + lambda name: ( + monkeypatch.setitem(sys.modules, name, modules[name]) or modules[name] + ), + ) + + tasks = [ + asyncio.create_task(runtime_loader.activate_agent_service()) for _ in range(8) + ] + await manager.initialize_entered.wait() + assert runtime_loader.get_agent_manager() is manager + assert runtime_loader.get_running_agent_manager() is None + manager.initialize_release.set() + results = await asyncio.gather(*tasks) + + assert all(result is manager for result in results) + assert manager.initialize_calls == 1 + assert runtime_loader.get_running_agent_manager() is manager + + +@pytest.mark.anyio +async def test_concurrent_service_and_entrypoint_first_use_share_identity( + runtime_loader, + monkeypatch, +) -> None: + """两个 spec 并发首解析时也只能引用同一个 canonical manager。""" + manager = _FakeManager() + modules = _fake_agent_modules(manager) + import_barrier = threading.Barrier(2) + import_calls = [] + monkeypatch.setattr( + "app.agent.capabilities.adapter.settings.AI_AGENT_ENABLE", + True, + ) + + def import_module(name: str): + import_calls.append(name) + import_barrier.wait(timeout=5) + monkeypatch.setitem(sys.modules, name, modules[name]) + return modules[name] + + monkeypatch.setattr( + "app.agent.capabilities.adapter.importlib.import_module", + import_module, + ) + + service_task = asyncio.create_task(runtime_loader.activate_agent_service()) + entrypoint_task = asyncio.create_task( + asyncio.to_thread(runtime_loader.get_agent_manager) + ) + service, entrypoint = await asyncio.gather(service_task, entrypoint_task) + + assert service is manager + assert entrypoint is manager + assert runtime_loader.get_running_agent_manager() is manager + assert manager.initialize_calls == 1 + assert import_calls == ["app.agent.orchestrator"] * 2 + + await runtime_loader.begin_agent_shutdown() + + +@pytest.mark.anyio +async def test_service_failure_is_not_published_and_requires_retry( + runtime_loader, + monkeypatch, +) -> None: + """初始化失败必须清理候选、保持不可见,并要求显式 retry。""" + manager = _FakeManager() + manager.fail_initialize = True + modules = _fake_agent_modules(manager) + monkeypatch.setattr( + "app.agent.capabilities.adapter.settings.AI_AGENT_ENABLE", + True, + ) + monkeypatch.setattr( + "app.agent.capabilities.adapter.importlib.import_module", + lambda name: ( + monkeypatch.setitem(sys.modules, name, modules[name]) or modules[name] + ), + ) + + with pytest.raises(CapabilityOperationError, match="initialization failed"): + await runtime_loader.activate_agent_service() + + assert runtime_loader.get_running_agent_manager() is None + failed = runtime_loader._agent_runtime.snapshot("agent.service") + assert failed.lifecycle is CapabilityLifecycleState.FAILED + assert failed.visible is False + assert manager.close_calls == 1 + with pytest.raises(CapabilityOperationError, match="retry=True"): + await runtime_loader.activate_agent_service() + + manager.fail_initialize = False + assert await runtime_loader.activate_agent_service(retry=True) is manager + assert manager.initialize_calls == 2 + + +@pytest.mark.anyio +async def test_shutdown_racing_service_first_use_fails_closed( + runtime_loader, + monkeypatch, +) -> None: + """关闭与 service 首启竞争时不得发布对象,并须清理已初始化候选。""" + manager = _FakeManager() + manager.initialize_entered = asyncio.Event() + manager.initialize_release = asyncio.Event() + modules = _fake_agent_modules(manager) + monkeypatch.setattr( + "app.agent.capabilities.adapter.settings.AI_AGENT_ENABLE", + True, + ) + monkeypatch.setattr( + "app.agent.capabilities.adapter.importlib.import_module", + lambda name: ( + monkeypatch.setitem(sys.modules, name, modules[name]) or modules[name] + ), + ) + + activation = asyncio.create_task(runtime_loader.activate_agent_service()) + await manager.initialize_entered.wait() + shutdown = asyncio.create_task(runtime_loader.begin_agent_shutdown()) + await asyncio.sleep(0) + manager.initialize_release.set() + + with pytest.raises(CapabilityRuntimeClosedError): + await activation + await shutdown + + assert runtime_loader.get_running_agent_manager() is None + assert manager.initialize_calls == 1 + assert manager.close_calls == 1 + snapshot = runtime_loader._agent_runtime.snapshot("agent.service") + assert snapshot.lifecycle is CapabilityLifecycleState.STOPPED + assert snapshot.visible is False + + +@pytest.mark.anyio +async def test_disabled_service_reconcile_stays_unmaterialized( + runtime_loader, + monkeypatch, +) -> None: + """selector 为 false 时协调结果为空且不得导入 orchestrator。""" + imported = [] + monkeypatch.setattr( + "app.agent.capabilities.adapter.settings.AI_AGENT_ENABLE", + False, + ) + monkeypatch.setattr( + "app.agent.capabilities.adapter.importlib.import_module", + lambda name: imported.append(name), + ) + + assert await runtime_loader.activate_agent_service() is None + assert runtime_loader.get_running_agent_manager() is None + snapshot = runtime_loader._agent_runtime.snapshot("agent.service") + assert snapshot.materialization is CapabilityMaterializationState.UNRESOLVED + assert snapshot.lifecycle is CapabilityLifecycleState.STOPPED + assert imported == [] + + +@pytest.mark.anyio +async def test_empty_shutdown_does_not_import_agent_or_tools( + runtime_loader, + monkeypatch, +) -> None: + """空载关闭只解析 data-only manifests,不导入编排器或工具实现。""" + imported = [] + monkeypatch.setattr( + "app.agent.capabilities.adapter.importlib.import_module", + lambda name: imported.append(name), + ) + + await runtime_loader.begin_agent_shutdown() + + assert imported == [] + assert runtime_loader.get_running_agent_manager() is None + assert runtime_loader.is_tool_factory_materialized() is False + + +@pytest.mark.anyio +async def test_disable_reconcile_waits_for_concurrent_service_start( + runtime_loader, + monkeypatch, +) -> None: + """关闭配置与首启竞争时必须等待启动并最终撤销实例。""" + manager = _FakeManager() + manager.initialize_entered = asyncio.Event() + manager.initialize_release = asyncio.Event() + modules = _fake_agent_modules(manager) + monkeypatch.setattr( + "app.agent.capabilities.adapter.settings.AI_AGENT_ENABLE", + True, + ) + monkeypatch.setattr( + "app.agent.capabilities.adapter.importlib.import_module", + lambda name: ( + monkeypatch.setitem(sys.modules, name, modules[name]) or modules[name] + ), + ) + + activation = asyncio.create_task(runtime_loader.activate_agent_service()) + await manager.initialize_entered.wait() + monkeypatch.setattr( + "app.agent.capabilities.adapter.settings.AI_AGENT_ENABLE", + False, + ) + disable = asyncio.create_task( + runtime_loader.reconcile_agent_service( + reason="config_changed", + changed_keys={"AI_AGENT_ENABLE"}, + retry=True, + ) + ) + await asyncio.sleep(0) + manager.initialize_release.set() + + assert await activation is manager + assert await disable is None + assert runtime_loader.get_running_agent_manager() is None + assert manager.initialize_calls == 1 + assert manager.close_calls == 1 + + +@pytest.mark.anyio +async def test_config_reconcile_hot_switches_service_generations( + runtime_loader, + monkeypatch, +) -> None: + """watch 命中的配置切换应停止并重启同一 canonical service。""" + manager = _FakeManager() + modules = _fake_agent_modules(manager) + monkeypatch.setattr( + "app.agent.capabilities.adapter.importlib.import_module", + lambda name: ( + monkeypatch.setitem(sys.modules, name, modules[name]) or modules[name] + ), + ) + monkeypatch.setattr( + "app.agent.capabilities.adapter.settings.AI_AGENT_ENABLE", + False, + ) + + assert await runtime_loader.activate_agent_service() is None + assert ( + await runtime_loader.reconcile_agent_service( + reason="config_changed", + changed_keys={"UNRELATED"}, + retry=True, + ) + is None + ) + assert manager.initialize_calls == 0 + + monkeypatch.setattr( + "app.agent.capabilities.adapter.settings.AI_AGENT_ENABLE", + True, + ) + assert ( + await runtime_loader.reconcile_agent_service( + reason="config_changed", + changed_keys={"AI_AGENT_ENABLE"}, + retry=True, + ) + is manager + ) + assert manager.initialize_calls == 1 + + monkeypatch.setattr( + "app.agent.capabilities.adapter.settings.AI_AGENT_ENABLE", + False, + ) + assert ( + await runtime_loader.reconcile_agent_service( + reason="config_changed", + changed_keys={"AI_AGENT_ENABLE"}, + retry=True, + ) + is None + ) + assert manager.close_calls == 1 + assert runtime_loader.get_running_agent_manager() is None + + monkeypatch.setattr( + "app.agent.capabilities.adapter.settings.AI_AGENT_ENABLE", + True, + ) + assert ( + await runtime_loader.reconcile_agent_service( + reason="config_changed", + changed_keys={"AI_AGENT_ENABLE"}, + retry=True, + ) + is manager + ) + assert manager.initialize_calls == 2 + assert runtime_loader._agent_runtime.snapshot("agent.service").generation == 3 + + +@pytest.mark.anyio +async def test_config_reconcile_after_shutdown_cannot_restart_service( + runtime_loader, + monkeypatch, +) -> None: + """Runtime 关闭后即使 selector 再次为 true,配置协调也必须拒绝重启。""" + manager = _FakeManager() + modules = _fake_agent_modules(manager) + monkeypatch.setattr( + "app.agent.capabilities.adapter.settings.AI_AGENT_ENABLE", + True, + ) + monkeypatch.setattr( + "app.agent.capabilities.adapter.importlib.import_module", + lambda name: ( + monkeypatch.setitem(sys.modules, name, modules[name]) or modules[name] + ), + ) + + assert await runtime_loader.activate_agent_service() is manager + await runtime_loader.begin_agent_shutdown() + + with pytest.raises(CapabilityRuntimeClosedError): + await runtime_loader.reconcile_agent_service( + reason="config_changed", + changed_keys={"AI_AGENT_ENABLE"}, + retry=True, + ) + assert manager.initialize_calls == 1 + assert manager.close_calls == 1 + assert runtime_loader.get_running_agent_manager() is None + + +@pytest.mark.anyio +async def test_real_agent_manager_can_restart_across_config_generations( + runtime_loader, + monkeypatch, +) -> None: + """真实 AgentManager 在配置热切换后必须重新建立运行代际。""" + orchestrator = importlib.import_module("app.agent.orchestrator") + + manager = orchestrator.AgentManager() + memory_events = [] + + async def close_memory() -> None: + memory_events.append("close") + + monkeypatch.setattr( + orchestrator.memory_manager, + "initialize", + lambda: memory_events.append("initialize"), + ) + monkeypatch.setattr(orchestrator.memory_manager, "close", close_memory) + monkeypatch.setattr(orchestrator, "agent_manager", manager) + monkeypatch.setattr( + "app.agent.capabilities.adapter.settings.AI_AGENT_ENABLE", + False, + ) + + assert await runtime_loader.activate_agent_service() is None + + monkeypatch.setattr( + "app.agent.capabilities.adapter.settings.AI_AGENT_ENABLE", + True, + ) + assert ( + await runtime_loader.reconcile_agent_service( + reason="config_changed", + changed_keys={"AI_AGENT_ENABLE"}, + retry=True, + ) + is manager + ) + assert manager._accepting_tasks is True + + monkeypatch.setattr( + "app.agent.capabilities.adapter.settings.AI_AGENT_ENABLE", + False, + ) + assert ( + await runtime_loader.reconcile_agent_service( + reason="config_changed", + changed_keys={"AI_AGENT_ENABLE"}, + retry=True, + ) + is None + ) + assert manager._accepting_tasks is False + + monkeypatch.setattr( + "app.agent.capabilities.adapter.settings.AI_AGENT_ENABLE", + True, + ) + assert ( + await runtime_loader.reconcile_agent_service( + reason="config_changed", + changed_keys={"AI_AGENT_ENABLE"}, + retry=True, + ) + is manager + ) + assert manager._accepting_tasks is True + assert memory_events == ["initialize", "close", "initialize"] + + await runtime_loader.begin_agent_shutdown() + + assert manager._accepting_tasks is False + assert memory_events == ["initialize", "close", "initialize", "close"] diff --git a/tests/test_agent_scheduled_tasks.py b/tests/test_agent_scheduled_tasks.py index 88fdd3b87..eaaac8c6c 100644 --- a/tests/test_agent_scheduled_tasks.py +++ b/tests/test_agent_scheduled_tasks.py @@ -385,7 +385,15 @@ async def test_interrupted_date_task_manual_run_disables_and_removes_job( scheduler.init_agent_task_jobs() process_message = AsyncMock(return_value="执行完成") - monkeypatch.setattr("app.agent.orchestrator.agent_manager.process_message", process_message) + manager = SimpleNamespace( + execute_scheduled_task=AgentManager.execute_scheduled_task, + process_message=process_message, + ) + manager.execute_scheduled_task = AgentManager.execute_scheduled_task.__get__(manager) + monkeypatch.setattr( + "app.agent.runtime_loader.get_running_agent_manager", + lambda: manager, + ) assert await scheduler.execute_agent_task( task.id, @@ -410,7 +418,10 @@ async def test_scheduler_propagates_scheduled_trigger_source(monkeypatch) -> Non task = _add_agent_task("cron", "0 * * * *", "scheduled-source") scheduler = _build_agent_task_scheduler() execute = AsyncMock(return_value=(True, "执行完成")) - monkeypatch.setattr("app.agent.orchestrator.agent_manager.execute_scheduled_task", execute) + monkeypatch.setattr( + "app.agent.runtime_loader.get_running_agent_manager", + lambda: SimpleNamespace(execute_scheduled_task=execute), + ) assert await scheduler.execute_agent_task(task.id) == (True, "执行完成") execute.assert_awaited_once_with(task.id, trigger_source="scheduled") @@ -1048,6 +1059,7 @@ async def test_agent_manager_close_finishes_active_and_queued_scheduled_tasks() for index in range(2) ] manager = AgentManager() + await manager.initialize() started = asyncio.Event() async def block_current_task(_task): @@ -1069,11 +1081,14 @@ async def test_agent_manager_close_finishes_active_and_queued_scheduled_tasks() await manager.close() results = await asyncio.gather(*executions, return_exceptions=True) - assert all(isinstance(result, asyncio.CancelledError) for result in results) + assert all( + result == (False, "Agent 定时任务执行失败:AgentManager 已关闭") + for result in results + ) for task in tasks: completed = AgentTaskOper().get(task.id) assert completed.last_status == "failed" - assert completed.last_result == "Agent 定时任务已取消" + assert completed.last_result == "Agent 定时任务执行失败:AgentManager 已关闭" assert completed.run_count == 1 diff --git a/tests/test_agent_session_status.py b/tests/test_agent_session_status.py index 1d189740f..50c4d1b6b 100644 --- a/tests/test_agent_session_status.py +++ b/tests/test_agent_session_status.py @@ -1,18 +1,3 @@ -# 把真实 Agent 服务注册进 application 门面(幂等),供测试 patch 门面背后的单例方法。 -from app.agent.llm import AgentCapabilityManager, LLMHelper -from app.agent.orchestrator import agent_manager -from app.agent.prompt import prompt_manager -from app.agent.prompt.transfer_redo import build_manual_redo_prompt -from app.application.agent import register_agent_services - -register_agent_services( - agent_manager=agent_manager, - prompt_manager=prompt_manager, - capability_manager=AgentCapabilityManager, - llm_helper=LLMHelper, - manual_redo_prompt_builder=build_manual_redo_prompt, -) - import asyncio import unittest from datetime import datetime, timedelta @@ -109,8 +94,8 @@ class TestAgentSessionStatus(unittest.TestCase): with ( patch( - "app.application.agent._agent_manager.get_session_status", - return_value=status, + "app.chain.message.get_running_agent_manager", + return_value=SimpleNamespace(get_session_status=lambda **_: status), ), patch.object(chain, "post_message") as post_message, ): diff --git a/tests/test_agent_system_settings_tools.py b/tests/test_agent_system_settings_tools.py index aabe73ad9..a22159a4d 100644 --- a/tests/test_agent_system_settings_tools.py +++ b/tests/test_agent_system_settings_tools.py @@ -5,6 +5,8 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest +from app.agent.tools.catalog import ToolCatalogSnapshot +from app.agent.tools.factory import MoviePilotToolFactory from app.agent.tools.impl._system_setting_utils import list_setting_specs from app.agent.tools.impl.query_system_settings import QuerySystemSettingsTool from app.agent.tools.impl.update_system_settings import UpdateSystemSettingsTool @@ -330,10 +332,14 @@ class TestAgentSystemSettingsTools(unittest.TestCase): def test_tool_manager_blocks_admin_tools_for_non_admin_context(self): tool = QuerySystemSettingsTool(session_id="session-1", user_id="10001") + catalog = ToolCatalogSnapshot.from_tools( + [tool], plugin_revision=0, factory_revision="test" + ) - with patch( - "app.agent.tools.manager.MoviePilotToolFactory.create_tools", - return_value=[tool], + with patch.object( + MoviePilotToolFactory, + "create_catalog", + return_value=catalog, ): manager = MoviePilotToolsManager(is_admin=False) result = asyncio.run( diff --git a/tests/test_agent_tool_streaming.py b/tests/test_agent_tool_streaming.py index fb0e9987d..558af59ef 100644 --- a/tests/test_agent_tool_streaming.py +++ b/tests/test_agent_tool_streaming.py @@ -12,7 +12,7 @@ from app.agent.callback import StreamingHandler from app.agent.middleware.subagents import is_subagent_stream_metadata from app.agent.tools.base import MoviePilotTool from app.agent.tools.impl.send_voice_message import SendVoiceMessageTool -from app.api.endpoints.openai import _OpenAIStreamingHandler +from app.api.endpoints.openai import _get_openai_streaming_handler_type from app.runtime.config import settings from app.schemas.message import MessageResponse from app.schemas.types import MessageChannel, NotificationType @@ -321,7 +321,7 @@ class TestAgentToolStreaming: def test_openai_streaming_handler_flushes_pending_summary_to_queue(self): """校验 OpenAI 流式处理器将待发送摘要推入队列。""" async def _run(): - handler = _OpenAIStreamingHandler() + handler = _get_openai_streaming_handler_type()() queue: asyncio.Queue = asyncio.Queue() handler.bind_queue(queue) await handler.start_streaming() diff --git a/tests/test_architecture_dependencies.py b/tests/test_architecture_dependencies.py index aab554eda..42bfb72bd 100644 --- a/tests/test_architecture_dependencies.py +++ b/tests/test_architecture_dependencies.py @@ -557,6 +557,16 @@ def test_chain_does_not_import_agent_implementation(): assert violations == {} +def test_agent_application_facade_does_not_import_agent_implementation(): + """Agent application 门面只能接收组合根注入,不能反向解析具体实现。""" + dependencies = _build_module_graph()["app.application.agent"] + assert { + dependency + for dependency in dependencies + if dependency.startswith("app.agent") + } == set() + + def test_agent_tools_do_not_import_entrypoint_internals(): """Agent 工具不得穿透导入 HTTP 端点、调度器与命令注册表内部实现。 diff --git a/tests/test_search_ai_recommend.py b/tests/test_search_ai_recommend.py index 9b3ca080b..be2739fe3 100644 --- a/tests/test_search_ai_recommend.py +++ b/tests/test_search_ai_recommend.py @@ -112,6 +112,14 @@ class SearchChainAIRecommendTest(unittest.IsolatedAsyncioTestCase): "render_system_task_message", return_value="PROMPT", ), + patch( + "app.application.agent.get_prompt_manager", + return_value=prompt_manager, + ), + patch( + "app.application.agent.get_running_agent_manager", + return_value=agent_manager, + ), patch.object( agent_manager, "run_background_prompt", diff --git a/tests/test_telegram_typing_lifecycle.py b/tests/test_telegram_typing_lifecycle.py index dda99f46e..abfec8039 100644 --- a/tests/test_telegram_typing_lifecycle.py +++ b/tests/test_telegram_typing_lifecycle.py @@ -1,18 +1,3 @@ -# 把真实 Agent 服务注册进 application 门面(幂等),供测试 patch 门面背后的单例方法。 -from app.agent.llm import AgentCapabilityManager, LLMHelper -from app.agent.orchestrator import agent_manager -from app.agent.prompt import prompt_manager -from app.agent.prompt.transfer_redo import build_manual_redo_prompt -from app.application.agent import register_agent_services - -register_agent_services( - agent_manager=agent_manager, - prompt_manager=prompt_manager, - capability_manager=AgentCapabilityManager, - llm_helper=LLMHelper, - manual_redo_prompt_builder=build_manual_redo_prompt, -) - import asyncio import threading import time @@ -278,14 +263,15 @@ class TestTelegramTypingLifecycle(unittest.TestCase): ) as start_status, patch( "app.chain.message.settings.AI_AGENT_ENABLE", True ), patch( - "app.application.agent._agent_manager.process_message", - new_callable=AsyncMock, - ) as process_message, patch( + "app.chain.message.get_running_agent_manager", + ) as get_running_manager, patch( "app.chain.message.asyncio.run_coroutine_threadsafe", side_effect=lambda coro, _loop: (coro.close(), Mock())[1], ), patch.object( chain, "_mark_message_processing_finished" ) as finish_status: + process_message = AsyncMock() + get_running_manager.return_value.process_message = process_message chain.handle_message( channel=MessageChannel.Telegram, source="telegram-test", diff --git a/tests/test_transfer_failed_retry_buttons.py b/tests/test_transfer_failed_retry_buttons.py index 223d9e4e5..a08021841 100644 --- a/tests/test_transfer_failed_retry_buttons.py +++ b/tests/test_transfer_failed_retry_buttons.py @@ -1,18 +1,3 @@ -# 把真实 Agent 服务注册进 application 门面(幂等),供测试 patch 门面背后的单例方法。 -from app.agent.llm import AgentCapabilityManager, LLMHelper -from app.agent.orchestrator import agent_manager -from app.agent.prompt import prompt_manager -from app.agent.prompt.transfer_redo import build_manual_redo_prompt -from app.application.agent import register_agent_services - -register_agent_services( - agent_manager=agent_manager, - prompt_manager=prompt_manager, - capability_manager=AgentCapabilityManager, - llm_helper=LLMHelper, - manual_redo_prompt_builder=build_manual_redo_prompt, -) - import unittest import asyncio import sys @@ -147,16 +132,15 @@ class TestTransferFailedRetryButtons(unittest.TestCase): with patch.object(settings, "AI_AGENT_ENABLE", True): with patch( - "app.chain.transfer.TransferHistoryOper" - ) as history_oper_cls, patch( - # mixin 中按自身模块命名空间解析 TransferHistoryOper,需同步镜像 "app.chain._transfer.TransferHistoryOper" - ) as mixins_history_oper_cls, patch( - "app.chain.transfer.asyncio.run_coroutine_threadsafe", + ) as history_oper_cls, patch( + "app.chain._transfer.build_manual_redo_prompt", + return_value="retry transfer prompt", + ), patch( + "app.chain._transfer.asyncio.run_coroutine_threadsafe", side_effect=_close_pending_coro, ) as run_task: history_oper_cls.return_value.get.return_value = history - mixins_history_oper_cls.return_value.get.return_value = history with patch.object(chain, "post_message") as post_message: chain.handle_failed_transfer_callback( callback_data="transfer_ai_retry_34", @@ -219,21 +203,23 @@ class TestTransferFailedRetryButtons(unittest.TestCase): async def fake_async_post_message(*args, **kwargs): return None + from app.agent.prompt.transfer_redo import build_manual_redo_prompt + + manager = SimpleNamespace(run_background_prompt=fake_run_background_prompt) with patch.object(settings, "AI_AGENT_ENABLE", True): with patch( - "app.chain.transfer.TransferHistoryOper" - ) as history_oper_cls, patch( - # mixin 中按自身模块命名空间解析 TransferHistoryOper,需同步镜像 "app.chain._transfer.TransferHistoryOper" - ) as mixins_history_oper_cls, patch( - "app.application.agent._agent_manager.run_background_prompt", - side_effect=fake_run_background_prompt, + ) as history_oper_cls, patch( + "app.chain._transfer.build_manual_redo_prompt", + side_effect=build_manual_redo_prompt, ), patch( - "app.chain.transfer.asyncio.run_coroutine_threadsafe", + "app.chain._transfer.get_running_agent_manager", + return_value=manager, + ), patch( + "app.chain._transfer.asyncio.run_coroutine_threadsafe", side_effect=_run_pending_coro, ): history_oper_cls.return_value.get.return_value = history - mixins_history_oper_cls.return_value.get.return_value = history with patch.object(chain, "post_message"), patch.object( chain, "async_post_message", side_effect=fake_async_post_message ): diff --git a/tests/test_web_agent_stream.py b/tests/test_web_agent_stream.py index e908cb561..d5948f499 100644 --- a/tests/test_web_agent_stream.py +++ b/tests/test_web_agent_stream.py @@ -6,10 +6,11 @@ from threading import Event as ThreadEvent from types import SimpleNamespace from unittest.mock import AsyncMock, patch +import pytest + from app import schemas from app.agent import ReplyMode, agent_manager from app.api.endpoints.agent import ( - _WebAgentMoviePilotAgent, _WebAgentEventPublisher, _WEB_AGENT_FILE_REGISTRY, _WEB_AGENT_NOTICE_QUEUES, @@ -23,6 +24,7 @@ from app.api.endpoints.agent import ( _collect_web_agent_traditional_events, _dispatch_web_agent_notice_event, _extract_web_agent_notification_from_event_data, + _get_web_agent_type, _has_web_agent_traditional_interaction, _prepare_web_agent_audio_attachment_path, _transcribe_web_agent_audio_refs, @@ -41,6 +43,26 @@ from app.schemas.message import ChannelCapability, ChannelCapabilityManager from app.schemas.types import EventType, MessageChannel, NotificationType +@pytest.fixture(autouse=True) +def _running_agent_service(): + """本文件验证运行态 Web Agent 行为,显式提供已启动的 canonical manager。""" + was_accepting = agent_manager._accepting_tasks + agent_manager._accepting_tasks = True + MessageChain._user_sessions.clear() + try: + with patch( + "app.api.endpoints.agent.get_running_agent_manager", + return_value=agent_manager, + ), patch( + "app.chain.message.get_running_agent_manager", + return_value=agent_manager, + ): + yield + finally: + MessageChain._user_sessions.clear() + agent_manager._accepting_tasks = was_accepting + + def test_split_web_agent_output_extracts_verbose_tool_message(): """应将啰嗦模式工具提示拆成独立工具事件,并保留渠道展示文案。""" events = _split_web_agent_output("准备查询。\n\n⚙️ => 查询站点\n\n已完成") @@ -358,7 +380,7 @@ def test_has_web_agent_traditional_interaction_detects_pending_skills(): def test_web_agent_admin_context_uses_current_user_id(): """Web Agent 工具权限应按当前登录用户 ID 判断管理员身份。""" - agent = _WebAgentMoviePilotAgent( + agent = _get_web_agent_type()( session_id="web-agent:session", user_id="7", channel=MessageChannel.WebAgent.value, @@ -378,7 +400,7 @@ def test_web_agent_admin_context_uses_current_user_id(): def test_web_agent_reused_for_background_task_disables_streaming(): """Web Agent 被后台任务复用且渠道已清空时应改用非流式广播。""" - agent = _WebAgentMoviePilotAgent( + agent = _get_web_agent_type()( session_id="web-agent:scheduled-session", user_id="7", channel=None, @@ -394,7 +416,7 @@ def test_web_agent_reused_for_background_task_disables_streaming(): def test_web_agent_output_callback_receives_only_new_text(): """WebAgent 外部回调应接收增量,同时内部仍保留完整输出。""" outputs = [] - agent = _WebAgentMoviePilotAgent( + agent = _get_web_agent_type()( session_id="web-agent:incremental-output", user_id="7", channel=MessageChannel.WebAgent.value, @@ -414,7 +436,7 @@ def test_web_agent_output_callback_receives_only_new_text(): def test_web_agent_tool_summary_is_emitted_before_following_text(): """Web 工具状态应在调用发生时输出,不能拖到正文结束后。""" outputs = [] - agent = _WebAgentMoviePilotAgent( + agent = _get_web_agent_type()( session_id="web-agent:tool-order", user_id="7", channel=MessageChannel.WebAgent.value, @@ -716,8 +738,8 @@ def test_web_agent_stream_binds_session_to_agent_manager(): try: with patch("app.api.endpoints.agent.settings.AI_AGENT_ENABLE", True), patch( - "app.api.endpoints.agent._WebAgentMoviePilotAgent", - FakeWebAgent, + "app.api.endpoints.agent._get_web_agent_type", + return_value=FakeWebAgent, ): body = asyncio.run(scenario()) @@ -812,8 +834,8 @@ def test_web_agent_stream_emits_secret_result_only_as_protected_event(): try: with patch("app.api.endpoints.agent.settings.AI_AGENT_ENABLE", True), patch( - "app.api.endpoints.agent._WebAgentMoviePilotAgent", - FakeProtectedAgent, + "app.api.endpoints.agent._get_web_agent_type", + return_value=FakeProtectedAgent, ), patch( "app.api.endpoints.agent._save_web_agent_display_snapshot", ) as save_snapshot: @@ -1128,8 +1150,8 @@ def test_web_agent_stop_finishes_stream_without_error(): MessageChain, "bind_user_session", ), patch( - "app.api.endpoints.agent._WebAgentMoviePilotAgent", - BlockingWebAgent, + "app.api.endpoints.agent._get_web_agent_type", + return_value=BlockingWebAgent, ), patch( "app.api.endpoints.agent._save_web_agent_display_snapshot", ): @@ -1143,6 +1165,39 @@ def test_web_agent_stop_finishes_stream_without_error(): assert '"type": "error"' not in body +def test_web_agent_stream_rechecks_running_service_before_enqueue(): + """响应建立后服务若已关闭,生成器必须稳定返回错误且不向旧 manager 入队。""" + payload = schemas.AgentWebChatRequest( + text="检查状态", + session_id="shutdown-race", + ) + request = SimpleNamespace(is_disconnected=AsyncMock(return_value=False)) + user = SimpleNamespace(id=1, name="admin", is_superuser=True) + stale_manager = SimpleNamespace(process_message=AsyncMock()) + + async def scenario(): + response = await web_agent_stream(payload, request, user) + return "".join(await _collect_streaming_response(response)) + + with patch("app.api.endpoints.agent.settings.AI_AGENT_ENABLE", True), patch( + "app.api.endpoints.agent._is_web_agent_traditional_message", + return_value=False, + ), patch( + "app.api.endpoints.agent._has_web_agent_traditional_interaction", + return_value=False, + ), patch( + "app.api.endpoints.agent.get_running_agent_manager", + side_effect=[stale_manager, None], + ), patch( + "app.api.endpoints.agent._save_web_agent_display_snapshot", + ): + body = asyncio.run(scenario()) + + assert '"type": "error"' in body + assert '"type": "done"' in body + stale_manager.process_message.assert_not_awaited() + + def test_web_agent_traditional_stream_keeps_alive_and_saves_after_done(): """传统消息等待期间应保活,且展示快照不能阻塞终态。""" payload = schemas.AgentWebChatRequest(text="/状态", session_id="traditional-heartbeat")