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