From dbdf7d0582cade37050b2e5e4ce8fcc602955e0f Mon Sep 17 00:00:00 2001 From: jxxghp Date: Sat, 29 Aug 2026 13:51:19 +0800 Subject: [PATCH] refactor(agent): narrow package compatibility surfaces --- app/agent/__init__.py | 31 +---- app/agent/llm/__init__.py | 53 +-------- app/agent/llm/provider.py | 7 -- app/api/endpoints/llm.py | 4 +- app/runtime/compat/manifest.py | 50 +++++++- docs/architecture-optimization-checklist.md | 6 +- docs/architecture-overview.md | 8 +- docs/rules/05-architecture.md | 19 ++++ scripts/local_setup.py | 2 +- .../architecture/dependency-baseline.json | 5 +- .../fixtures/architecture/ruff-baseline.json | 3 - .../runtime-contract-baseline.json | 50 +++++++- tests/test_agent_image_capability.py | 5 +- tests/test_agent_lazy_runtime_boundary.py | 11 +- tests/test_architecture_dependencies.py | 42 ++++++- tests/test_llm_facade_governance.py | 107 +++++++++++++----- 16 files changed, 265 insertions(+), 138 deletions(-) diff --git a/app/agent/__init__.py b/app/agent/__init__.py index 6db4e19c5..5c5fa60b3 100644 --- a/app/agent/__init__.py +++ b/app/agent/__init__.py @@ -1,30 +1 @@ -"""Agent 公共入口,仅按白名单延迟加载稳定契约。""" - -from importlib import import_module -from typing import Any - -_PUBLIC_SYMBOLS = { - "AgentChain": ("app.chain.agent", "AgentChain"), - "AgentManager": ("app.agent.manager", "AgentManager"), - "HEARTBEAT_SESSION_PREFIX": ("app.agent.orchestrator", "HEARTBEAT_SESSION_PREFIX"), - "MoviePilotAgent": ("app.agent.orchestrator", "MoviePilotAgent"), - "ReplyMode": ("app.agent.contracts", "ReplyMode"), - "UNSUPPORTED_IMAGE_INPUT_MESSAGE": ("app.agent.orchestrator", "UNSUPPORTED_IMAGE_INPUT_MESSAGE"), - "agent_manager": ("app.agent.manager", "agent_manager"), -} - - -def __getattr__(name: str) -> Any: - """按白名单延迟解析稳定 Agent 契约。""" - target = _PUBLIC_SYMBOLS.get(name) - if target is None: - raise AttributeError(f"module 'app.agent' has no attribute {name!r}") - module_name, symbol_name = target - value = getattr(import_module(module_name), symbol_name) - globals()[name] = value - return value - - -def __dir__() -> list[str]: - """返回包内名称与白名单公开契约。""" - return sorted(set(globals()) | set(_PUBLIC_SYMBOLS)) +"""Agent 运行时实现包;宿主直接导入职责 owner,旧包根符号由 Compat 承接。""" diff --git a/app/agent/llm/__init__.py b/app/agent/llm/__init__.py index 4845c742b..fd1ace346 100644 --- a/app/agent/llm/__init__.py +++ b/app/agent/llm/__init__.py @@ -1,52 +1 @@ -"""Agent 内部使用的 LLM 适配层,公开对象按需解析。""" - -from importlib import import_module -from typing import Any - - -_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", - "AgentCapabilityManager", - "AgentCapabilityProvider", - "AudioCapabilityProvider", - "LLMProviderAuthError", - "LLMProviderError", - "LLMProviderManager", - "LLMTestError", - "LLMTestTimeout", - "MiMoAudioProvider", - "OpenAIChatAudioProvider", - "OpenAIAudioProvider", - "render_auth_result_html", -] +"""Agent LLM 实现包;宿主直接导入职责 owner,旧 Helper 包根符号由 Compat 承接。""" diff --git a/app/agent/llm/provider.py b/app/agent/llm/provider.py index 423a16a44..b676ad786 100644 --- a/app/agent/llm/provider.py +++ b/app/agent/llm/provider.py @@ -9,7 +9,6 @@ from pathlib import Path from typing import Any, Dict, Optional from app.agent.llm.auth import _ProviderAuth -from app.agent.llm.auth import render_auth_result_html as _render_auth_result_html from app.agent.llm.catalog import ( ProviderAuthMethod, ProviderSpec, @@ -32,7 +31,6 @@ __all__ = [ "ProviderAuthMethod", "ProviderSpec", "ProviderUrlPreset", - "render_auth_result_html", ] from app.foundation.singleton import Singleton from app.runtime.log import logger @@ -413,8 +411,3 @@ class LLMProviderManager( method_id, callback_url, ) - - -def render_auth_result_html(success: bool, message: str) -> str: - """保持 OAuth 回调结果页的稳定 Facade 签名。""" - return _render_auth_result_html(success, message) diff --git a/app/api/endpoints/llm.py b/app/api/endpoints/llm.py index 65db4166e..dbc771143 100644 --- a/app/api/endpoints/llm.py +++ b/app/api/endpoints/llm.py @@ -3,6 +3,7 @@ from typing import Any, Dict, List, Optional, Union from fastapi import Depends, Request, Response from fastapi.responses import HTMLResponse +from app.agent.llm.auth import render_auth_result_html from app.agent.llm.gateway import resolve_llm_provider_runtime from app.api.dependencies.auth import get_current_active_superuser_async from app.api.response import ResponseAPIRouter @@ -77,7 +78,4 @@ async def llm_provider_auth_callback( error, error_description, ) - # 该符号由 app.agent.llm.__getattr__ 惰性公开,Pylint 无法静态发现。 - from app.agent.llm import render_auth_result_html # pylint: disable=no-name-in-module - return HTMLResponse(content=render_auth_result_html(success, message)) diff --git a/app/runtime/compat/manifest.py b/app/runtime/compat/manifest.py index ecc2172ab..0302bc740 100644 --- a/app/runtime/compat/manifest.py +++ b/app/runtime/compat/manifest.py @@ -640,8 +640,10 @@ MODULE_ALIASES: Dict[str, ModuleAlias] = { introduced="v3.0.0", owner="application", ), "app.helper.llm": ModuleAlias( - target="app.agent.llm", replacement="app.agent.llm", - introduced="v3.0.0", owner="agent", is_package=True, + target="app.agent.llm.helper", + replacement="app.agent.llm.helper", + introduced="v3.0.0", + owner="agent", ), } @@ -774,6 +776,50 @@ _MESSAGE_NOTIFICATION_SYMBOL_ALIASES: Dict[str, SymbolAlias] = { } SYMBOL_ALIASES: Dict[str, Dict[str, SymbolAlias]] = { + "app.agent": { + "AgentChain": SymbolAlias( + target_module="app.chain.agent", + target_name="AgentChain", + replacement="app.chain.agent.AgentChain", + ), + "AgentManager": SymbolAlias( + target_module="app.agent.manager", + target_name="AgentManager", + replacement="app.agent.manager.AgentManager", + ), + "HEARTBEAT_SESSION_PREFIX": SymbolAlias( + target_module="app.agent.orchestrator", + target_name="HEARTBEAT_SESSION_PREFIX", + replacement="app.agent.orchestrator.HEARTBEAT_SESSION_PREFIX", + ), + "MoviePilotAgent": SymbolAlias( + target_module="app.agent.orchestrator", + target_name="MoviePilotAgent", + replacement="app.agent.orchestrator.MoviePilotAgent", + ), + "ReplyMode": SymbolAlias( + target_module="app.agent.contracts", + target_name="ReplyMode", + replacement="app.agent.contracts.ReplyMode", + ), + "UNSUPPORTED_IMAGE_INPUT_MESSAGE": SymbolAlias( + target_module="app.agent.orchestrator", + target_name="UNSUPPORTED_IMAGE_INPUT_MESSAGE", + replacement="app.agent.orchestrator.UNSUPPORTED_IMAGE_INPUT_MESSAGE", + ), + "agent_manager": SymbolAlias( + target_module="app.agent.manager", + target_name="agent_manager", + replacement="app.agent.manager.agent_manager", + ), + }, + "app.agent.llm": { + "LLMHelper": SymbolAlias( + target_module="app.agent.llm.helper", + target_name="LLMHelper", + replacement="app.agent.llm.helper.LLMHelper", + ), + }, "app.chain": { "ChainBase": SymbolAlias( target_module="app.sdk.chain", diff --git a/docs/architecture-optimization-checklist.md b/docs/architecture-optimization-checklist.md index df42a8bc0..8c6a53d9d 100644 --- a/docs/architecture-optimization-checklist.md +++ b/docs/architecture-optimization-checklist.md @@ -70,7 +70,7 @@ MoviePilot V3 已经形成较清晰的模块化单体:`foundation`、`domain` | 指标 | 当前值 | 解释 | |---|---:|---| -| 宿主 Python 模块 / 内部依赖边 | 959 / 8,149 | `dependency-baseline.json` 当前快照 | +| 宿主 Python 模块 / 内部依赖边 | 959 / 8,150 | `dependency-baseline.json` 当前快照 | | 非平凡 SCC | 1 | 仅保留精确 containment 的 29 模块 TMDB 移植包环 | | 跨层 DB 边界债务 | 0 | Application、Chain、API、Agent、Runtime、Workflow 到 DB 的受控债务均为零 | | Model/Oper 事务债务 | 0 | 自建 Session、自动事务装饰器、直接 commit/rollback 等基线均为零 | @@ -651,6 +651,10 @@ Pylint 10/10、Ruff、mypy/复杂度 ratchet、宿主与最新官方插件基线 ### ARCH-202 拆分 Agent 与 LLM provider +- [x] 冻结官方插件真实 Agent/LLM 导入与属性调用;三条 `LLMHelper` 路径保持同一身份, + `MoviePilotTool`、`moviepilot_tool_manager._load_tools()` 保持原 owner 契约。 +- [x] 删除 `app.agent`、`app.agent.llm` 包根动态/重复导出;宿主改为 owner 导入,旧符号仅由 + 精确 Compat 承接,`app.helper.llm` 收窄为 `app.agent.llm.helper` 模块别名。 - [x] 将内置 provider spec 数据从 `LLMProviderManager` 分离为只读 catalog。 - [x] 模型发现、认证会话和运行时构建已拆至单词 owner。 - [ ] 将 WebAgent SSE、文件/音频与会话桥接移出 API endpoint。 diff --git a/docs/architecture-overview.md b/docs/architecture-overview.md index cecc58844..93108b2a4 100644 --- a/docs/architecture-overview.md +++ b/docs/architecture-overview.md @@ -597,6 +597,12 @@ flowchart TB - `app.agent.AgentManager` 是插件稳定路径,精确解析到 `app/agent/manager.py`;宿主直接导入 owner,包根不得通配转发 `orchestrator.py` 的任意内部名称。 +- `app.agent.llm` 包根不承载实现或动态通配导出;宿主直接导入 + `llm/helper.py`、`llm/capability.py`、`llm/auth.py`、`llm/provider.py` 等 owner。 + 旧 `app.agent.llm.LLMHelper` 由 Compat 精确叠加,`app.helper.llm` 直接映射到 + canonical `llm/helper.py`,三条插件路径保持同一类身份且不公开 Provider 内部 owner。 +- 官方插件使用的 `MoviePilotTool` 与 `moviepilot_tool_manager` 继续由 + `agent/tools/base.py`、`agent/tools/manager.py` 原位拥有,现有 `_load_tools()` 调用保持兼容。 - Agent 工具不直接 import API / 调度器 / 命令:插件动态路由与文件夹操作使用 `application/plugin/routes.py`、`application/plugin/folders.py`,调度和命令分别使用 `application/scheduling.py`、`application/commands.py`。FastAPI 具体实现位于 @@ -736,7 +742,7 @@ flowchart LR | 指标 | 当前值 | |---|---:| | Python 模块 | 959 | -| 内部导入边 | 8,149 | +| 内部导入边 | 8,150 | | 非平凡 SCC | 1(精确 containment 的 TMDB 移植包环) | | Application / Chain 具体 Adapter 直连 | 0 / 0 | | Direct egress | 53(债务已清零,53 条精确 containment) | diff --git a/docs/rules/05-architecture.md b/docs/rules/05-architecture.md index f349b8a41..a890a699b 100644 --- a/docs/rules/05-architecture.md +++ b/docs/rules/05-architecture.md @@ -564,6 +564,21 @@ consumes the registered runtime and must not recreate provider discovery or lega runtime fallbacks; management tests pass the facade directly to avoid a `provider_manage -> helper -> gateway -> provider_manage` call loop. +`app.agent` and `app.agent.llm` package roots contain no implementation, +dynamic forwarding or host export list. Canonical callers import the owning +module directly, such as `app.agent.orchestrator`, `app.agent.llm.helper`, +`app.agent.llm.capability`, `app.agent.llm.auth` or `app.agent.llm.provider`. Historical Agent package +symbols and the verified `app.agent.llm.LLMHelper` plugin path are preserved only +by exact `SYMBOL_ALIASES`; `app.helper.llm` is an exact module alias to +`app.agent.llm.helper`, not an alias to the whole LLM package. Therefore +`app.agent.llm.LLMHelper`, `app.agent.llm.helper.LLMHelper` and +`app.helper.llm.LLMHelper` resolve to the same class without exposing provider, +auth or capability owners through either package root. The verified tool ABI +continues at `app.agent.tools.base.MoviePilotTool` and +`app.agent.tools.manager.moviepilot_tool_manager`, including the existing +`_load_tools()` compatibility call; those canonical owner modules are not +re-exported from `app.agent`. + ### DB / Oper layer SQLAlchemy models stay under `app/db/models/`; the data access classes live in @@ -860,6 +875,10 @@ driven workflow registration. | `app/agent/tasks.py` | Background prompt, scheduled task and heartbeat execution owner | | `app/agent/orchestrator.py` | Per-session `MoviePilotAgent` execution and LLM/tool/middleware orchestration only | | `app/agent/runtime_loader.py` | Agent-specific capability discovery and canonical entrypoint/service materialization; reuses the generic Capability Runtime while keeping Agent ownership under `app/agent/` | +| `app/agent/__init__.py` | Implementation-free package root; exact historical Agent symbols are supplied by the Compat overlay only, while host callers import `orchestrator.py` or the relevant owner directly | +| `app/agent/llm/__init__.py` | Implementation-free package root; only the verified historical `LLMHelper` symbol is supplied by exact Compat routing | +| `app/agent/llm/helper.py` | Canonical `LLMHelper` owner and exact target of the historical `app.helper.llm` module path | +| `app/agent/llm/auth.py` | OAuth callback result presentation; escapes provider-controlled text before rendering HTML and is not re-exported from the package root | | `app/application/subscription/contract.py` | Deeply frozen Subscription/History DTOs, media identity, write patch and typed query/write/staging Repository contracts | | `app/application/subscription/write.py` | Subscription media translation and sync/async write-command orchestration | | `app/db/adapters/subscription.py` | Standalone short-transaction and caller-Session subscription/history adapters; ORM projection remains inside the Session | diff --git a/scripts/local_setup.py b/scripts/local_setup.py index a4a090812..4b6f82cf0 100644 --- a/scripts/local_setup.py +++ b/scripts/local_setup.py @@ -3814,7 +3814,7 @@ def run_agent_request( try: from app.startup.initializers.database import prepare_database - from app.agent import MoviePilotAgent + from app.agent.orchestrator import MoviePilotAgent from app.runtime.config import settings except ModuleNotFoundError as exc: raise RuntimeError( diff --git a/tests/fixtures/architecture/dependency-baseline.json b/tests/fixtures/architecture/dependency-baseline.json index 3d5e99151..09fe7c97d 100644 --- a/tests/fixtures/architecture/dependency-baseline.json +++ b/tests/fixtures/architecture/dependency-baseline.json @@ -1089,8 +1089,8 @@ "runtime_only": true } }, - "edge_count": 8149, - "edge_sha256": "ba993b931e67bbd7901b6dbfc344634f508483533000d5b8f2372b7b130ea420", + "edge_count": 8150, + "edge_sha256": "ea2b01950aaaee993732fc83800a5db57dc57920967d5b83b6f0374f23a524e7", "edges": [ "app -> app.foundation", "app -> app.foundation.environment", @@ -3064,6 +3064,7 @@ "app.api.endpoints.history -> app.schemas.token", "app.api.endpoints.llm -> app.agent", "app.api.endpoints.llm -> app.agent.llm", + "app.api.endpoints.llm -> app.agent.llm.auth", "app.api.endpoints.llm -> app.agent.llm.gateway", "app.api.endpoints.llm -> app.api", "app.api.endpoints.llm -> app.api.dependencies", diff --git a/tests/fixtures/architecture/ruff-baseline.json b/tests/fixtures/architecture/ruff-baseline.json index 5c8d30a09..0e6ba82bf 100644 --- a/tests/fixtures/architecture/ruff-baseline.json +++ b/tests/fixtures/architecture/ruff-baseline.json @@ -24,9 +24,6 @@ "app/agent/callback/__init__.py": { "I001": 1 }, - "app/agent/llm/__init__.py": { - "I001": 1 - }, "app/agent/llm/capability.py": { "I001": 1 }, diff --git a/tests/fixtures/architecture/runtime-contract-baseline.json b/tests/fixtures/architecture/runtime-contract-baseline.json index 986f4ca71..4af0d09a4 100644 --- a/tests/fixtures/architecture/runtime-contract-baseline.json +++ b/tests/fixtures/architecture/runtime-contract-baseline.json @@ -388,10 +388,10 @@ }, "app.helper.llm": { "introduced": "v3.0.0", - "is_package": true, + "is_package": false, "owner": "agent", - "replacement": "app.agent.llm", - "target": "app.agent.llm" + "replacement": "app.agent.llm.helper", + "target": "app.agent.llm.helper" }, "app.helper.locale": { "introduced": "v3.0.0", @@ -878,6 +878,50 @@ } }, "symbol_aliases": { + "app.agent": { + "AgentChain": { + "replacement": "app.chain.agent.AgentChain", + "target_module": "app.chain.agent", + "target_name": "AgentChain" + }, + "AgentManager": { + "replacement": "app.agent.manager.AgentManager", + "target_module": "app.agent.manager", + "target_name": "AgentManager" + }, + "HEARTBEAT_SESSION_PREFIX": { + "replacement": "app.agent.orchestrator.HEARTBEAT_SESSION_PREFIX", + "target_module": "app.agent.orchestrator", + "target_name": "HEARTBEAT_SESSION_PREFIX" + }, + "MoviePilotAgent": { + "replacement": "app.agent.orchestrator.MoviePilotAgent", + "target_module": "app.agent.orchestrator", + "target_name": "MoviePilotAgent" + }, + "ReplyMode": { + "replacement": "app.agent.contracts.ReplyMode", + "target_module": "app.agent.contracts", + "target_name": "ReplyMode" + }, + "UNSUPPORTED_IMAGE_INPUT_MESSAGE": { + "replacement": "app.agent.orchestrator.UNSUPPORTED_IMAGE_INPUT_MESSAGE", + "target_module": "app.agent.orchestrator", + "target_name": "UNSUPPORTED_IMAGE_INPUT_MESSAGE" + }, + "agent_manager": { + "replacement": "app.agent.manager.agent_manager", + "target_module": "app.agent.manager", + "target_name": "agent_manager" + } + }, + "app.agent.llm": { + "LLMHelper": { + "replacement": "app.agent.llm.helper.LLMHelper", + "target_module": "app.agent.llm.helper", + "target_name": "LLMHelper" + } + }, "app.agent.orchestrator": { "AGENT_SESSION_QUEUE_MAX_SIZE": { "replacement": "app.agent.session.AGENT_SESSION_QUEUE_MAX_SIZE", diff --git a/tests/test_agent_image_capability.py b/tests/test_agent_image_capability.py index 3bafd17b1..2d8dcae94 100644 --- a/tests/test_agent_image_capability.py +++ b/tests/test_agent_image_capability.py @@ -3,9 +3,10 @@ from unittest.mock import AsyncMock, Mock, patch import pytest -from app.agent import MoviePilotAgent -from app.agent.llm import AgentCapabilityManager, LLMHelper +from app.agent.llm.capability import AgentCapabilityManager +from app.agent.llm.helper import LLMHelper from app.agent.llm.provider import LLMProviderManager +from app.agent.orchestrator import MoviePilotAgent from app.chain.message import MessageChain from app.runtime.config import global_vars, settings from app.schemas.types import NotificationChannel diff --git a/tests/test_agent_lazy_runtime_boundary.py b/tests/test_agent_lazy_runtime_boundary.py index dfda740d6..7268087cc 100644 --- a/tests/test_agent_lazy_runtime_boundary.py +++ b/tests/test_agent_lazy_runtime_boundary.py @@ -298,17 +298,19 @@ def test_reply_mode_identity_and_display_message_contract() -> None: assert legacy_message == contract_message -def test_llm_facade_resolves_only_requested_public_module() -> None: - """访问 capability 导出时不应顺带加载 helper 或 provider registry。""" +def test_llm_internal_owner_is_imported_directly_without_loading_other_owners() -> None: + """内部 capability 使用 owner 路径,包根不再重复导出其实现。""" result = _run_isolated( """ import json import sys import app.agent.llm as llm -capability = llm.AgentCapabilityManager +missing_root_export = not hasattr(llm, "AgentCapabilityManager") +from app.agent.llm.capability import AgentCapabilityManager print(json.dumps({ - "module": capability.__module__, + "missing_root_export": missing_root_export, + "module": AgentCapabilityManager.__module__, "helper_loaded": "app.agent.llm.helper" in sys.modules, "provider_loaded": "app.agent.llm.provider" in sys.modules, })) @@ -316,6 +318,7 @@ print(json.dumps({ ) assert result == { + "missing_root_export": True, "module": "app.agent.llm.capability", "helper_loaded": False, "provider_loaded": False, diff --git a/tests/test_architecture_dependencies.py b/tests/test_architecture_dependencies.py index 73c3fd0d6..0522bdf9f 100644 --- a/tests/test_architecture_dependencies.py +++ b/tests/test_architecture_dependencies.py @@ -3,7 +3,7 @@ import json from functools import lru_cache from pathlib import Path -from app.runtime.compat.manifest import SYMBOL_ALIASES +from app.runtime.compat.manifest import MODULE_ALIASES, SYMBOL_ALIASES from scripts.architecture.baseline import ( collect_current_event_facts as _collect_current_event_facts, ) @@ -1597,6 +1597,46 @@ def test_host_consumers_get_agent_manager_through_application_facade(): assert violations == {} +def test_agent_package_roots_do_not_duplicate_implementation_exports(): + """Agent 与 LLM 包根不得实现动态转发,旧符号只能由精确 Compat 路由承接。""" + for relative_path in ("app/agent/__init__.py", "app/agent/llm/__init__.py"): + path = PROJECT_ROOT / relative_path + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + assert all(isinstance(node, ast.Expr) for node in tree.body), relative_path + + assert set(SYMBOL_ALIASES["app.agent"]) == { + "AgentChain", + "AgentManager", + "HEARTBEAT_SESSION_PREFIX", + "MoviePilotAgent", + "ReplyMode", + "UNSUPPORTED_IMAGE_INPUT_MESSAGE", + "agent_manager", + } + assert set(SYMBOL_ALIASES["app.agent.llm"]) == {"LLMHelper"} + helper_alias = MODULE_ALIASES["app.helper.llm"] + assert helper_alias.target == "app.agent.llm.helper" + assert helper_alias.replacement == "app.agent.llm.helper" + assert not helper_alias.is_package + + +def test_host_code_imports_agent_and_llm_symbols_from_owner_modules(): + """宿主不得消费 Agent 包根兼容符号,避免包根再次成为第二公开面。""" + violations: dict[str, set[str]] = {} + for module_name, path in _discover_modules().items(): + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + imported = { + f"{node.module}.{alias.name}" + for node in ast.walk(tree) + if isinstance(node, ast.ImportFrom) + and node.module in {"app.agent", "app.agent.llm"} + for alias in node.names + } + if imported: + violations[module_name] = imported + assert violations == {} + + def test_host_consumers_resolve_llm_provider_runtime_through_gateway(): """宿主不得绕过 gateway 或 LLM 公共导出穿透 provider 实现。""" violations: dict[str, set[str]] = {} diff --git a/tests/test_llm_facade_governance.py b/tests/test_llm_facade_governance.py index 638e73eae..cc36a996e 100644 --- a/tests/test_llm_facade_governance.py +++ b/tests/test_llm_facade_governance.py @@ -72,11 +72,14 @@ cold = { "provider": "app.agent.llm.provider" in sys.modules, } from app.agent.llm import LLMHelper as PackageHelper +import app.agent.llm.helper as CanonicalModule from app.agent.llm.helper import LLMHelper as CanonicalHelper +import app.helper.llm as CompatModule from app.helper.llm import LLMHelper as CompatHelper print(json.dumps({ "cold": cold, "package_identity": PackageHelper is CanonicalHelper, + "compat_module_identity": CompatModule is CanonicalModule, "compat_identity": CompatHelper is CanonicalHelper, "helper_loaded": "app.agent.llm.helper" in sys.modules, "provider_loaded": "app.agent.llm.provider" in sys.modules, @@ -87,6 +90,7 @@ print(json.dumps({ assert result == { "cold": {"helper": False, "provider": False}, "package_identity": True, + "compat_module_identity": True, "compat_identity": True, "helper_loaded": True, "provider_loaded": False, @@ -120,31 +124,82 @@ def test_llm_gateway_protocol_and_registration_delegate_to_one_runtime() -> None gateway.register_llm_provider_runtime(previous) -def test_llm_package_root_does_not_duplicate_internal_owner_exports() -> None: - """兼容包根只保留既有 ABI,不得重新导出拆分后的内部 owner。""" - path = PROJECT_ROOT / "app" / "agent" / "llm" / "__init__.py" - tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) - assignments = { - target.id: ast.literal_eval(node.value) - for node in tree.body - if isinstance(node, ast.Assign) - for target in node.targets - if isinstance(target, ast.Name) and target.id in {"_EXPORT_MODULES", "__all__"} - } - exported = set(assignments["__all__"]) | set(assignments["_EXPORT_MODULES"]) - forbidden = { - "LLMProviderRuntimePort", - "PendingAuthSession", - "ProviderAuthMethod", - "ProviderSpec", - "ProviderUrlPreset", - "register_llm_provider_runtime", - "resolve_llm_provider_runtime", +def test_agent_package_root_uses_only_precise_compat_symbols() -> None: + """Agent 包根保持冷启动,旧公开符号精确解析且未知编排内部不再泄漏。""" + result = _run_isolated( + """ +import json +import sys + +import app.agent as facade +cold = "app.agent.orchestrator" not in sys.modules +from app.agent import AgentChain, AgentManager, MoviePilotAgent, ReplyMode, agent_manager +from app.agent.contracts import ReplyMode as CanonicalReplyMode +from app.agent.orchestrator import ( + AgentManager as CanonicalManager, + MoviePilotAgent as CanonicalAgent, + agent_manager as canonical_manager, +) +from app.chain.agent import AgentChain as CanonicalChain +try: + facade.AgentManagerQueueFullError +except AttributeError: + unknown_blocked = True +else: + unknown_blocked = False +print(json.dumps({ + "cold": cold, + "chain_identity": AgentChain is CanonicalChain, + "manager_identity": AgentManager is CanonicalManager, + "agent_identity": MoviePilotAgent is CanonicalAgent, + "reply_mode_identity": ReplyMode is CanonicalReplyMode, + "singleton_identity": agent_manager is canonical_manager, + "unknown_blocked": unknown_blocked, +})) +""" + ) + + assert result == { + "cold": True, + "chain_identity": True, + "manager_identity": True, + "agent_identity": True, + "reply_mode_identity": True, + "singleton_identity": True, + "unknown_blocked": True, } - assert exported.isdisjoint(forbidden) - assert not any( - isinstance(node, ast.ImportFrom) - and (node.module or "").startswith("app.agent.llm.") - for node in tree.body - ) + +def test_oauth_result_html_escapes_external_error_description() -> None: + """OAuth provider 返回的错误描述必须作为文本展示,不能注入落地页。""" + from app.agent.llm.auth import render_auth_result_html + + html = render_auth_result_html(False, '') + + assert '' not in html + assert "<script>alert("x")</script>" in html + + +def test_llm_package_root_does_not_duplicate_internal_owner_exports() -> None: + """LLM 包根不得保留实现或重复导出,旧 Helper 只由 Compat 精确承接。""" + from app.runtime.compat.manifest import MODULE_ALIASES, SYMBOL_ALIASES + + path = PROJECT_ROOT / "app" / "agent" / "llm" / "__init__.py" + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + + assert all(isinstance(node, ast.Expr) for node in tree.body) + assert set(SYMBOL_ALIASES["app.agent.llm"]) == {"LLMHelper"} + helper_alias = MODULE_ALIASES["app.helper.llm"] + assert helper_alias.target == "app.agent.llm.helper" + assert helper_alias.replacement == "app.agent.llm.helper" + assert not helper_alias.is_package + + +def test_agent_tool_plugin_contracts_keep_canonical_identity() -> None: + """官方插件使用的工具基类、管理器实例和显式刷新入口保持原 owner。""" + from app.agent.tools.base import MoviePilotTool + from app.agent.tools.manager import MoviePilotToolsManager, moviepilot_tool_manager + + assert MoviePilotTool.__module__ == "app.agent.tools.base" + assert isinstance(moviepilot_tool_manager, MoviePilotToolsManager) + assert callable(moviepilot_tool_manager._load_tools)