refactor(agent): 按需加载 Agent 运行时 (#6336)

This commit is contained in:
InfinityPacer
2026-08-16 19:25:24 +08:00
committed by GitHub
parent e5f0c53069
commit 5b367011c5
56 changed files with 5059 additions and 628 deletions
+8
View File
@@ -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"
+218
View File
@@ -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 = []
+35
View File
@@ -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
View File
@@ -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
View File
@@ -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,
+137
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 = {
+60 -20
View File
@@ -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:
+13 -3
View File
@@ -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,
+11 -3
View File
@@ -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
View File
@@ -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
View File
@@ -1,26 +1,46 @@
"""Agent 编排服务门面。
chain 层需要触发 Agent 后台任务渲染提示词查询模型能力时统一经本模块调用
具体实现由 app.agent 在启动时注册形成依赖倒置
具体实现由 startup 组合根注册形成依赖倒置
chain -> application.agent <- agentstartup 在导入期注册
chain -> application.agent <- startup -> agent
静态依赖图上 application 不依赖 agentagent 作为入口层向 application
注册实现从而拆除 chain <-> agent 的互指环
注意本模块禁止静态导入 app.agent 下的任何模块含函数内导入
否则会形成 agent -> chain -> application -> agent 的新环
未注册时的兜底注册由 startup/agent_initializer 在导入期完成
门面保存 provider 而非重量级实现对象注册本身不会物化 AgentLLM 或工具树
本模块禁止静态或函数内导入 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)
+6 -3
View File
@@ -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}"
)
+5 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 -8
View File
@@ -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):
+7
View File
@@ -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 = (
+148 -33
View File
@@ -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}")
+3 -2
View File
@@ -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` |
+56
View File
@@ -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
+258 -2
View File
@@ -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(
{
+366 -15
View File
@@ -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 = [
+441 -3
View File
@@ -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
+460
View File
@@ -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": [],
}
+18 -17
View File
@@ -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"])
+4
View File
@@ -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())
+12 -5
View File
@@ -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"]
+1 -1
View File
@@ -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(
+4 -17
View File
@@ -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,
+12 -24
View File
@@ -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,
+8 -20
View File
@@ -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",
+245
View File
@@ -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 关闸并释放 serviceinitializer 只清理自身引用。"""
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)
+322
View File
@@ -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,
}
+177
View File
@@ -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 == {}
+21 -36
View File
@@ -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
+216
View File
@@ -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
+12 -5
View File
@@ -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"]
+18 -6
View File
@@ -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)
+674
View File
@@ -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"]
+19 -4
View File
@@ -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
+2 -17
View File
@@ -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,
):
+9 -3
View File
@@ -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(
+2 -2
View File
@@ -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()
+10
View File
@@ -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 端点、调度器与命令注册表内部实现。
+8
View File
@@ -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",
+4 -18
View File
@@ -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",
+15 -29
View File
@@ -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
):
+66 -11
View File
@@ -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")