refactor(agent): narrow package compatibility surfaces

This commit is contained in:
jxxghp
2026-08-29 13:51:19 +08:00
parent 3a33da0b38
commit dbdf7d0582
16 changed files with 265 additions and 138 deletions

View File

@@ -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 承接。"""

View File

@@ -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 承接。"""

View File

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

View File

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

View File

@@ -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",

View File

@@ -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。

View File

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

View File

@@ -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 |

View File

@@ -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(

View File

@@ -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",

View File

@@ -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
},

View File

@@ -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",

View File

@@ -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

View File

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

View File

@@ -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]] = {}

View File

@@ -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, '<script>alert("x")</script>')
assert '<script>alert("x")</script>' not in html
assert "&lt;script&gt;alert(&quot;x&quot;)&lt;/script&gt;" 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)