From e1b703aacc4589fec01a1d9753b54d96cf21e94d Mon Sep 17 00:00:00 2001
From: DDSRem <73049927+DDSRem@users.noreply.github.com>
Date: Sun, 18 Oct 2026 21:49:57 +0800
Subject: [PATCH] refactor(agent): centralize graph resource ownership (#7062)
---
app/agent/graph.py | 266 ++++++++
app/agent/orchestrator.py | 637 +++++++-----------
app/api/endpoints/openai.py | 7 +-
docs/architecture-overview.md | 4 +-
docs/rules/05-architecture.md | 3 +-
scripts/evaluation/runtime.py | 2 +-
tests/agent_graph.py | 37 +
.../architecture/dependency-baseline.json | 15 +-
.../fixtures/architecture/mypy-baseline.json | 3 +-
tests/test_agent_api_lazy_imports.py | 30 +-
tests/test_agent_graph_cache.py | 17 +-
tests/test_agent_graph_resources.py | 335 +++++++++
tests/test_agent_recovery.py | 17 +-
tests/test_agent_side_effect_boundaries.py | 35 +-
tests/test_agent_summarization_streaming.py | 5 +-
tests/test_architecture_dependencies.py | 23 +
tests/test_llm_client_lifecycle.py | 31 +-
17 files changed, 978 insertions(+), 489 deletions(-)
create mode 100644 app/agent/graph.py
create mode 100644 tests/agent_graph.py
create mode 100644 tests/test_agent_graph_resources.py
diff --git a/app/agent/graph.py b/app/agent/graph.py
new file mode 100644
index 000000000..9da51224b
--- /dev/null
+++ b/app/agent/graph.py
@@ -0,0 +1,266 @@
+"""
+会话执行图的缓存及资源所有权。
+
+构建中的模型、中间件与已发布图使用同一资源范围;发布同步移交所有权,
+取消、构建失败和缓存替换只回收退休范围,未退出的消费者继续保留对应模型。
+"""
+
+import asyncio
+import inspect
+from collections.abc import AsyncIterator, Callable, Iterable
+from contextlib import asynccontextmanager
+from dataclasses import dataclass, field
+from datetime import datetime
+from typing import Any, Optional
+
+from app.agent.llm.helper import LLMHelper
+from app.agent.tools.catalog import ToolCatalogSnapshot
+from app.runtime.log import logger
+
+
+class GraphResources:
+ """
+ 一次构图或临时模型调用拥有的资源,不负责创建模型或业务中间件。
+ """
+
+ def __init__(self) -> None:
+ """
+ 按对象身份记录资源,避免模型相等比较及重复释放。
+ """
+ self.models: dict[int, Any] = {}
+ self.middlewares: dict[int, object] = {}
+ self._sealed = False
+
+ def own_model(self, model: Any) -> None:
+ """
+ 在创建模型后的下一个异步等待前接管其客户端。
+
+ :param model: 当前范围使用的模型;重复登记同一对象不会增加关闭次数
+ """
+ self.models[id(model)] = model
+
+ def own_middlewares(self, middlewares: Iterable[object]) -> None:
+ """
+ 接管提供关闭能力的中间件;关停期间接管时立即封住新任务入口。
+
+ :param middlewares: 图使用的中间件,未声明 close 的无资源中间件不登记
+ """
+ for middleware in middlewares:
+ if callable(getattr(middleware, "close", None)):
+ self.middlewares[id(middleware)] = middleware
+ if self._sealed:
+ self.seal()
+
+ def seal(self) -> None:
+ """
+ 同步禁止已接管控制器提交新任务,仍由异步回收确认真实退出。
+ """
+ self._sealed = True
+ for middleware in self.middlewares.values():
+ seal = getattr(middleware, "seal", None)
+ if callable(seal):
+ try:
+ seal()
+ except Exception as error:
+ logger.debug(f"封住子代理中间件失败: {error}")
+
+ @property
+ def empty(self) -> bool:
+ """
+ 所有消费者及模型都已经释放时返回 True。
+ """
+ return not self.models and not self.middlewares
+
+
+@dataclass
+class CompiledAgentBundle:
+ """
+ 会话内可复用的执行图、构造签名及其唯一资源范围。
+
+ :param signature: 模型、工具和运行配置的构造签名
+ :param agent: 已编译的执行图
+ :param streaming: 是否按流式模式构造
+ :param created_at: 图构造完成时间
+ :param tool_catalog: 主智能体工具目录;None 表示尚未提供
+ :param subagent_catalog: 子智能体工具目录;None 表示尚未提供
+ :param plugin_revision: 插件目录修订号;-1 表示尚未记录
+ :param mcp_config_signature: 外部 MCP 配置签名;空串表示尚未记录
+ :param catalog_checked_at: 最近目录复核时间;None 表示尚未复核
+ :param resources: 图的模型和中间件资源;默认建立空范围
+ """
+
+ signature: tuple[Any, ...]
+ agent: Any
+ streaming: bool
+ created_at: datetime
+ tool_catalog: Optional[ToolCatalogSnapshot] = None
+ subagent_catalog: Optional[ToolCatalogSnapshot] = None
+ plugin_revision: int = -1
+ mcp_config_signature: str = ""
+ catalog_checked_at: Optional[datetime] = None
+ resources: GraphResources = field(default_factory=GraphResources)
+
+
+class AgentGraphCache:
+ """
+ 单会话图资源 owner,统一跟踪构建、发布和退休范围。
+
+ 在会话所属事件循环内使用;资源登记与发布不等待,回收串行执行。
+ 关闭失败或取消时保留尚未确认释放的对象,后续失效或清理可继续回收。
+ """
+
+ def __init__(self, *, models_in_use: Callable[[], bool]) -> None:
+ """
+ 创建空图缓存,不启动任务或取得外部资源。
+
+ :param models_in_use: 后台复盘仍使用共享模型时返回 True,暂缓退休模型释放
+ """
+ self._models_in_use = models_in_use
+ self._current: Optional[CompiledAgentBundle] = None
+ self._building: dict[int, GraphResources] = {}
+ self._retired: dict[int, GraphResources] = {}
+ self._collection_lock = asyncio.Lock()
+ self._sealed = False
+
+ @property
+ def current(self) -> Optional[CompiledAgentBundle]:
+ """
+ 返回当前已发布图;失效后立即为 None,即使资源仍待回收。
+ """
+ return self._current
+
+ def get(self, signature: tuple[Any, ...], streaming: bool) -> Optional[Any]:
+ """
+ 只复用构造签名和流式模式一致的当前图。
+ """
+ bundle = self._current
+ if bundle and bundle.signature == signature and bundle.streaming == streaming:
+ return bundle.agent
+ return None
+
+ @asynccontextmanager
+ async def build(self) -> AsyncIterator[GraphResources]:
+ """
+ 登记构建范围;未发布的范围在任意退出路径退休并尝试回收。
+
+ :yields: 模型和中间件创建后必须立即登记的资源范围
+ :raises RuntimeError: 会话已经开始关闭,禁止创建新范围
+ """
+ if self._sealed:
+ raise RuntimeError("Agent 图资源正在关闭")
+ resources = GraphResources()
+ self._building[id(resources)] = resources
+ try:
+ yield resources
+ finally:
+ if self._building.pop(id(resources), None) is not None:
+ self._retired[id(resources)] = resources
+ await self.collect()
+
+ def publish(self, bundle: CompiledAgentBundle) -> None:
+ """
+ 同步发布完整图并移交资源,旧图在后续回收中退休。
+
+ :param bundle: 构造成功的图;资源应来自当前构建范围
+ :raises RuntimeError: 会话已封闭,调用方范围负责回收未发布资源
+ """
+ if self._sealed:
+ raise RuntimeError("Agent 图资源正在关闭")
+ if self._current is not None and self._current.resources is not bundle.resources:
+ previous = self._current.resources
+ self._retired[id(previous)] = previous
+ self._current = bundle
+ self._building.pop(id(bundle.resources), None)
+
+ def seal(self) -> None:
+ """
+ 封住当前、构建中及退休控制器;随后完成的构建不能发布。
+ """
+ self._sealed = True
+ for resources in (*self._active(), *self._retired.values()):
+ resources.seal()
+
+ def _active(self) -> tuple[GraphResources, ...]:
+ """
+ 返回仍由前台构建或已发布图使用的资源范围。
+ """
+ current = (self._current.resources,) if self._current is not None else ()
+ return (*current, *self._building.values())
+
+ def retire(self) -> None:
+ """
+ 同步使当前图失效,资源保留到下次构建退出或异步清理时回收。
+ """
+ if self._current is not None:
+ resources = self._current.resources
+ self._retired[id(resources)] = resources
+ self._current = None
+
+ async def invalidate(self) -> bool:
+ """
+ 立即使图失效并回收资源;构建、消费者或关闭操作未收敛时返回 False。
+ """
+ self.retire()
+ await self.collect()
+ return self._current is None and not self._building and not self._retired
+
+ @staticmethod
+ async def _close_middleware(middleware: object) -> bool:
+ """
+ 适配中间件已有同步/异步关闭合同;False 或异常表示仍需保留资源。
+ """
+ try:
+ result = getattr(middleware, "close")()
+ if inspect.isawaitable(result):
+ result = await result
+ return result is not False
+ except Exception as error:
+ logger.debug(f"关闭子代理中间件失败: {error}")
+ return False
+
+ async def _close_middlewares(self, retired: tuple[GraphResources, ...]) -> None:
+ """
+ 先关闭退休消费者;同一控制器每轮最多尝试一次,取消不丢失未关闭对象。
+ """
+ attempted: set[int] = set()
+ for resources in retired:
+ for identity, middleware in tuple(resources.middlewares.items()):
+ if identity in attempted:
+ continue
+ attempted.add(identity)
+ active = {key for group in self._active() for key in group.middlewares}
+ if identity in active or await self._close_middleware(middleware):
+ for group in self._retired.values():
+ group.middlewares.pop(identity, None)
+
+ async def _close_models(self, retired: tuple[GraphResources, ...]) -> None:
+ """
+ 消费者退出后释放模型;仍被复盘或其他资源范围使用的模型继续保留。
+ """
+ attempted: set[int] = set()
+ for resources in retired:
+ for identity, model in tuple(resources.models.items()):
+ if identity in attempted:
+ continue
+ attempted.add(identity)
+ active = {key for group in self._active() for key in group.models}
+ pending = {key for group in self._retired.values() if group.middlewares for key in group.models}
+ if identity not in active:
+ if identity in pending or self._models_in_use():
+ continue
+ if not await LLMHelper.close_llm(model):
+ continue
+ for group in self._retired.values():
+ group.models.pop(identity, None)
+
+ async def collect(self) -> None:
+ """
+ 串行回收退休范围;成功的单个释放立即登记,取消后可从剩余资源继续。
+ """
+ async with self._collection_lock:
+ retired = tuple(self._retired.values())
+ await self._close_middlewares(retired)
+ await self._close_models(retired)
+ for resources in retired:
+ if resources.empty:
+ self._retired.pop(id(resources), None)
diff --git a/app/agent/orchestrator.py b/app/agent/orchestrator.py
index 255511286..67cf7a9a0 100644
--- a/app/agent/orchestrator.py
+++ b/app/agent/orchestrator.py
@@ -4,7 +4,6 @@
import asyncio
import hashlib
-import inspect
import json
import re
import time
@@ -28,6 +27,7 @@ from langgraph.checkpoint.memory import InMemorySaver
from app.agent.callback import StreamingHandler
from app.agent.contracts import ReplyMode, build_display_message
+from app.agent.graph import AgentGraphCache, CompiledAgentBundle
from app.agent.learning.session import LearningSession
from app.agent.llm.helper import LLMHelper
from app.agent.llm.tools import ServerToolRegistry
@@ -285,35 +285,6 @@ class _SessionUsageSnapshot:
}
-@dataclass
-class _CompiledAgentBundle:
- """
- 会话内可复用的 Agent 图及其构造签名。
-
- :param signature: 构造模型、工具和运行图时使用的配置签名,供复用判断
- :param agent: 已编译且可复用的 Agent 执行图
- :param streaming: 该执行图是否按流式模式构造
- :param created_at: 本次执行图构造完成的时间
- :param tool_catalog: 主智能体使用的工具目录快照;尚未提供时为 None
- :param subagent_catalog: 子智能体使用的工具目录快照;尚未提供时为 None
- :param plugin_revision: 构造时插件工具目录修订号;-1 表示尚未记录
- :param mcp_config_signature: 构造时外部 MCP 配置签名;尚未记录时为空字符串
- :param catalog_checked_at: 最近检查工具目录是否变化的时间;未检查时为 None
- :param models: 本执行图持有的模型实例,用于统一释放客户端资源
- """
-
- signature: tuple[Any, ...]
- agent: Any
- streaming: bool
- created_at: datetime
- tool_catalog: Optional[ToolCatalogSnapshot] = None
- subagent_catalog: Optional[ToolCatalogSnapshot] = None
- plugin_revision: int = -1
- mcp_config_signature: str = ""
- catalog_checked_at: Optional[datetime] = None
- models: tuple[Any, ...] = ()
-
-
class _ThinkTagStripper:
"""
流式剥离 ... 标签的辅助类。
@@ -562,9 +533,7 @@ class MoviePilotAgent:
self._llm_runtime_config: Optional[Dict[str, Any]] = None
self._llm_provider_selection: Dict[str, Any] = {}
self._agent_started_at: Optional[datetime] = None
- self._compiled_agent_bundle: Optional[_CompiledAgentBundle] = None
- self._retired_llm_models: list[Any] = []
- self._subagent_middlewares: tuple[Any, ...] = ()
+ self._graphs = AgentGraphCache(models_in_use=self._learning_models_in_use)
self._shutdown_started = False
self._last_agent_cache_hit = False
@@ -712,16 +681,15 @@ class MoviePilotAgent:
"""
if not str(message or "").strip():
return ""
- model = await self._initialize_llm(streaming=False)
- try:
+ async with self._graphs.build() as resources:
+ model = await self._initialize_llm(streaming=False)
+ resources.own_model(model)
response = await model.ainvoke(
[
SystemMessage(content=AGENT_CHAT_TITLE_PROMPT),
HumanMessage(content=self._build_chat_title_message(message)),
]
)
- finally:
- await self._release_llm_models((model,))
content = LLMHelper.extract_text_content(getattr(response, "content", response))
title = self._parse_chat_title_response(content)
if not self._is_valid_chat_title(title):
@@ -1877,71 +1845,9 @@ class MoviePilotAgent:
return get_tool_factory().catalog_factory_revision()
- def _get_cached_agent(self, signature: tuple[Any, ...], streaming: bool) -> Optional[Any]:
- """
- 按签名读取当前会话已编译的 Agent 图。
- """
- bundle = self._compiled_agent_bundle
- if bundle and bundle.streaming == streaming and bundle.signature == signature:
- return bundle.agent
- return None
-
- @staticmethod
- def _seal_subagent_middleware_instances(
- middlewares: tuple[Any, ...],
- ) -> None:
- """
- 同步封住子代理控制器的新任务入口,不等待既有任务退出。
- """
- for middleware in middlewares:
- seal = getattr(middleware, "seal", None)
- if not callable(seal):
- continue
- try:
- seal()
- except Exception as error:
- logger.debug(f"封住子代理中间件失败: {error}")
-
- @staticmethod
- async def _close_subagent_middleware_instances(
- middlewares: tuple[Any, ...],
- ) -> tuple[Any, ...]:
- """
- 关闭子代理控制器,并返回仍持有未收敛任务的实例。
- """
- pending_middlewares = []
- for middleware in middlewares:
- close = getattr(middleware, "close", None)
- if not callable(close):
- continue
- try:
- result = close()
- if inspect.isawaitable(result):
- result = await result
- if result is False:
- pending_middlewares.append(middleware)
- except Exception as error:
- logger.debug(f"关闭子代理中间件失败: {error}")
- pending_middlewares.append(middleware)
- return tuple(pending_middlewares)
-
- @staticmethod
- def _merge_subagent_middleware_owners(
- *groups: tuple[Any, ...],
- ) -> tuple[Any, ...]:
- """
- 按对象身份合并当前图与延迟收敛控制器的 owner 集合。
- """
- merged = []
- for group in groups:
- for middleware in group:
- if not any(middleware is existing for existing in merged):
- merged.append(middleware)
- return tuple(merged)
-
def begin_shutdown(self) -> None:
"""
- 在任何异步等待前封住当前 Agent 的子代理提交和终端作用域。
+ 在任何异步等待前封住图资源、后台复盘和终端作用域。
"""
if self._learning:
self._learning.seal()
@@ -1949,86 +1855,20 @@ class MoviePilotAgent:
for scope in self._scheduled_terminal_scopes:
scope.seal()
self._shutdown_started = True
- self._seal_subagent_middleware_instances(self._subagent_middlewares)
+ self._graphs.seal()
- async def _cache_agent(
- self,
- *,
- signature: tuple[Any, ...],
- agent: Any,
- streaming: bool,
- tool_catalog: ToolCatalogSnapshot,
- subagent_catalog: ToolCatalogSnapshot,
- mcp_config_signature: str,
- subagent_middlewares: tuple[Any, ...] = (),
- models: tuple[Any, ...] = (),
- ) -> Any:
+ def _learning_models_in_use(self) -> bool:
"""
- 保存当前会话可复用的 Agent 图。
+ 后台复盘尚未退出时保留可能与其共享客户端的退休模型。
"""
- previous_middlewares = tuple(
- middleware
- for middleware in self._subagent_middlewares
- if not any(middleware is replacement for replacement in subagent_middlewares)
- )
- if self._shutdown_started:
- self._seal_subagent_middleware_instances(subagent_middlewares)
- if self._compiled_agent_bundle:
- self._retired_llm_models.extend(self._compiled_agent_bundle.models)
- pending_middlewares = await self._close_subagent_middleware_instances(previous_middlewares)
- self._compiled_agent_bundle = _CompiledAgentBundle(
- signature=signature,
- agent=agent,
- streaming=streaming,
- created_at=datetime.now(),
- tool_catalog=tool_catalog,
- subagent_catalog=subagent_catalog,
- plugin_revision=tool_catalog.plugin_revision,
- mcp_config_signature=mcp_config_signature,
- catalog_checked_at=datetime.now(),
- models=models,
- )
- self._subagent_middlewares = self._merge_subagent_middleware_owners(
- pending_middlewares,
- subagent_middlewares,
- )
- await self._release_retired_llm_models(consumers_closed=not pending_middlewares)
- return agent
-
- async def _release_llm_models(self, models: tuple[Any, ...]) -> bool:
- """
- 释放已无消费者的模型;关闭失败或清理被取消时保留模型供下一次清理。
- """
- for model in models:
- if not any(model is existing for existing in self._retired_llm_models):
- self._retired_llm_models.append(model)
- for model in models:
- if await LLMHelper.close_llm(model):
- self._retired_llm_models = [item for item in self._retired_llm_models if item is not model]
- return not self._retired_llm_models
-
- async def _release_retired_llm_models(self, *, consumers_closed: bool) -> bool:
- """
- 等子代理及共享模型的后台复盘退出后,再释放旧图连接。
- """
- learning_run = self._learning.run if self._learning else None
- learning_active = bool(learning_run and learning_run.task and not learning_run.task.done())
- if not consumers_closed or learning_active:
- return not self._retired_llm_models
- return await self._release_llm_models(tuple(self._retired_llm_models))
+ run = self._learning.run if self._learning else None
+ return bool(run and run.task and not run.task.done())
async def _invalidate_cached_agent(self) -> bool:
"""
- 使当前图失效,未收敛的消费者及其模型连接继续由 Agent 持有。
+ 使图失效,由图资源 owner 保留未退出消费者和关闭失败的连接。
"""
- subagent_middlewares = self._subagent_middlewares
- if self._compiled_agent_bundle:
- self._retired_llm_models.extend(self._compiled_agent_bundle.models)
- self._compiled_agent_bundle = None
- pending_middlewares = await self._close_subagent_middleware_instances(subagent_middlewares)
- self._subagent_middlewares = pending_middlewares
- models_closed = await self._release_retired_llm_models(consumers_closed=not pending_middlewares)
- return not pending_middlewares and models_closed
+ return await self._graphs.invalidate()
@staticmethod
def _latest_turn_messages(messages: List[BaseMessage]) -> List[BaseMessage]:
@@ -2123,259 +1963,234 @@ class MoviePilotAgent:
创建 LangGraph Agent(使用 create_agent + SummarizationMiddleware)
:param streaming: 是否启用流式输出
"""
- temporary_subagent_middlewares: tuple[Any, ...] = ()
- temporary_models: list[Any] = []
- pending_middlewares: tuple[Any, ...] = ()
try:
- runtime_config = await self._resolve_llm_runtime_config()
- plugin_revision = _get_plugin_tools_revision()
- mcp_config_signature = agent_mcp_manager.config_signature()
- cached_bundle = self._compiled_agent_bundle
- catalog_is_fresh = bool(
- cached_bundle
- and cached_bundle.streaming == streaming
- and cached_bundle.tool_catalog is not None
- and cached_bundle.subagent_catalog is not None
- and cached_bundle.plugin_revision == plugin_revision
- and cached_bundle.mcp_config_signature == mcp_config_signature
- and cached_bundle.catalog_checked_at is not None
- and (datetime.now() - cached_bundle.catalog_checked_at).total_seconds()
- < self.TOOL_CATALOG_REFRESH_SECONDS
- )
- if catalog_is_fresh:
+ async with self._graphs.build() as resources:
+ runtime_config = await self._resolve_llm_runtime_config()
+ plugin_revision = _get_plugin_tools_revision()
+ mcp_config_signature = agent_mcp_manager.config_signature()
+ cached_bundle = self._graphs.current
+ catalog_is_fresh = bool(
+ cached_bundle
+ and cached_bundle.streaming == streaming
+ and cached_bundle.tool_catalog is not None
+ and cached_bundle.subagent_catalog is not None
+ and cached_bundle.plugin_revision == plugin_revision
+ and cached_bundle.mcp_config_signature == mcp_config_signature
+ and cached_bundle.catalog_checked_at is not None
+ and (datetime.now() - cached_bundle.catalog_checked_at).total_seconds()
+ < self.TOOL_CATALOG_REFRESH_SECONDS
+ )
+ if catalog_is_fresh:
+ bundle_signature = await self._agent_bundle_signature(
+ streaming,
+ tool_catalog=cached_bundle.tool_catalog,
+ subagent_catalog=cached_bundle.subagent_catalog,
+ )
+ cached_agent = self._graphs.get(bundle_signature, streaming)
+ self._last_agent_cache_hit = bool(cached_agent)
+ if cached_agent:
+ logger.debug(f"复用会话内 Agent 图: session_id={self.session_id}")
+ return cached_agent
+ web_search_resolution = ServerToolRegistry.resolve_web_search(
+ provider=str(runtime_config.get("provider") or ""),
+ model=str(runtime_config.get("model") or ""),
+ mode=runtime_config.get("web_search_mode"),
+ api_protocol=runtime_config.get("api_protocol"),
+ base_url=runtime_config.get("base_url"),
+ )
+ base_tool_catalog, base_subagent_catalog = self._initialize_local_tool_catalogs()
+ mcp_specs = await agent_mcp_manager.list_enabled_tool_specs()
+ local_tools = self._filter_local_web_search_tools(
+ base_tool_catalog.tools,
+ enabled=web_search_resolution.use_local_web_search,
+ )
+ mcp_tools = await self._initialize_mcp_tools(specs=mcp_specs)
+ tools = [*local_tools, *mcp_tools]
+ local_subagent_tools = self._filter_local_web_search_tools(
+ base_subagent_catalog.tools,
+ enabled=web_search_resolution.use_local_web_search,
+ )
+ subagent_mcp_tools = await self._initialize_subagent_mcp_tools(specs=mcp_specs)
+ subagent_catalog = ToolCatalogSnapshot.from_tools(
+ [*local_subagent_tools, *subagent_mcp_tools],
+ plugin_revision=base_subagent_catalog.plugin_revision,
+ factory_revision=base_subagent_catalog.factory_revision,
+ ).require_unique()
+ subagent_tools = [*local_subagent_tools, *subagent_mcp_tools]
+ # 系统提示词
+ system_prompt = prompt_manager.get_agent_prompt(channel=self.channel)
+
+ # LLM 模型(用于 agent 执行)
+ agent_model = await self._initialize_llm(streaming=streaming)
+ resources.own_model(agent_model)
+ self._sync_model_profile(agent_model)
+ # 供应商原生工具不进入本地 ToolNode,宿主策略只覆盖 client-side tools。
+ server_tools = LLMHelper.get_server_tools(agent_model)
+
+ # 为内部模型调用准备非流式 LLM,避免与用户流式回复复用同一实例。
+ non_streaming_model = agent_model if not streaming else await self._initialize_llm(streaming=False)
+ if non_streaming_model is not agent_model:
+ resources.own_model(non_streaming_model)
+ skills_middleware = SkillsMiddleware(
+ sources=[str(agent_runtime_manager.skills_dir)],
+ bundled_skills_dir=str(get_runtime_setting("ROOT_PATH") / "skills"),
+ stream_handler=self.stream_handler,
+ )
+ skill_tools = list(getattr(skills_middleware, "tools", []) or [])
+ user_memory_dir = agent_runtime_manager.get_user_memory_dir(self.user_id)
+ memory_middleware = MemoryMiddleware(
+ memory_dir=str(agent_runtime_manager.memory_dir),
+ user_memory_dir=str(user_memory_dir) if user_memory_dir else None,
+ store=self._learning.tools.memory if self._learning else None,
+ stream_handler=self.stream_handler,
+ )
+ memory_tools = list(getattr(memory_middleware, "tools", []) or [])
+ learning_middleware = LearningMiddleware(self._learning) if self._learning is not None and self._learning_enabled() else None
+ if learning_middleware:
+ memory_tools.extend(learning_middleware.tools)
+ recall_service = getattr(self._data, "recall", None)
+ recall_middlewares = []
+ if recall_service and build_user_memory_key(self.user_id):
+ history_session = await self._recall_session(agent_model)
+ if history_session is not None:
+ recall_middlewares.append(RecallMiddleware(recall_service, str(self.user_id), history_session))
+ memory_tools.extend(tool for middleware in recall_middlewares for tool in middleware.tools)
+ policy_context = self._build_policy_context()
+ subagent_middlewares, subagent_task_tools = create_subagent_middlewares(
+ model=non_streaming_model,
+ tools=subagent_tools,
+ server_tools=server_tools,
+ stream_handler=self.stream_handler,
+ policy_context=policy_context.for_subagent(),
+ catalog=subagent_catalog,
+ )
+ resources.own_middlewares(subagent_middlewares)
+ plan_middleware = PlanMiddleware()
+ output_middleware = ToolOutputMiddleware(policy_context)
+ invocation_repository = getattr(self._data, "invocations", None)
+ invocation_middlewares = [InvocationMiddleware(policy_context, invocation_repository, tools)] if invocation_repository else []
+ internal_tools = [
+ *skill_tools, *memory_tools, *subagent_task_tools, *plan_middleware.tools,
+ *output_middleware.tools,
+ *(tool for middleware in invocation_middlewares for tool in middleware.tools),
+ ]
+ tool_selector = self._initialize_tool_selector(tools, internal_tools, non_streaming_model)
+ # 严格目录必须覆盖 LangGraph ToolNode 可执行的全部 client-side 工具。
+ tool_catalog = ToolCatalogSnapshot.from_tools(
+ [
+ *local_tools,
+ *mcp_tools,
+ *internal_tools,
+ *(tool_selector.tools if tool_selector else []),
+ ],
+ plugin_revision=base_tool_catalog.plugin_revision,
+ factory_revision=base_tool_catalog.factory_revision,
+ ).require_unique()
bundle_signature = await self._agent_bundle_signature(
streaming,
- tool_catalog=cached_bundle.tool_catalog,
- subagent_catalog=cached_bundle.subagent_catalog,
+ tool_catalog=tool_catalog,
+ subagent_catalog=subagent_catalog,
)
- cached_agent = self._get_cached_agent(bundle_signature, streaming)
+ cached_agent = self._graphs.get(bundle_signature, streaming)
self._last_agent_cache_hit = bool(cached_agent)
if cached_agent:
+ # 签名相同表示已编译图中的精确工具实例仍有效;新建快照仅用于复核。
+ cached_bundle.catalog_checked_at = datetime.now()
logger.debug(f"复用会话内 Agent 图: session_id={self.session_id}")
return cached_agent
- web_search_resolution = ServerToolRegistry.resolve_web_search(
- provider=str(runtime_config.get("provider") or ""),
- model=str(runtime_config.get("model") or ""),
- mode=runtime_config.get("web_search_mode"),
- api_protocol=runtime_config.get("api_protocol"),
- base_url=runtime_config.get("base_url"),
- )
- base_tool_catalog, base_subagent_catalog = self._initialize_local_tool_catalogs()
- mcp_specs = await agent_mcp_manager.list_enabled_tool_specs()
- local_tools = self._filter_local_web_search_tools(
- base_tool_catalog.tools,
- enabled=web_search_resolution.use_local_web_search,
- )
- mcp_tools = await self._initialize_mcp_tools(specs=mcp_specs)
- tools = [*local_tools, *mcp_tools]
- local_subagent_tools = self._filter_local_web_search_tools(
- base_subagent_catalog.tools,
- enabled=web_search_resolution.use_local_web_search,
- )
- subagent_mcp_tools = await self._initialize_subagent_mcp_tools(specs=mcp_specs)
- subagent_catalog = ToolCatalogSnapshot.from_tools(
- [*local_subagent_tools, *subagent_mcp_tools],
- plugin_revision=base_subagent_catalog.plugin_revision,
- factory_revision=base_subagent_catalog.factory_revision,
- ).require_unique()
- subagent_tools = [*local_subagent_tools, *subagent_mcp_tools]
- # 系统提示词
- system_prompt = prompt_manager.get_agent_prompt(channel=self.channel)
-
- # LLM 模型(用于 agent 执行)
- agent_model = await self._initialize_llm(streaming=streaming)
- temporary_models.append(agent_model)
- self._sync_model_profile(agent_model)
- # 供应商原生工具不进入本地 ToolNode,宿主策略只覆盖 client-side tools。
- server_tools = LLMHelper.get_server_tools(agent_model)
-
- # 为内部模型调用准备非流式 LLM,避免与用户流式回复复用同一实例。
- non_streaming_model = agent_model if not streaming else await self._initialize_llm(streaming=False)
- if non_streaming_model is not agent_model:
- temporary_models.append(non_streaming_model)
- skills_middleware = SkillsMiddleware(
- sources=[str(agent_runtime_manager.skills_dir)],
- bundled_skills_dir=str(get_runtime_setting("ROOT_PATH") / "skills"),
- stream_handler=self.stream_handler,
- )
- skill_tools = list(getattr(skills_middleware, "tools", []) or [])
- user_memory_dir = agent_runtime_manager.get_user_memory_dir(self.user_id)
- memory_middleware = MemoryMiddleware(
- memory_dir=str(agent_runtime_manager.memory_dir),
- user_memory_dir=str(user_memory_dir) if user_memory_dir else None,
- store=self._learning.tools.memory if self._learning else None,
- stream_handler=self.stream_handler,
- )
- memory_tools = list(getattr(memory_middleware, "tools", []) or [])
- learning_middleware = LearningMiddleware(self._learning) if self._learning is not None and self._learning_enabled() else None
- if learning_middleware:
- memory_tools.extend(learning_middleware.tools)
- recall_service = getattr(self._data, "recall", None)
- recall_middlewares = []
- if recall_service and build_user_memory_key(self.user_id):
- history_session = await self._recall_session(agent_model)
- if history_session is not None:
- recall_middlewares.append(RecallMiddleware(recall_service, str(self.user_id), history_session))
- memory_tools.extend(tool for middleware in recall_middlewares for tool in middleware.tools)
- policy_context = self._build_policy_context()
- subagent_middlewares, subagent_task_tools = create_subagent_middlewares(
- model=non_streaming_model,
- tools=subagent_tools,
- server_tools=server_tools,
- stream_handler=self.stream_handler,
- policy_context=policy_context.for_subagent(),
- catalog=subagent_catalog,
- )
- temporary_subagent_middlewares = tuple(subagent_middlewares)
- plan_middleware = PlanMiddleware()
- output_middleware = ToolOutputMiddleware(policy_context)
- invocation_repository = getattr(self._data, "invocations", None)
- invocation_middlewares = [InvocationMiddleware(policy_context, invocation_repository, tools)] if invocation_repository else []
- internal_tools = [
- *skill_tools, *memory_tools, *subagent_task_tools, *plan_middleware.tools,
- *output_middleware.tools,
- *(tool for middleware in invocation_middlewares for tool in middleware.tools),
- ]
- tool_selector = self._initialize_tool_selector(tools, internal_tools, non_streaming_model)
- # 严格目录必须覆盖 LangGraph ToolNode 可执行的全部 client-side 工具。
- tool_catalog = ToolCatalogSnapshot.from_tools(
- [
- *local_tools,
- *mcp_tools,
- *internal_tools,
- *(tool_selector.tools if tool_selector else []),
- ],
- plugin_revision=base_tool_catalog.plugin_revision,
- factory_revision=base_tool_catalog.factory_revision,
- ).require_unique()
- bundle_signature = await self._agent_bundle_signature(
- streaming,
- tool_catalog=tool_catalog,
- subagent_catalog=subagent_catalog,
- )
- cached_agent = self._get_cached_agent(bundle_signature, streaming)
- self._last_agent_cache_hit = bool(cached_agent)
- if cached_agent:
- # 签名相同表示已编译图中的精确工具实例仍有效;新建快照仅用于复核。
- cached_bundle.catalog_checked_at = datetime.now()
- pending_middlewares = await self._close_subagent_middleware_instances(temporary_subagent_middlewares)
- self._subagent_middlewares = self._merge_subagent_middleware_owners(
- self._subagent_middlewares,
- pending_middlewares,
+ summarization_middleware = SummarizationMiddleware(
+ model=non_streaming_model,
+ trigger=("fraction", 0.85),
+ keep=("messages", 20),
)
- temporary_subagent_middlewares = ()
- logger.debug(f"复用会话内 Agent 图: session_id={self.session_id}")
- return cached_agent
- summarization_middleware = SummarizationMiddleware(
- model=non_streaming_model,
- trigger=("fraction", 0.85),
- keep=("messages", 20),
- )
- # 中间件
- guardrails = ToolGuardrailsMiddleware(policy_context)
- policy_middleware = AgentPolicyMiddleware(
- context=policy_context, catalog=tool_catalog, tools=tools, guardrails=guardrails,
- )
- code_middleware = CodeExecutionMiddleware(policy_middleware)
- middlewares = [
- # 宿主策略必须位于最外层,确保插件覆盖工具基类也不能绕过。
- policy_middleware,
- code_middleware,
- # 运行中补充消息只在模型回合边界进入同一张图,不启动并行 Agent。
- *([SteeringMiddleware()] if self._steering_inbox.running else []),
- output_middleware,
- *invocation_middlewares,
- skills_middleware,
- # Jobs 任务管理
- JobsMiddleware(
- sources=[str(agent_runtime_manager.jobs_dir)],
- ),
- # 运行时人格与核心规则
- RuntimeConfigMiddleware(),
- # 计划独立保存,最终请求压缩仍计入其系统上下文预算。
- plan_middleware,
- # 稳定偏好与原始会话证据分别管理。
- memory_middleware,
- *recall_middlewares,
- # 以工具原文及模型请求顺序检测循环,外层归档仍保留真实回执。
- guardrails,
- *([learning_middleware] if learning_middleware else []),
- # 错误工具调用修复
- PatchToolCallsMiddleware(),
- # 子代理委派
- *subagent_middlewares,
- ]
-
- # 工具选择
- if tool_selector is not None:
- middlewares.append(tool_selector)
- middlewares.append(CodeCaptureMiddleware(code_middleware))
-
- # 所有压缩都在最终请求边界完成,避免主模型失败前写入摘要状态。
- middlewares.append(
- FinalRequestCompactionMiddleware(
- summarizer=summarization_middleware,
+ # 中间件
+ guardrails = ToolGuardrailsMiddleware(policy_context)
+ policy_middleware = AgentPolicyMiddleware(
+ context=policy_context, catalog=tool_catalog, tools=tools, guardrails=guardrails,
)
- )
- middlewares.append(VisionMiddleware())
- if learning_middleware:
- middlewares.append(LearningCaptureMiddleware(learning_middleware.session))
+ code_middleware = CodeExecutionMiddleware(policy_middleware)
+ middlewares = [
+ # 宿主策略必须位于最外层,确保插件覆盖工具基类也不能绕过。
+ policy_middleware,
+ code_middleware,
+ # 运行中补充消息只在模型回合边界进入同一张图,不启动并行 Agent。
+ *([SteeringMiddleware()] if self._steering_inbox.running else []),
+ output_middleware,
+ *invocation_middlewares,
+ skills_middleware,
+ # Jobs 任务管理
+ JobsMiddleware(
+ sources=[str(agent_runtime_manager.jobs_dir)],
+ ),
+ # 运行时人格与核心规则
+ RuntimeConfigMiddleware(),
+ # 计划独立保存,最终请求压缩仍计入其系统上下文预算。
+ plan_middleware,
+ # 稳定偏好与原始会话证据分别管理。
+ memory_middleware,
+ *recall_middlewares,
+ # 以工具原文及模型请求顺序检测循环,外层归档仍保留真实回执。
+ guardrails,
+ *([learning_middleware] if learning_middleware else []),
+ # 错误工具调用修复
+ PatchToolCallsMiddleware(),
+ # 子代理委派
+ *subagent_middlewares,
+ ]
- # 预算观察器必须位于最内层,才能看到动态 system 和最终筛选后的工具。
- middlewares.append(
- UsageMiddleware(
- on_usage=self._record_usage,
- on_request_budget=self._record_request_budget,
- next_request_sequence=self._next_request_sequence,
+ # 工具选择
+ if tool_selector is not None:
+ middlewares.append(tool_selector)
+ middlewares.append(CodeCaptureMiddleware(code_middleware))
+
+ # 所有压缩都在最终请求边界完成,避免主模型失败前写入摘要状态。
+ middlewares.append(
+ FinalRequestCompactionMiddleware(
+ summarizer=summarization_middleware,
+ )
)
- )
+ middlewares.append(VisionMiddleware())
+ if learning_middleware:
+ middlewares.append(LearningCaptureMiddleware(learning_middleware.session))
- agent = create_agent(
- model=agent_model,
- tools=[
- *tools,
- *skill_tools,
- *memory_tools,
- *server_tools,
- ],
- system_prompt=system_prompt,
- middleware=middlewares,
- checkpointer=InMemorySaver(),
- )
- cached_agent = await self._cache_agent(
- signature=bundle_signature,
- agent=agent,
- streaming=streaming,
- tool_catalog=tool_catalog,
- subagent_catalog=subagent_catalog,
- mcp_config_signature=mcp_config_signature,
- subagent_middlewares=tuple(subagent_middlewares),
- models=tuple(temporary_models),
- )
- temporary_subagent_middlewares = ()
- temporary_models = []
- return cached_agent
- except asyncio.CancelledError:
- pending_middlewares = await self._close_subagent_middleware_instances(temporary_subagent_middlewares)
- self._subagent_middlewares = self._merge_subagent_middleware_owners(
- self._subagent_middlewares,
- pending_middlewares,
- )
+ # 预算观察器必须位于最内层,才能看到动态 system 和最终筛选后的工具。
+ middlewares.append(
+ UsageMiddleware(
+ on_usage=self._record_usage,
+ on_request_budget=self._record_request_budget,
+ next_request_sequence=self._next_request_sequence,
+ )
+ )
+
+ agent = create_agent(
+ model=agent_model,
+ tools=[
+ *tools,
+ *skill_tools,
+ *memory_tools,
+ *server_tools,
+ ],
+ system_prompt=system_prompt,
+ middleware=middlewares,
+ checkpointer=InMemorySaver(),
+ )
+ self._graphs.publish(CompiledAgentBundle(
+ signature=bundle_signature,
+ agent=agent,
+ streaming=streaming,
+ created_at=datetime.now(),
+ tool_catalog=tool_catalog,
+ subagent_catalog=subagent_catalog,
+ plugin_revision=tool_catalog.plugin_revision,
+ mcp_config_signature=mcp_config_signature,
+ catalog_checked_at=datetime.now(),
+ resources=resources,
+ ))
+ return agent
+ except Exception as error:
+ logger.error(f"创建 Agent 失败: {error}")
raise
- except Exception as e:
- pending_middlewares = await self._close_subagent_middleware_instances(temporary_subagent_middlewares)
- self._subagent_middlewares = self._merge_subagent_middleware_owners(
- self._subagent_middlewares,
- pending_middlewares,
- )
- logger.error(f"创建 Agent 失败: {e}")
- raise
- finally:
- if pending_middlewares:
- self._retired_llm_models.extend(temporary_models)
- else:
- await self._release_llm_models(tuple(temporary_models))
async def process(
self,
diff --git a/app/api/endpoints/openai.py b/app/api/endpoints/openai.py
index 575769fec..bb50a3d30 100644
--- a/app/api/endpoints/openai.py
+++ b/app/api/endpoints/openai.py
@@ -49,6 +49,7 @@ from app.schemas.openai import OpenAIUsage as _SchemaOpenAIUsage
from app.schemas.types import NotificationChannel
if TYPE_CHECKING:
+ from app.agent.graph import AgentGraphCache
from app.agent.manager import AgentManager
from app.agent.orchestrator import MoviePilotAgent
@@ -70,8 +71,12 @@ OpenAIStreamEvent = str | dict[str, str] | None
class _CollectingMoviePilotAgentMixin:
"""
捕获 Agent 最终输出,避免再通过消息渠道二次发送。
+
+ :ivar _graphs: 运行时 Agent 基类提供的会话图资源 owner
"""
+ _graphs: AgentGraphCache
+
def __init__(self, *args, stream_mode: bool = False, **kwargs):
"""
初始化协议 Agent 的消息收集与流式模式。
@@ -107,7 +112,7 @@ class _CollectingMoviePilotAgentMixin:
self.stream_handler = _get_openai_streaming_handler_type()()
self.stream_handler.bind_queue(event_queue)
# 已编译工具持有旧 handler;identity 变化时必须重建图和工具目录。
- self._compiled_agent_bundle = None
+ self._graphs.retire()
def release_protocol_request(
self,
diff --git a/docs/architecture-overview.md b/docs/architecture-overview.md
index 1879ecf95..a2fe6196a 100644
--- a/docs/architecture-overview.md
+++ b/docs/architecture-overview.md
@@ -816,8 +816,8 @@ flowchart LR
| 指标 | 当前值 |
|---|---:|
-| Python 模块 | 1123 |
-| 内部导入边 | 9,579 |
+| Python 模块 | 1124 |
+| 内部导入边 | 9,587 |
| 非平凡 SCC | 1(29 个模块的 TMDB vendor 精确 containment) |
| Application / Chain 具体 Adapter 直连 | 0 / 0 |
| Direct egress | 51(精确 policy 准入,Application / Chain 均为 0) |
diff --git a/docs/rules/05-architecture.md b/docs/rules/05-architecture.md
index 35fc26dc9..39a638619 100644
--- a/docs/rules/05-architecture.md
+++ b/docs/rules/05-architecture.md
@@ -97,7 +97,8 @@ to make the directory tree look symmetrical.
| `app/agent/session.py` | Per-session queues, workers, active Agent instances, status projection, cancellation and deferred cleanup |
| `app/agent/lifecycle.py` | Runtime generation admission, initialization, idle-session collection and bounded manager shutdown |
| `app/agent/tasks.py` | Background prompts, durable scheduled-task execution and heartbeat wakeups |
-| `app/agent/orchestrator.py` | One `MoviePilotAgent` execution instance: prompt/tool/middleware assembly, model invocation, streaming and per-agent state |
+| `app/agent/orchestrator.py` | One `MoviePilotAgent` execution instance: prompt/tool/middleware assembly, model invocation, streaming and per-agent state; graph resources are registered with the graph owner immediately after acquisition |
+| `app/agent/graph.py` | Session-local compiled graph cache and model/controller ownership: one resource scope follows construction through publication and retirement; publication transfers ownership synchronously, cancellation retains unclosed resources, and consumers finish before their models close. Background learning delays model reclamation; shutdown seals in-flight scopes and refuses late publication. This is an internal owner, not an SDK or Compat export |
| `app/agent/middleware/plan.py` | Current task objective, step status and evidence in graph state; sanitized snapshots travel through existing message persistence and never authorize tool effects |
| `app/application/messaging/recall.py` / `app/agent/history/` | Application 持有独立消息库 DTO/Port 和后台维护服务;Agent 专属 SQLite/FTS5 schema、存储、检索、窗口与旧快照导入由 `agent/history/` 拥有,数据库位于 Agent 运行目录且不共享主库 Session。`db/adapters/recall.py` 仅负责一次性旧快照只读导出 |
| `app/agent/learning/` / `app/agent/middleware/learning.py` | Agent 专属个人技能、稳定记忆和复盘生命周期;独立文件目录按用户隔离,模型仅能调用受控维护工具。`session.py` 管理计数及 TaskRegistry owner,`review.py` 复用最终模型请求并在派发处限制工具,`files.py` 只封装该存储的路径、进程锁与原子替换,不作为通用文件 Adapter。人工提案/技能控制命令经 messaging interaction 契约路由,只有宿主收到的用户原文可以触发,后台快照没有执行权限 |
diff --git a/scripts/evaluation/runtime.py b/scripts/evaluation/runtime.py
index c96a9b0e6..58bdaebc4 100644
--- a/scripts/evaluation/runtime.py
+++ b/scripts/evaluation/runtime.py
@@ -584,7 +584,7 @@ def _agent_type() -> type:
保留原始执行图的观察引用,生产失败恢复清缓存后仍可导出失败轨迹。
"""
graph = await super()._create_agent(streaming=streaming)
- self.evaluation_bundle = self._compiled_agent_bundle
+ self.evaluation_bundle = self._graphs.current
return graph
async def _initialize_llm(self, streaming: bool = False) -> Any:
diff --git a/tests/agent_graph.py b/tests/agent_graph.py
new file mode 100644
index 000000000..101a9096b
--- /dev/null
+++ b/tests/agent_graph.py
@@ -0,0 +1,37 @@
+"""
+通过真实图资源 owner 为 Agent 行为测试装配已发布图。
+"""
+
+from datetime import datetime
+from typing import Any, Optional
+
+from app.agent.graph import CompiledAgentBundle, GraphResources
+from app.agent.orchestrator import MoviePilotAgent
+
+
+def publish_graph(
+ agent: MoviePilotAgent,
+ *,
+ graph: Optional[Any] = None,
+ models: tuple[Any, ...] = (),
+ middlewares: tuple[object, ...] = (),
+) -> CompiledAgentBundle:
+ """
+ 为无需真实构图的回归装配资源完整的缓存图,不模拟生命周期实现。
+
+ :param agent: 被测会话执行对象
+ :param graph: 供执行的测试图;None 时使用占位对象
+ :param models: 需观察客户端释放的模型
+ :param middlewares: 需观察关闭及未收敛重试的控制器
+ :return: 已发布图,供断言资源归属和缓存身份
+ """
+ resources = GraphResources()
+ for model in models:
+ resources.own_model(model)
+ resources.own_middlewares(middlewares)
+ bundle = CompiledAgentBundle(
+ signature=("test",), agent=graph if graph is not None else object(),
+ streaming=False, created_at=datetime.now(), resources=resources,
+ )
+ agent._graphs.publish(bundle)
+ return bundle
diff --git a/tests/fixtures/architecture/dependency-baseline.json b/tests/fixtures/architecture/dependency-baseline.json
index 518372127..8f47c9993 100644
--- a/tests/fixtures/architecture/dependency-baseline.json
+++ b/tests/fixtures/architecture/dependency-baseline.json
@@ -974,8 +974,8 @@
"runtime_only": true
}
},
- "edge_count": 9579,
- "edge_sha256": "fbbb828033ea217b8461f87d24cba67d08d93086bac88007f6445dbf34ec2eb2",
+ "edge_count": 9587,
+ "edge_sha256": "c95b2c94aa5c65c69b1374ca4e0014241e841d8520004141ba39e12f3052286a",
"edges": [
"app -> app.foundation",
"app -> app.foundation.environment",
@@ -1300,6 +1300,13 @@
"app.agent.code.result -> app.agent.policy.sanitizer",
"app.agent.contracts -> app.schemas",
"app.agent.contracts -> app.schemas.types",
+ "app.agent.graph -> app.agent",
+ "app.agent.graph -> app.agent.llm",
+ "app.agent.graph -> app.agent.llm.helper",
+ "app.agent.graph -> app.agent.tools",
+ "app.agent.graph -> app.agent.tools.catalog",
+ "app.agent.graph -> app.runtime",
+ "app.agent.graph -> app.runtime.log",
"app.agent.guardrails.controller -> app.agent",
"app.agent.guardrails.controller -> app.agent.guardrails",
"app.agent.guardrails.controller -> app.agent.guardrails.results",
@@ -1679,6 +1686,7 @@
"app.agent.orchestrator -> app.agent",
"app.agent.orchestrator -> app.agent.callback",
"app.agent.orchestrator -> app.agent.contracts",
+ "app.agent.orchestrator -> app.agent.graph",
"app.agent.orchestrator -> app.agent.learning",
"app.agent.orchestrator -> app.agent.learning.session",
"app.agent.orchestrator -> app.agent.llm",
@@ -10557,7 +10565,7 @@
"app.workflow.actions.transfer_file -> app.workflow",
"app.workflow.actions.transfer_file -> app.workflow.actions"
],
- "module_count": 1123,
+ "module_count": 1124,
"modules": [
"app",
"app.adapters",
@@ -10632,6 +10640,7 @@
"app.agent.code.manager",
"app.agent.code.result",
"app.agent.contracts",
+ "app.agent.graph",
"app.agent.guardrails",
"app.agent.guardrails.completion",
"app.agent.guardrails.controller",
diff --git a/tests/fixtures/architecture/mypy-baseline.json b/tests/fixtures/architecture/mypy-baseline.json
index 5091518f3..d874aea1d 100644
--- a/tests/fixtures/architecture/mypy-baseline.json
+++ b/tests/fixtures/architecture/mypy-baseline.json
@@ -114,8 +114,7 @@
"no-any-return": 6,
"no-untyped-def": 6,
"type-arg": 12,
- "union-attr": 3,
- "var-annotated": 1
+ "union-attr": 3
},
"app/agent/skills/registry.py": {
"misc": 3,
diff --git a/tests/test_agent_api_lazy_imports.py b/tests/test_agent_api_lazy_imports.py
index eb2385e0d..46f57fe32 100644
--- a/tests/test_agent_api_lazy_imports.py
+++ b/tests/test_agent_api_lazy_imports.py
@@ -304,6 +304,8 @@ import asyncio
import json
import sys
import types
+from datetime import datetime
+from unittest.mock import Mock
sites = types.ModuleType("app.application.site.sites")
sites.SitesHelper = type("SitesHelper", (), {})
@@ -311,12 +313,13 @@ sites.__file__ = ""
sys.modules["app.application.site.sites"] = sites
from app.api.endpoints import openai
+from app.agent.graph import AgentGraphCache, CompiledAgentBundle, GraphResources
class RuntimeAgent:
def __init__(self, **kwargs):
self.__dict__.update(kwargs)
self.stream_handler = object()
- self._compiled_agent_bundle = object()
+ self._graphs = AgentGraphCache(models_in_use=lambda: False)
class TestStreamingHandler(openai._OpenAIStreamingHandlerMixin):
pass
@@ -327,11 +330,23 @@ agent = agent_type(session_id="stable", user_id="api")
first_queue = asyncio.Queue()
second_queue = asyncio.Queue()
+resources = GraphResources()
+consumer = Mock(close=Mock(return_value=True))
+resources.own_middlewares([consumer])
+agent._graphs.publish(CompiledAgentBundle(
+ signature=("old",), agent=object(), streaming=False,
+ created_at=datetime.now(), resources=resources,
+))
agent.configure_protocol_request(stream_mode=True, event_queue=first_queue)
+invalidated = agent._graphs.current is None
+retained = not resources.empty
+asyncio.run(agent._graphs.collect())
handler = agent.stream_handler
handler._event_queue.put_nowait("first")
-compiled_bundle = object()
-agent._compiled_agent_bundle = compiled_bundle
+compiled_bundle = CompiledAgentBundle(
+ signature=("new",), agent=object(), streaming=True, created_at=datetime.now(),
+)
+agent._graphs.publish(compiled_bundle)
agent.configure_protocol_request(stream_mode=True, event_queue=second_queue)
agent.release_protocol_request(first_queue)
@@ -339,8 +354,11 @@ handler._event_queue.put_nowait("second")
agent.release_protocol_request(second_queue)
print(json.dumps({
+ "invalidated": invalidated,
+ "retained_until_collection": retained,
+ "consumer_closes": consumer.close.call_count,
"same_handler": agent.stream_handler is handler,
- "same_bundle": agent._compiled_agent_bundle is compiled_bundle,
+ "same_bundle": agent._graphs.current is compiled_bundle,
"first": first_queue.get_nowait(),
"first_empty": first_queue.empty(),
"second": second_queue.get_nowait(),
@@ -352,6 +370,9 @@ print(json.dumps({
)
assert result == {
+ "invalidated": True,
+ "retained_until_collection": True,
+ "consumer_closes": 1,
"same_handler": True,
"same_bundle": True,
"first": "first",
@@ -432,7 +453,6 @@ 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"
diff --git a/tests/test_agent_graph_cache.py b/tests/test_agent_graph_cache.py
index 6084fbf68..79b7498ff 100644
--- a/tests/test_agent_graph_cache.py
+++ b/tests/test_agent_graph_cache.py
@@ -11,12 +11,13 @@ import pytest
from langchain_core.messages import AIMessage, HumanMessage
from app.agent.contracts import ReplyMode
+from app.agent.graph import CompiledAgentBundle
from app.agent.mcp import AgentMcpToolSpec
from app.agent.middleware.invocation import GET_TOOL_EXECUTION_NAME
from app.agent.middleware.output import READ_TOOL_RESULT_NAME
from app.agent.middleware.plan import PLAN_TOOL_NAME
from app.agent.middleware.selection import TOOL_DISCOVERY_NAME
-from app.agent.orchestrator import MoviePilotAgent, _CompiledAgentBundle
+from app.agent.orchestrator import MoviePilotAgent
from app.agent.tools.catalog import (
ToolCatalogSnapshot,
ToolIdentityAmbiguousError,
@@ -103,7 +104,7 @@ async def test_create_agent_reuses_cached_graph_when_signature_matches():
cached_graph = object()
catalog = ToolCatalogSnapshot.from_tools([], plugin_revision=0, factory_revision="factory-v1")
agent = MoviePilotAgent(session_id="cache-hit", user_id="user-1")
- agent._compiled_agent_bundle = _CompiledAgentBundle(
+ agent._graphs.publish(CompiledAgentBundle(
signature=("sig",),
agent=cached_graph,
streaming=False,
@@ -113,7 +114,7 @@ async def test_create_agent_reuses_cached_graph_when_signature_matches():
plugin_revision=0,
mcp_config_signature="mcp-config",
catalog_checked_at=datetime.now(),
- )
+ ))
with (
patch.object(
@@ -146,7 +147,7 @@ async def test_fresh_catalog_cache_hit_skips_tool_and_mcp_discovery() -> None:
cached_graph = object()
catalog = ToolCatalogSnapshot.from_tools([], plugin_revision=0, factory_revision="factory-v1")
agent = MoviePilotAgent(session_id="catalog-cache-hit", user_id="user-1")
- agent._compiled_agent_bundle = _CompiledAgentBundle(
+ agent._graphs.publish(CompiledAgentBundle(
signature=("sig",),
agent=cached_graph,
streaming=False,
@@ -156,7 +157,7 @@ async def test_fresh_catalog_cache_hit_skips_tool_and_mcp_discovery() -> None:
plugin_revision=0,
mcp_config_signature="mcp-config",
catalog_checked_at=datetime.now(),
- )
+ ))
with (
patch.object(
@@ -197,7 +198,7 @@ async def test_expired_unchanged_catalog_renews_freshness() -> None:
catalog = ToolCatalogSnapshot.from_tools([], plugin_revision=0, factory_revision="factory-v1")
expired_at = datetime.now() - timedelta(minutes=5)
agent = MoviePilotAgent(session_id="catalog-refresh", user_id="user-1")
- agent._compiled_agent_bundle = _CompiledAgentBundle(
+ agent._graphs.publish(CompiledAgentBundle(
signature=("sig",),
agent=cached_graph,
streaming=False,
@@ -207,7 +208,7 @@ async def test_expired_unchanged_catalog_renews_freshness() -> None:
plugin_revision=0,
mcp_config_signature="mcp-config",
catalog_checked_at=expired_at,
- )
+ ))
fake_llm = SimpleNamespace(
_llm_type="openai-chat",
model="fake",
@@ -283,7 +284,7 @@ async def test_expired_unchanged_catalog_renews_freshness() -> None:
graph = await agent._create_agent(streaming=False)
assert graph is cached_graph
- assert agent._compiled_agent_bundle.catalog_checked_at > expired_at
+ assert agent._graphs.current.catalog_checked_at > expired_at
temporary_middleware.close.assert_awaited_once()
fake_llm.http_client.close.assert_called_once()
fake_llm.http_async_client.aclose.assert_awaited_once()
diff --git a/tests/test_agent_graph_resources.py b/tests/test_agent_graph_resources.py
new file mode 100644
index 000000000..644ec0f16
--- /dev/null
+++ b/tests/test_agent_graph_resources.py
@@ -0,0 +1,335 @@
+"""
+验证构建、图替换、取消和关闭之间的真实资源所有权转换。
+"""
+
+import asyncio
+from datetime import datetime
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, Mock
+
+import pytest
+
+from app.agent.graph import AgentGraphCache, CompiledAgentBundle, GraphResources
+from app.agent.llm.helper import LLMHelper
+
+
+@pytest.fixture
+def anyio_backend():
+ """
+ 生命周期屏障使用 asyncio 事件循环。
+ """
+ return "asyncio"
+
+
+def _bundle(resources: GraphResources, revision: int = 0) -> CompiledAgentBundle:
+ """
+ 以真实资源范围构造不同签名的占位执行图。
+ """
+ return CompiledAgentBundle(
+ signature=(revision,), agent=object(), streaming=False,
+ created_at=datetime.now(), resources=resources,
+ )
+
+
+@pytest.mark.anyio
+@pytest.mark.parametrize("failure", [None, RuntimeError("构建失败"), asyncio.CancelledError()])
+async def test_unpublished_build_closes_consumers_before_models(monkeypatch, failure):
+ """
+ 正常复核返回、异常和取消均关闭未发布范围,消费者必须先于模型退出。
+ """
+ events = []
+ cache = AgentGraphCache(models_in_use=lambda: False)
+ model = object()
+
+ async def close_consumer():
+ """
+ 记录消费者退出,验证其使用模型期间不会提前释放连接。
+ """
+ events.append("consumer")
+ return True
+
+ async def close_model(value):
+ """
+ 只允许关闭本范围接管的模型。
+ """
+ assert value is model
+ events.append("model")
+ return True
+
+ monkeypatch.setattr(LLMHelper, "close_llm", close_model)
+
+ async def build():
+ """
+ 在登记完整资源后模拟三种构图退出路径。
+ """
+ async with cache.build() as resources:
+ resources.own_model(model)
+ resources.own_model(model)
+ resources.own_middlewares([SimpleNamespace(close=close_consumer), object()])
+ if failure is not None:
+ raise failure
+
+ if failure is None:
+ await build()
+ else:
+ with pytest.raises(type(failure)):
+ await build()
+ assert events == ["consumer", "model"]
+ assert await cache.invalidate() is True
+ assert events == ["consumer", "model"]
+
+
+@pytest.mark.anyio
+async def test_cancel_during_post_publish_collection_keeps_new_graph_alive(monkeypatch):
+ """
+ 新图已发布后取消旧图回收,不能把新图误当作未提交的临时资源关闭。
+ """
+ cache = AgentGraphCache(models_in_use=lambda: False)
+ old_model, new_model = object(), object()
+ started, release = asyncio.Event(), asyncio.Event()
+ closed = []
+
+ async def close_model(model):
+ """
+ 在旧模型关闭前阻塞,使取消准确落在图发布后的回收阶段。
+ """
+ if model is old_model:
+ started.set()
+ await release.wait()
+ closed.append(model)
+ return True
+
+ monkeypatch.setattr(LLMHelper, "close_llm", close_model)
+ async with cache.build() as resources:
+ resources.own_model(old_model)
+ cache.publish(_bundle(resources))
+
+ async def replace():
+ """
+ 在真实构建范围内同步发布新图,再由退出逻辑回收旧图。
+ """
+ async with cache.build() as replacement:
+ replacement.own_model(new_model)
+ cache.publish(_bundle(replacement, 1))
+
+ task = asyncio.create_task(replace())
+ await started.wait()
+ task.cancel()
+ with pytest.raises(asyncio.CancelledError):
+ await task
+ assert cache.get((1,), False) is cache.current.agent
+ assert closed == []
+ release.set()
+ await cache.collect()
+ assert closed == [old_model]
+ assert await cache.invalidate() is True
+ assert closed == [old_model, new_model]
+
+
+@pytest.mark.anyio
+@pytest.mark.parametrize("first_result", [False, RuntimeError("关闭失败")])
+async def test_pending_consumers_retain_only_their_own_models(monkeypatch, first_result):
+ """
+ 某代消费者未退出时保留对应模型,但不能阻塞另一独立范围释放。
+ """
+ cache = AgentGraphCache(models_in_use=lambda: False)
+ close_model = AsyncMock(return_value=True)
+ monkeypatch.setattr(LLMHelper, "close_llm", close_model)
+ model, independent_model = object(), object()
+ consumer = SimpleNamespace(close=AsyncMock(side_effect=[first_result, False, True]))
+ async with cache.build() as resources:
+ resources.own_model(model)
+ resources.own_middlewares([consumer])
+ close_model.assert_not_awaited()
+ async with cache.build() as independent:
+ independent.own_model(independent_model)
+ close_model.assert_awaited_once_with(independent_model)
+ assert await cache.invalidate() is True
+ assert [call.args[0] for call in close_model.await_args_list] == [independent_model, model]
+
+
+@pytest.mark.anyio
+@pytest.mark.parametrize("phase", ["consumer", "model"])
+async def test_interrupted_collection_can_retry_without_losing_resources(monkeypatch, phase):
+ """
+ 消费者或模型关闭中再次取消,资源必须仍由原 owner 保留并可重试。
+ """
+ cache = AgentGraphCache(models_in_use=lambda: False)
+ started, release = asyncio.Event(), asyncio.Event()
+ model = object()
+
+ async def blocked_close(*_args):
+ """
+ 在真实关闭边界等待,用屏障代替不稳定的时间等待。
+ """
+ started.set()
+ await release.wait()
+ return True
+
+ consumer = SimpleNamespace(close=AsyncMock(return_value=True))
+ close_model = AsyncMock(return_value=True)
+ if phase == "consumer":
+ consumer.close.side_effect = blocked_close
+ else:
+ close_model.side_effect = blocked_close
+ monkeypatch.setattr(LLMHelper, "close_llm", close_model)
+ async with cache.build() as resources:
+ resources.own_model(model)
+ resources.own_middlewares([consumer])
+ cache.publish(_bundle(resources))
+ task = asyncio.create_task(cache.invalidate())
+ await started.wait()
+ task.cancel()
+ with pytest.raises(asyncio.CancelledError):
+ await task
+ assert cache.current is None
+ assert not resources.empty
+ release.set()
+ assert await cache.invalidate() is True
+ assert resources.empty
+ assert consumer.close.await_count == (2 if phase == "consumer" else 1)
+ assert close_model.await_count == (1 if phase == "consumer" else 2)
+
+
+@pytest.mark.anyio
+async def test_shutdown_tracks_inflight_build_and_seals_late_controller(monkeypatch):
+ """
+ 关停与构建交错时不提前释放在用模型,迟到控制器立即封口,迟到图拒绝发布。
+ """
+ cache = AgentGraphCache(models_in_use=lambda: False)
+ close_model = AsyncMock(return_value=True)
+ monkeypatch.setattr(LLMHelper, "close_llm", close_model)
+ model = object()
+ consumer = SimpleNamespace(seal=Mock(), close=AsyncMock(return_value=True))
+ async with cache.build() as resources:
+ resources.own_model(model)
+ cache.seal()
+ assert await cache.invalidate() is False
+ close_model.assert_not_awaited()
+ resources.own_middlewares([consumer])
+ consumer.seal.assert_called_once()
+ with pytest.raises(RuntimeError, match="正在关闭"):
+ cache.publish(_bundle(resources))
+ close_model.assert_awaited_once_with(model)
+ assert await cache.invalidate() is True
+ with pytest.raises(RuntimeError, match="正在关闭"):
+ async with cache.build():
+ pytest.fail("关闭后不得进入构建范围")
+
+
+@pytest.mark.anyio
+async def test_concurrent_collection_does_not_double_close(monkeypatch):
+ """
+ 两个清理请求并发时只执行一次实际模型关闭。
+ """
+ cache = AgentGraphCache(models_in_use=lambda: False)
+ started, release = asyncio.Event(), asyncio.Event()
+
+ async def close_model(_model):
+ """
+ 保持首次关闭在途,让第二次清理与其真实交错。
+ """
+ started.set()
+ await release.wait()
+ return True
+
+ close = AsyncMock(side_effect=close_model)
+ monkeypatch.setattr(LLMHelper, "close_llm", close)
+ async with cache.build() as resources:
+ resources.own_model(object())
+ cache.publish(_bundle(resources))
+ first = asyncio.create_task(cache.invalidate())
+ await started.wait()
+ second = asyncio.create_task(cache.invalidate())
+ release.set()
+ assert await asyncio.gather(first, second) == [True, True]
+ close.assert_awaited_once()
+
+
+@pytest.mark.anyio
+async def test_retirement_during_collection_keeps_shared_model_for_late_consumer(monkeypatch):
+ """
+ 清理等待期间另一图退休时,也必须等待其消费者退出后再释放共享模型。
+ """
+ cache = AgentGraphCache(models_in_use=lambda: False)
+ started, release = asyncio.Event(), asyncio.Event()
+ model = object()
+ close = AsyncMock(return_value=True)
+ monkeypatch.setattr(LLMHelper, "close_llm", close)
+
+ async def close_old_consumer():
+ """
+ 阻塞旧消费者退出,为新图退休提供确定的交错点。
+ """
+ started.set()
+ await release.wait()
+ return True
+
+ async with cache.build() as resources:
+ resources.own_model(model)
+ resources.own_middlewares([SimpleNamespace(close=close_old_consumer)])
+ cache.publish(_bundle(resources))
+ cache.retire()
+ newer = GraphResources()
+ consumer = SimpleNamespace(close=AsyncMock(return_value=True))
+ newer.own_model(model)
+ newer.own_middlewares([consumer])
+ cache.publish(_bundle(newer, 1))
+ collecting = asyncio.create_task(cache.collect())
+ await started.wait()
+ cache.retire()
+ release.set()
+ await collecting
+ close.assert_not_awaited()
+ consumer.close.assert_not_awaited()
+ assert await cache.invalidate() is True
+ consumer.close.assert_awaited_once()
+ close.assert_awaited_once_with(model)
+
+
+@pytest.mark.anyio
+async def test_repeated_replacement_preserves_shared_resources_and_releases_once(monkeypatch):
+ """
+ 多轮替换共享同一模型与控制器时,只有最终失效才释放各一次。
+ """
+ cache = AgentGraphCache(models_in_use=lambda: False)
+ model = object()
+ consumer = SimpleNamespace(close=AsyncMock(return_value=True))
+ close = AsyncMock(return_value=True)
+ monkeypatch.setattr(LLMHelper, "close_llm", close)
+ for revision in range(20):
+ async with cache.build() as resources:
+ resources.own_model(model)
+ resources.own_middlewares([consumer])
+ bundle = _bundle(resources, revision)
+ cache.publish(bundle)
+ cache.publish(bundle)
+ assert cache.get((revision,), False) is bundle.agent
+ assert cache.get((revision,), True) is None
+ close.assert_not_awaited()
+ consumer.close.assert_not_awaited()
+ assert await cache.invalidate() is True
+ close.assert_awaited_once_with(model)
+ consumer.close.assert_awaited_once()
+
+
+@pytest.mark.anyio
+async def test_learning_and_close_failure_keep_resource_until_both_converge(monkeypatch):
+ """
+ 后台复盘退出后才能尝试关闭;关闭失败仍保留资源至后续成功。
+ """
+ learning = asyncio.get_running_loop().create_future()
+ cache = AgentGraphCache(models_in_use=lambda: not learning.done())
+ close = AsyncMock(side_effect=[False, True])
+ monkeypatch.setattr(LLMHelper, "close_llm", close)
+ async with cache.build() as resources:
+ resources.own_model(object())
+ cache.publish(_bundle(resources))
+ assert await cache.invalidate() is False
+ close.assert_not_awaited()
+ learning.set_result(None)
+ assert await cache.invalidate() is False
+ assert not resources.empty
+ assert await cache.invalidate() is True
+ assert resources.empty
+ assert close.await_count == 2
diff --git a/tests/test_agent_recovery.py b/tests/test_agent_recovery.py
index 677d51333..e33d783b8 100644
--- a/tests/test_agent_recovery.py
+++ b/tests/test_agent_recovery.py
@@ -18,6 +18,7 @@ from app.agent.memory import MemoryManager
from app.agent.middleware.patching import PatchToolCallsMiddleware
from app.agent.middleware.plan import PLAN_SNAPSHOT_KEY
from app.agent.orchestrator import MoviePilotAgent
+from tests.agent_graph import publish_graph
class _InterruptedGraph:
@@ -114,12 +115,12 @@ async def test_failure_preserves_completed_receipts_and_marks_unknown_outcomes(r
original = _completed_tool_messages()
graph = _InterruptedGraph(original)
agent._create_agent = AsyncMock(return_value=graph)
- agent._compiled_agent_bundle = SimpleNamespace(models=())
+ publish_graph(agent)
result, _ = await agent._execute_agent(original[:1])
assert result == "智能助手执行失败,请稍后重试"
- assert agent._compiled_agent_bundle is None
+ assert agent._graphs.current is None
recovered = await agent._memory.async_get_agent_messages(agent.session_id, agent.user_id)
receipts = [message for message in recovered if isinstance(message, ToolMessage)]
assert receipts[0].content == original[2].content
@@ -151,7 +152,7 @@ async def test_cancellation_preserves_snapshot_and_still_propagates(recovery_age
agent._should_stream = lambda: streaming
graph = _InterruptedGraph(_completed_tool_messages(), blocked=True)
agent._create_agent = AsyncMock(return_value=graph)
- agent._compiled_agent_bundle = SimpleNamespace(models=())
+ publish_graph(agent)
execution = asyncio.create_task(agent._execute_agent(graph.messages[:1]))
await graph.started.wait()
execution.cancel()
@@ -159,7 +160,7 @@ async def test_cancellation_preserves_snapshot_and_still_propagates(recovery_age
with pytest.raises(asyncio.CancelledError):
await execution
- assert agent._compiled_agent_bundle is None
+ assert agent._graphs.current is None
recovered = await agent._memory.async_get_agent_messages(agent.session_id, agent.user_id)
assert "already-created" in str(messages_to_dict(recovered))
assert "取消" in recovered[-1].content
@@ -210,12 +211,12 @@ async def test_snapshot_failure_does_not_replace_original_error(recovery_agent,
agent._get_recursion_limit = lambda: int("invalid configuration")
if failure == "persistence":
agent._memory.async_save_agent_messages = AsyncMock(side_effect=RuntimeError("save failed"))
- agent._compiled_agent_bundle = SimpleNamespace(models=())
+ publish_graph(agent)
result, _ = await agent._execute_agent(graph.messages[:1])
assert result == "智能助手执行失败,请稍后重试"
- assert agent._compiled_agent_bundle is None
+ assert agent._graphs.current is None
agent.stream_handler.stop_streaming.assert_awaited_once()
@@ -269,7 +270,7 @@ async def test_second_cancellation_during_snapshot_still_invalidates_graph(recov
agent, persistence = recovery_agent
graph = _InterruptedGraph(_completed_tool_messages(), blocked=True)
agent._create_agent = AsyncMock(return_value=graph)
- agent._compiled_agent_bundle = SimpleNamespace(models=())
+ publish_graph(agent)
saving = asyncio.Event()
async def block_save(**_kwargs):
@@ -289,7 +290,7 @@ async def test_second_cancellation_during_snapshot_still_invalidates_graph(recov
with pytest.raises(asyncio.CancelledError):
await execution
- assert agent._compiled_agent_bundle is None
+ assert agent._graphs.current is None
agent.stream_handler.stop_streaming.assert_awaited_once()
diff --git a/tests/test_agent_side_effect_boundaries.py b/tests/test_agent_side_effect_boundaries.py
index 998eada72..570d55402 100644
--- a/tests/test_agent_side_effect_boundaries.py
+++ b/tests/test_agent_side_effect_boundaries.py
@@ -29,7 +29,7 @@ from app.agent.terminal.manager import _TerminalSessionManager
from app.agent.terminal.ownership import current_terminal_scope
from app.agent.terminal.session import _TerminalSession
from app.agent.tools.base import MoviePilotTool
-from app.agent.tools.catalog import ToolCatalogSnapshot
+from tests.agent_graph import publish_graph
pytestmark = pytest.mark.usefixtures("terminal_scope")
@@ -327,12 +327,12 @@ async def test_agent_cleanup_closes_subagent_middlewares() -> None:
"""
closed.append(True)
- agent._subagent_middlewares = (_Middleware(),)
+ bundle = publish_graph(agent, middlewares=(_Middleware(),))
assert await agent.cleanup() is True
assert closed == [True]
- assert agent._subagent_middlewares == ()
+ assert bundle.resources.empty
@pytest.mark.anyio
@@ -343,22 +343,14 @@ async def test_agent_cache_replacement_closes_previous_subagent_middleware() ->
agent = MoviePilotAgent(session_id="session-1", user_id="user-1")
old_middleware = SimpleNamespace(close=AsyncMock())
new_middleware = SimpleNamespace(close=AsyncMock())
- catalog = ToolCatalogSnapshot.from_tools([], plugin_revision=0, factory_revision="factory-v1")
- agent._subagent_middlewares = (old_middleware,)
+ publish_graph(agent, middlewares=(old_middleware,))
- await agent._cache_agent(
- signature=("new",),
- agent=object(),
- streaming=False,
- tool_catalog=catalog,
- subagent_catalog=catalog,
- mcp_config_signature="mcp-config",
- subagent_middlewares=(new_middleware,),
- )
+ bundle = publish_graph(agent, middlewares=(new_middleware,))
+ await agent._graphs.collect()
old_middleware.close.assert_awaited_once()
new_middleware.close.assert_not_awaited()
- assert agent._subagent_middlewares == (new_middleware,)
+ assert tuple(bundle.resources.middlewares.values()) == (new_middleware,)
@pytest.mark.anyio
@@ -369,8 +361,7 @@ async def test_agent_execution_failure_closes_cached_subagent_middleware() -> No
agent = MoviePilotAgent(session_id="session-1", user_id="user-1")
middleware = SimpleNamespace(close=AsyncMock())
graph = SimpleNamespace(ainvoke=AsyncMock(side_effect=RuntimeError("failed")))
- agent._compiled_agent_bundle = SimpleNamespace(agent=graph, models=())
- agent._subagent_middlewares = (middleware,)
+ bundle = publish_graph(agent, graph=graph, middlewares=(middleware,))
agent._should_stream = lambda: False
agent._create_agent = AsyncMock(return_value=graph)
agent._dispatch_execution_notice = AsyncMock()
@@ -380,8 +371,8 @@ async def test_agent_execution_failure_closes_cached_subagent_middleware() -> No
assert result == "智能助手执行失败,请稍后重试"
middleware.close.assert_awaited_once()
- assert agent._compiled_agent_bundle is None
- assert agent._subagent_middlewares == ()
+ assert agent._graphs.current is None
+ assert bundle.resources.empty
def test_subagent_control_middleware_close_is_idempotent() -> None:
@@ -478,13 +469,13 @@ async def test_agent_cleanup_retains_nonconverged_subagent_middleware() -> None:
seal=MagicMock(),
close=AsyncMock(side_effect=[False, True]),
)
- agent._subagent_middlewares = (middleware,)
+ bundle = publish_graph(agent, middlewares=(middleware,))
assert await agent.cleanup() is False
- assert agent._subagent_middlewares == (middleware,)
+ assert tuple(bundle.resources.middlewares.values()) == (middleware,)
assert await agent.cleanup() is True
- assert agent._subagent_middlewares == ()
+ assert bundle.resources.empty
assert middleware.seal.call_count == 2
assert middleware.close.await_count == 2
diff --git a/tests/test_agent_summarization_streaming.py b/tests/test_agent_summarization_streaming.py
index 981c0e27e..2bdfce5b5 100644
--- a/tests/test_agent_summarization_streaming.py
+++ b/tests/test_agent_summarization_streaming.py
@@ -40,6 +40,7 @@ from app.agent.middleware.summarization import (
FinalRequestCompactionMiddleware,
)
from app.agent.middleware.usage import UsageMiddleware
+from tests.agent_graph import publish_graph
class _FakeLLM:
@@ -1338,7 +1339,7 @@ def test_summary_failure_preserves_database_history():
user_id=user_id,
memory=isolated_memory,
)
- agent._compiled_agent_bundle = SimpleNamespace(models=())
+ publish_graph(agent)
agent._should_stream = lambda: False
agent._create_agent = AsyncMock(return_value=_FailingGraph())
agent.stream_handler = SimpleNamespace(stop_streaming=AsyncMock(return_value=(False, "")))
@@ -1352,7 +1353,7 @@ def test_summary_failure_preserves_database_history():
isolated_memory.clear_memory(session_id, user_id)
recovered_messages = isolated_memory.get_agent_messages(session_id, user_id)
assert result == "智能助手执行失败,请稍后重试"
- assert agent._compiled_agent_bundle is None
+ assert agent._graphs.current is None
assert [message.content for message in recovered_messages] == ["数据库中的旧事实"]
send_usage_event.assert_called_once()
assert not send_usage_event.call_args.args[1].success
diff --git a/tests/test_architecture_dependencies.py b/tests/test_architecture_dependencies.py
index a05dba737..2d9a3ba74 100644
--- a/tests/test_architecture_dependencies.py
+++ b/tests/test_architecture_dependencies.py
@@ -374,6 +374,29 @@ def test_agent_manager_uses_focused_owner_modules_and_precise_facade():
assert "from app.agent.orchestrator import AgentManager" not in startup_source
+def test_agent_graph_resources_have_one_owner() -> None:
+ """
+ 图资源只归 graph 所有,执行对象不得重新建立平行资源集合或私有兼容导出。
+ """
+ graph_path = APP_ROOT / "agent" / "graph.py"
+ assert graph_path.is_file()
+ graph_tree = _parse_source(graph_path)
+ assert {node.name for node in graph_tree.body if isinstance(node, ast.ClassDef)} == {
+ "GraphResources", "CompiledAgentBundle", "AgentGraphCache",
+ }
+ orchestrator_path = APP_ROOT / "agent" / "orchestrator.py"
+ orchestrator = orchestrator_path.read_text(encoding="utf-8")
+ for retired in ("_compiled_agent_bundle", "_retired_llm_models", "self._subagent_middlewares",
+ "def _cache_agent", "def _release_llm_models", "class _CompiledAgentBundle"):
+ assert retired not in orchestrator
+ assert "from app.agent.graph import AgentGraphCache, CompiledAgentBundle" in orchestrator
+ assert not any(
+ isinstance(node, ast.ImportFrom) and node.module == "app.agent.orchestrator"
+ for node in ast.walk(graph_tree)
+ )
+ assert "app/agent/graph.py" in (PROJECT_ROOT / "docs/rules/05-architecture.md").read_text(encoding="utf-8")
+
+
def test_mcp_schema_projection_has_one_pure_owner() -> None:
"""
MCP schema 投影使用单词 owner,且不引入运行时或端点依赖。
diff --git a/tests/test_llm_client_lifecycle.py b/tests/test_llm_client_lifecycle.py
index 1ea2a6f47..6b247528a 100644
--- a/tests/test_llm_client_lifecycle.py
+++ b/tests/test_llm_client_lifecycle.py
@@ -4,7 +4,6 @@ LLM 独占 HTTP 客户端必须随请求和 Agent 图确定性释放。
import asyncio
import json
-from datetime import datetime
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
@@ -14,8 +13,9 @@ import pytest
from app.agent import orchestrator as agent_module
from app.agent.llm import helper as llm_module
from app.agent.llm.helper import LLMHelper, LLMTestError, LLMTestTimeout
-from app.agent.orchestrator import MoviePilotAgent, _CompiledAgentBundle
+from app.agent.orchestrator import MoviePilotAgent
from app.agent.tools.catalog import ToolCatalogSnapshot
+from tests.agent_graph import publish_graph
@pytest.fixture
@@ -37,16 +37,6 @@ def _model(*, failure=None):
)
-def _bundle(model):
- """
- 构造持有实际模型 owner 的已编译图快照。
- """
- return _CompiledAgentBundle(
- signature=("old",), agent=object(), streaming=False,
- created_at=datetime.now(), models=(model,),
- )
-
-
@pytest.mark.anyio
@pytest.mark.parametrize("failure", [None, RuntimeError("failed"), TimeoutError(), asyncio.CancelledError()])
async def test_settings_test_closes_clients_on_every_exit(monkeypatch, failure):
@@ -91,13 +81,9 @@ async def test_graph_replacement_closes_only_retired_clients():
"""
old_model, new_model = _model(), _model()
agent = MoviePilotAgent(session_id="replace-clients", user_id="1")
- agent._compiled_agent_bundle = _bundle(old_model)
- catalog = ToolCatalogSnapshot.from_tools([], plugin_revision=0, factory_revision="test")
- await agent._cache_agent(
- signature=("new",), agent=object(), streaming=False,
- tool_catalog=catalog, subagent_catalog=catalog, mcp_config_signature="",
- models=(new_model,),
- )
+ publish_graph(agent, models=(old_model,))
+ publish_graph(agent, models=(new_model,))
+ await agent._graphs.collect()
old_model.http_async_client.aclose.assert_awaited_once()
new_model.http_async_client.aclose.assert_not_awaited()
assert await agent._invalidate_cached_agent() is True
@@ -113,8 +99,7 @@ async def test_unfinished_subagent_retains_model_until_cleanup_converges():
"""
model = _model()
agent = MoviePilotAgent(session_id="pending-clients", user_id="1")
- agent._compiled_agent_bundle = _bundle(model)
- agent._subagent_middlewares = (SimpleNamespace(close=AsyncMock(side_effect=[False, True])),)
+ publish_graph(agent, models=(model,), middlewares=(SimpleNamespace(close=AsyncMock(side_effect=[False, True])),))
assert await agent._invalidate_cached_agent() is False
model.http_async_client.aclose.assert_not_awaited()
assert await agent._invalidate_cached_agent() is True
@@ -128,7 +113,7 @@ async def test_unfinished_learning_retains_model_until_task_finishes():
"""
model = _model()
agent = MoviePilotAgent(session_id="learning-clients", user_id="1")
- agent._compiled_agent_bundle = _bundle(model)
+ publish_graph(agent, models=(model,))
task = asyncio.get_running_loop().create_future()
agent._learning = SimpleNamespace(run=SimpleNamespace(task=task))
assert await agent._invalidate_cached_agent() is False
@@ -146,7 +131,7 @@ async def test_close_failure_retains_owner_and_still_closes_other_client():
model = _model()
model.http_async_client.aclose.side_effect = [RuntimeError("close failed"), None]
agent = MoviePilotAgent(session_id="retry-clients", user_id="1")
- agent._compiled_agent_bundle = _bundle(model)
+ publish_graph(agent, models=(model,))
assert await agent._invalidate_cached_agent() is False
model.http_client.close.assert_called_once()
assert await agent._invalidate_cached_agent() is True