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,
)