mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-11-03 05:44:44 +08:00
refactor(agent): narrow package compatibility surfaces
This commit is contained in:
@@ -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 承接。"""
|
||||
|
||||
@@ -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 承接。"""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]] = {}
|
||||
|
||||
@@ -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 "<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)
|
||||
|
||||
Reference in New Issue
Block a user