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)