Merge remote-tracking branch 'origin/v3' into v3

# Conflicts:
#	app/api/endpoints/agent.py
#	app/api/endpoints/anthropic.py
#	app/api/endpoints/openai.py
#	app/chain/__init__.py
#	app/chain/message.py
#	app/chain/site.py
#	app/chain/subscribe.py
#	app/chain/transfer.py
#	app/modules/discord/__init__.py
#	app/modules/qqbot/__init__.py
#	app/modules/slack/__init__.py
#	app/modules/telegram/__init__.py
#	app/modules/wechat/__init__.py
#	app/runtime/extensions/module_manager.py
#	app/runtime/extensions/service_registry.py
#	tests/test_agent_interaction.py
#	tests/test_slash_command_interactions.py
#	tests/test_web_agent_stream.py
This commit is contained in:
jxxghp
2026-08-16 19:44:43 +08:00
232 changed files with 21375 additions and 6683 deletions
+157 -123
View File
@@ -4,9 +4,9 @@ import json
import re
import traceback
import uuid
import warnings
from dataclasses import dataclass
from datetime import datetime, timedelta
from enum import Enum
from typing import Any, Callable, Dict, List, Optional
from fastapi.concurrency import run_in_threadpool
@@ -17,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
@@ -61,14 +59,13 @@ 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,
select_legacy_mcp_tools,
)
from app.agent.tools.impl.query_system_settings import QuerySystemSettingsTool
from app.chain import ChainBase
from app.chain.agent import AgentChain
from app.runtime.config import settings
from app.runtime.events import eventmanager
from app.runtime.extensions.plugin_manager import PluginManager
@@ -81,11 +78,7 @@ from app.schemas.notification import ChannelCapabilityManager, ChannelCapability
from app.schemas.types import ChainEventType, EventType, NotificationChannel
from app.foundation.identity import SYSTEM_INTERNAL_USER_ID
class AgentChain(ChainBase):
"""Agent 业务处理链。"""
pass
warnings.filterwarnings("ignore", message=".*allowed_objects.*")
def _finish_processing_status(status: Optional[dict], user_id: Optional[str] = None) -> None:
@@ -321,15 +314,6 @@ class _ThinkTagStripper:
self.buffer = ""
class ReplyMode(str, Enum):
"""
Agent 最终回复处理模式。
"""
DISPATCH = "dispatch"
CAPTURE_ONLY = "capture_only"
HEARTBEAT_SESSION_PREFIX = "__agent_heartbeat_"
UNSUPPORTED_IMAGE_INPUT_MESSAGE = "当前模型不支持图片输入,请更换支持图片输入的模型,或在系统设置中关闭图片输入支持后重试。"
AGENT_EXECUTION_ERROR_PREFIX = "智能助手执行失败"
@@ -408,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,
@@ -424,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:
"""
@@ -1575,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,
@@ -1590,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,
@@ -1685,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]:
@@ -1737,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,
@@ -1922,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(
@@ -2453,9 +2438,16 @@ class _MessageTask:
protected_output_callback: Optional[Callable[[str], Optional[bool]]] = None
message_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智能体管理器
@@ -2473,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 使用状态。"""
@@ -2519,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:
"""
@@ -2622,6 +2628,7 @@ class AgentManager:
protected_output_callback: Optional[Callable[[str], Optional[bool]]] = None,
message_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:
"""
@@ -2650,38 +2657,40 @@ class AgentManager:
protected_output_callback=protected_output_callback,
message_callback=message_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
@@ -2713,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}")
@@ -2738,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:
@@ -2746,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
@@ -2825,6 +2845,9 @@ class AgentManager:
if task.message_callback is not None and hasattr(agent, "set_message_callback"):
agent.set_message_callback(task.message_callback)
if task.agent_setup is not None:
task.agent_setup(agent)
process_kwargs = {
"images": task.images,
"files": task.files,
@@ -2839,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)
@@ -2846,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:
@@ -2884,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:
@@ -2894,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:
@@ -2905,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,
@@ -2924,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,