feat(agent): 建立严格工具身份与调用契约 (#6280)

This commit is contained in:
InfinityPacer
2026-08-13 04:07:59 +08:00
committed by GitHub
parent 36297572fb
commit 4ee7ee2589
21 changed files with 1866 additions and 64 deletions

View File

@@ -27,6 +27,7 @@ from langgraph.checkpoint.memory import InMemorySaver
from app.agent.callback import StreamingHandler
from app.agent.llm import LLMHelper
from app.agent.llm.server_tools import ServerToolRegistry
from app.agent.memory import memory_manager
from app.agent.middleware.activity_log import (
ActivityLogMiddleware,
@@ -60,10 +61,15 @@ from app.agent.policy import (
from app.agent.runtime import agent_runtime_manager
from app.agent.mcp import agent_mcp_manager
from app.agent.tools.factory import MoviePilotToolFactory
from app.agent.tools.impl.mcp import create_external_mcp_tools
from app.agent.tools.catalog import ToolCatalogSnapshot
from app.agent.tools.impl.mcp import (
create_external_mcp_tools,
select_legacy_mcp_tools,
)
from app.chain import ChainBase
from app.core.config import settings
from app.core.event import eventmanager
from app.core.plugin import PluginManager
from app.db.agentchat_oper import AgentChatOper
from app.db.agenttask_oper import AgentTaskOper
from app.db.user_oper import UserOper
@@ -192,6 +198,11 @@ class _CompiledAgentBundle:
agent: Any
streaming: bool
created_at: datetime
tool_catalog: Optional[ToolCatalogSnapshot] = None
subagent_catalog: Optional[ToolCatalogSnapshot] = None
plugin_revision: int = -1
mcp_config_signature: str = ""
catalog_checked_at: Optional[datetime] = None
class _ThinkTagStripper:
@@ -297,6 +308,8 @@ class MoviePilotAgent:
MoviePilot AI智能体基于 LangChain v1 + LangGraph
"""
TOOL_CATALOG_REFRESH_SECONDS = 30
def __init__(
self,
session_id: str,
@@ -1116,7 +1129,7 @@ class MoviePilotAgent:
def _initialize_tools(self) -> List:
"""
初始化工具列表
初始化主 Agent 本地工具实例。
"""
return MoviePilotToolFactory.create_tools(
session_id=self.session_id,
@@ -1129,6 +1142,36 @@ class MoviePilotAgent:
allow_message_tools=self.allow_message_tools,
)
def _initialize_local_tool_catalogs(
self,
) -> tuple[ToolCatalogSnapshot, ToolCatalogSnapshot]:
"""在同一插件 revision 窗口内建立主图和子图工具目录。"""
plugin_manager = PluginManager()
for _attempt in range(MoviePilotToolFactory.CATALOG_BUILD_MAX_ATTEMPTS):
before_revision = plugin_manager.get_plugin_agent_tools_revision()
tools = self._initialize_tools()
subagent_tools = self._initialize_subagent_tools()
after_revision = plugin_manager.get_plugin_agent_tools_revision()
if before_revision == after_revision:
factory_revision = MoviePilotToolFactory.catalog_factory_revision()
return (
ToolCatalogSnapshot.from_tools(
tools,
plugin_revision=after_revision,
factory_revision=factory_revision,
),
ToolCatalogSnapshot.from_tools(
subagent_tools,
plugin_revision=after_revision,
factory_revision=factory_revision,
),
)
raise RuntimeError("插件工具目录持续变化,无法建立当前快照")
def _initialize_tool_catalog(self) -> ToolCatalogSnapshot:
"""兼容只需要主 Agent 工具目录的内部调用与测试。"""
return self._initialize_local_tool_catalogs()[0]
@staticmethod
def _filter_local_web_search_tools(tools: List, enabled: bool) -> List:
"""按联网搜索策略保留或移除本地 search_web 工具。"""
@@ -1168,7 +1211,12 @@ class MoviePilotAgent:
runtime_config.get("web_search_mode"),
)
async def _agent_bundle_signature(self, streaming: bool) -> tuple[Any, ...]:
async def _agent_bundle_signature(
self,
streaming: bool,
tool_catalog: Optional[ToolCatalogSnapshot] = None,
subagent_catalog: Optional[ToolCatalogSnapshot] = None,
) -> tuple[Any, ...]:
"""构造会话内 Agent 图缓存签名。"""
runtime_config = await self._resolve_llm_runtime_config()
return (
@@ -1188,6 +1236,14 @@ class MoviePilotAgent:
self._public_runtime_config_signature(runtime_config),
agent_runtime_manager.current_signature(),
agent_mcp_manager.config_signature(),
(
(tool_catalog.signature, subagent_catalog.signature)
if tool_catalog is not None and subagent_catalog is not None
else (
MoviePilotToolFactory.catalog_factory_revision(),
PluginManager().get_plugin_agent_tools_revision(),
)
),
)
def _get_cached_agent(
@@ -1209,6 +1265,9 @@ class MoviePilotAgent:
signature: tuple[Any, ...],
agent: Any,
streaming: bool,
tool_catalog: ToolCatalogSnapshot,
subagent_catalog: ToolCatalogSnapshot,
mcp_config_signature: str,
) -> Any:
"""保存当前会话可复用的 Agent 图。"""
self._compiled_agent_bundle = _CompiledAgentBundle(
@@ -1216,6 +1275,11 @@ class MoviePilotAgent:
agent=agent,
streaming=streaming,
created_at=datetime.now(),
tool_catalog=tool_catalog,
subagent_catalog=subagent_catalog,
plugin_revision=tool_catalog.plugin_revision,
mcp_config_signature=mcp_config_signature,
catalog_checked_at=datetime.now(),
)
return agent
@@ -1244,7 +1308,7 @@ class MoviePilotAgent:
allow_message_tools=False,
)
async def _initialize_mcp_tools(self) -> List:
async def _initialize_mcp_tools(self, specs=None) -> List:
"""
初始化外部 MCP 工具列表。
"""
@@ -1256,9 +1320,10 @@ class MoviePilotAgent:
username=self.username,
stream_handler=self.stream_handler,
agent_context=self._tool_context,
specs=specs,
)
async def _initialize_subagent_mcp_tools(self) -> List:
async def _initialize_subagent_mcp_tools(self, specs=None) -> List:
"""
初始化子代理可用的外部 MCP 工具列表。
"""
@@ -1275,6 +1340,7 @@ class MoviePilotAgent:
"should_dispatch_reply": False,
"is_admin": bool(self._tool_context.get("is_admin")),
},
specs=specs,
)
async def _create_agent(self, streaming: bool = False):
@@ -1283,13 +1349,68 @@ class MoviePilotAgent:
:param streaming: 是否启用流式输出
"""
try:
bundle_signature = await self._agent_bundle_signature(streaming)
cached_agent = self._get_cached_agent(bundle_signature, streaming)
self._last_agent_cache_hit = bool(cached_agent)
if cached_agent:
logger.debug(f"复用会话内 Agent 图: session_id={self.session_id}")
return cached_agent
runtime_config = await self._resolve_llm_runtime_config()
plugin_revision = PluginManager().get_plugin_agent_tools_revision()
mcp_config_signature = agent_mcp_manager.config_signature()
cached_bundle = self._compiled_agent_bundle
catalog_is_fresh = bool(
cached_bundle
and cached_bundle.streaming == streaming
and cached_bundle.tool_catalog is not None
and cached_bundle.subagent_catalog is not None
and cached_bundle.plugin_revision == plugin_revision
and cached_bundle.mcp_config_signature == mcp_config_signature
and cached_bundle.catalog_checked_at is not None
and (
datetime.now() - cached_bundle.catalog_checked_at
).total_seconds() < self.TOOL_CATALOG_REFRESH_SECONDS
)
if catalog_is_fresh:
bundle_signature = await self._agent_bundle_signature(
streaming,
tool_catalog=cached_bundle.tool_catalog,
subagent_catalog=cached_bundle.subagent_catalog,
)
cached_agent = self._get_cached_agent(bundle_signature, streaming)
self._last_agent_cache_hit = bool(cached_agent)
if cached_agent:
logger.debug(
f"复用会话内 Agent 图: session_id={self.session_id}"
)
return cached_agent
web_search_resolution = ServerToolRegistry.resolve_web_search(
provider=str(runtime_config.get("provider") or ""),
model=str(runtime_config.get("model") or ""),
mode=runtime_config.get("web_search_mode"),
api_protocol=runtime_config.get("api_protocol"),
base_url=runtime_config.get("base_url"),
)
base_tool_catalog, base_subagent_catalog = (
self._initialize_local_tool_catalogs()
)
mcp_specs = await agent_mcp_manager.list_enabled_tool_specs()
local_tools = self._filter_local_web_search_tools(
base_tool_catalog.tools,
enabled=web_search_resolution.use_local_web_search,
)
mcp_tools = await self._initialize_mcp_tools(specs=mcp_specs)
tools = [*local_tools, *select_legacy_mcp_tools(mcp_tools)]
local_subagent_tools = self._filter_local_web_search_tools(
base_subagent_catalog.tools,
enabled=web_search_resolution.use_local_web_search,
)
subagent_mcp_tools = await self._initialize_subagent_mcp_tools(
specs=mcp_specs
)
subagent_catalog = ToolCatalogSnapshot.from_tools(
[*local_subagent_tools, *subagent_mcp_tools],
plugin_revision=base_subagent_catalog.plugin_revision,
factory_revision=base_subagent_catalog.factory_revision,
)
subagent_tools = [
*local_subagent_tools,
*select_legacy_mcp_tools(subagent_mcp_tools),
]
# 系统提示词
system_prompt = prompt_manager.get_agent_prompt(channel=self.channel)
@@ -1298,7 +1419,6 @@ class MoviePilotAgent:
self._sync_model_profile(agent_model)
# 供应商原生工具不进入本地 ToolNode宿主策略只覆盖 client-side tools。
server_tools = LLMHelper.get_server_tools(agent_model)
use_local_web_search = LLMHelper.should_use_local_web_search(agent_model)
# 为内部模型调用准备非流式 LLM避免与用户流式回复复用同一实例。
non_streaming_model = (
@@ -1306,13 +1426,6 @@ class MoviePilotAgent:
if not streaming
else await self._initialize_llm(streaming=False)
)
# 工具列表
tools = self._filter_local_web_search_tools(
self._initialize_tools(),
enabled=use_local_web_search,
)
tools.extend(await self._initialize_mcp_tools())
skills_middleware = SkillsMiddleware(
sources=[str(agent_runtime_manager.skills_dir)],
bundled_skills_dir=str(settings.ROOT_PATH / "skills"),
@@ -1329,11 +1442,6 @@ class MoviePilotAgent:
activity_log_tools = list(
getattr(activity_log_middleware, "tools", []) or []
)
subagent_tools = self._filter_local_web_search_tools(
self._initialize_subagent_tools(),
enabled=use_local_web_search,
)
subagent_tools.extend(await self._initialize_subagent_mcp_tools())
policy_context = self._build_policy_context()
subagent_middlewares, subagent_task_tools = create_subagent_middlewares(
model=non_streaming_model,
@@ -1341,7 +1449,32 @@ class MoviePilotAgent:
server_tools=server_tools,
stream_handler=self.stream_handler,
policy_context=policy_context.for_subagent(),
catalog=subagent_catalog,
)
# 严格目录必须覆盖 LangGraph ToolNode 可执行的全部 client-side 工具。
tool_catalog = ToolCatalogSnapshot.from_tools(
[
*local_tools,
*mcp_tools,
*skill_tools,
*activity_log_tools,
*subagent_task_tools,
],
plugin_revision=base_tool_catalog.plugin_revision,
factory_revision=base_tool_catalog.factory_revision,
)
bundle_signature = await self._agent_bundle_signature(
streaming,
tool_catalog=tool_catalog,
subagent_catalog=subagent_catalog,
)
cached_agent = self._get_cached_agent(bundle_signature, streaming)
self._last_agent_cache_hit = bool(cached_agent)
if cached_agent:
# 签名相同表示已编译图中的精确工具实例仍有效;新建快照仅用于复核。
cached_bundle.catalog_checked_at = datetime.now()
logger.debug(f"复用会话内 Agent 图: session_id={self.session_id}")
return cached_agent
max_tools = settings.LLM_MAX_TOOLS
always_include_tools = (
MoviePilotToolFactory.get_tool_selector_always_include_names(tools)
@@ -1369,7 +1502,10 @@ class MoviePilotAgent:
# 中间件
middlewares = [
# 宿主策略必须位于最外层,确保插件覆盖工具基类也不能绕过。
AgentPolicyMiddleware(context=policy_context),
AgentPolicyMiddleware(
context=policy_context,
catalog=tool_catalog,
),
# Skills
skills_middleware,
# Jobs 任务管理
@@ -1412,7 +1548,12 @@ class MoviePilotAgent:
agent = create_agent(
model=agent_model,
tools=[*tools, *skill_tools, *activity_log_tools, *server_tools],
tools=[
*tools,
*skill_tools,
*activity_log_tools,
*server_tools,
],
system_prompt=system_prompt,
middleware=middlewares,
checkpointer=InMemorySaver(),
@@ -1421,6 +1562,9 @@ class MoviePilotAgent:
signature=bundle_signature,
agent=agent,
streaming=streaming,
tool_catalog=tool_catalog,
subagent_catalog=subagent_catalog,
mcp_config_signature=mcp_config_signature,
)
except Exception as e:
logger.error(f"创建 Agent 失败: {e}")

View File

@@ -541,19 +541,14 @@ class AgentMcpManager:
return tool_specs
async def list_enabled_tool_specs(self) -> list[AgentMcpToolSpec]:
"""读取所有启用 MCP 服务器暴露的工具定义。"""
"""读取所有启用 MCP 服务器暴露的工具定义并保留同名冲突"""
tool_specs: list[AgentMcpToolSpec] = []
seen_names: set[str] = set()
for server in self.get_servers():
if not server.enabled:
continue
try:
for spec in await self.list_server_tools(server):
if spec.agent_tool_name in seen_names:
logger.warning(f"跳过重复的 MCP Agent 工具名: {spec.agent_tool_name}")
continue
tool_specs.append(spec)
seen_names.add(spec.agent_tool_name)
except Exception as err:
logger.warning(f"读取 MCP 服务器 {server.name} 工具失败: {err}")
return tool_specs

View File

@@ -11,6 +11,7 @@ from app.agent.policy import (
ToolPolicyContext,
call_policy_hook,
)
from app.agent.tools.catalog import ToolCatalogSnapshot
class AgentPolicyMiddleware(AgentMiddleware):
@@ -25,10 +26,12 @@ class AgentPolicyMiddleware(AgentMiddleware):
*,
context: ToolPolicyContext,
orchestrator: AgentToolPolicyOrchestrator = DEFAULT_TOOL_POLICY_ORCHESTRATOR,
catalog: ToolCatalogSnapshot | None = None,
) -> None:
"""绑定宿主可信上下文和共享策略编排器。"""
self.context = context
self.orchestrator = orchestrator
self.catalog = catalog
async def awrap_tool_call(
self,

View File

@@ -36,6 +36,7 @@ from app.agent.policy import (
)
from app.agent.runtime import SubAgentDefinition, agent_runtime_manager
from app.agent.tools.tags import ToolTag
from app.agent.tools.catalog import ToolCatalogSnapshot
from app.log import logger
@@ -418,6 +419,7 @@ class _SubAgentAgentProvider:
tools: list[BaseTool],
server_tools: Optional[list[dict[str, Any]]] = None,
policy_context: Optional[ToolPolicyContext] = None,
catalog: Optional[ToolCatalogSnapshot] = None,
) -> None:
"""初始化子代理执行器。"""
self._model = model
@@ -425,6 +427,7 @@ class _SubAgentAgentProvider:
self._tools = tools
self._server_tools = server_tools or []
self._policy_context = policy_context or _default_subagent_policy_context(tools)
self._catalog = catalog
self._agents = {}
self._default_agent_name = "general-purpose"
@@ -442,6 +445,9 @@ class _SubAgentAgentProvider:
return profile.name, cached_agent
subagent_tools = _select_tools(self._tools, profile)
subagent_catalog = (
self._catalog.select(subagent_tools) if self._catalog is not None else None
)
logger.info(
f"创建子代理图: subagent_type={profile.name}, tools={len(subagent_tools)}"
)
@@ -450,7 +456,12 @@ class _SubAgentAgentProvider:
tools=[*subagent_tools, *self._server_tools],
system_prompt=profile.prompt,
name=profile.name,
middleware=[AgentPolicyMiddleware(context=self._policy_context)],
middleware=[
AgentPolicyMiddleware(
context=self._policy_context,
catalog=subagent_catalog,
)
],
)
self._agents[profile.name] = agent
return profile.name, agent
@@ -511,6 +522,7 @@ class MoviePilotSubAgentMiddleware(AgentMiddleware):
task_description: str = SUBAGENT_TASK_DESCRIPTION,
stream_handler: Any = None,
policy_context: Optional[ToolPolicyContext] = None,
catalog: Optional[ToolCatalogSnapshot] = None,
) -> None:
"""初始化同步子代理中间件。"""
self.system_prompt = system_prompt
@@ -521,6 +533,7 @@ class MoviePilotSubAgentMiddleware(AgentMiddleware):
tools=tools,
server_tools=server_tools,
policy_context=policy_context,
catalog=catalog,
)
self.tools = [
StructuredTool.from_function(
@@ -608,6 +621,7 @@ class SubAgentTaskControlMiddleware(AgentMiddleware):
task_description: str = SUBAGENT_CONTROL_DESCRIPTION,
stream_handler: Any = None,
policy_context: Optional[ToolPolicyContext] = None,
catalog: Optional[ToolCatalogSnapshot] = None,
) -> None:
"""初始化异步子代理调度中间件。"""
self.stream_handler = stream_handler
@@ -617,6 +631,7 @@ class SubAgentTaskControlMiddleware(AgentMiddleware):
tools=tools,
server_tools=server_tools,
policy_context=policy_context,
catalog=catalog,
)
self._semaphore = asyncio.Semaphore(SUBAGENT_MAX_CONCURRENT_TASKS)
self._tasks: dict[str, _SubAgentRuntimeTask] = {}
@@ -1193,6 +1208,7 @@ def create_subagent_middlewares(
server_tools: Optional[list[dict[str, Any]]] = None,
stream_handler: Any = None,
policy_context: Optional[ToolPolicyContext] = None,
catalog: Optional[ToolCatalogSnapshot] = None,
) -> tuple[list[AgentMiddleware], list[BaseTool]]:
"""创建子代理中间件列表和任务工具列表。"""
runtime_signature = agent_runtime_manager.current_signature()
@@ -1204,6 +1220,7 @@ def create_subagent_middlewares(
server_tools=server_tools or [],
stream_handler=stream_handler,
policy_context=policy_context,
catalog=catalog,
)
control_middleware = SubAgentTaskControlMiddleware(
model=model,
@@ -1212,6 +1229,7 @@ def create_subagent_middlewares(
server_tools=server_tools or [],
stream_handler=stream_handler,
policy_context=policy_context,
catalog=catalog,
)
task_tools = [

View File

@@ -4,9 +4,14 @@ from app.agent.policy.contracts import (
ActionEffect,
ActionPolicy,
AuthSource,
CanonicalInvocation,
ConversationKind,
DeliveryTarget,
ConfirmationMode,
ExecutionOutcome,
ExecutionReceipt,
InboundEnvelope,
InboundProvenance,
MigrationState,
PolicyDecision,
PolicyObservation,
@@ -14,10 +19,14 @@ from app.agent.policy.contracts import (
PrincipalRole,
PrincipalType,
RecoveryMode,
RECEIPT_STATE_TRANSITIONS,
ReceiptState,
ResultSensitivity,
TERMINAL_RECEIPT_STATES,
ToolInvocation,
ToolOrigin,
ToolPolicyContext,
ToolRevision,
)
from app.agent.policy.orchestrator import (
DEFAULT_TOOL_POLICY_ORCHESTRATOR,
@@ -39,11 +48,16 @@ __all__ = [
"ActionPolicy",
"AgentToolPolicyOrchestrator",
"AuthSource",
"CanonicalInvocation",
"ConversationKind",
"DeliveryTarget",
"ConfirmationMode",
"DEFAULT_TOOL_POLICY_ORCHESTRATOR",
"DEFAULT_TOOL_POLICY_REGISTRY",
"ExecutionOutcome",
"ExecutionReceipt",
"InboundEnvelope",
"InboundProvenance",
"MigrationState",
"PolicyDecision",
"PolicyObservation",
@@ -52,10 +66,14 @@ __all__ = [
"PrincipalType",
"REDACTED_VALUE",
"RecoveryMode",
"RECEIPT_STATE_TRANSITIONS",
"ReceiptState",
"ResultSensitivity",
"TERMINAL_RECEIPT_STATES",
"ToolInvocation",
"ToolOrigin",
"ToolPolicyContext",
"ToolRevision",
"ToolPolicyRegistry",
"call_policy_hook",
"sanitize_for_host",

View File

@@ -0,0 +1,130 @@
"""严格工具调用规范化,不读取设置值或执行工具实现。"""
import hashlib
import json
from typing import Any, Mapping
from pydantic import BaseModel, ValidationError
from app.agent.policy.contracts import (
ActionPolicy,
CanonicalInvocation,
ToolRevision,
)
from app.agent.tools.impl._system_setting_utils import (
list_setting_specs,
resolve_setting_spec,
)
class CanonicalizationError(ValueError):
"""当前工具 schema 或静态前提无法产生严格调用时的稳定失败。"""
def _stable_json(value: Any) -> str:
"""生成拒绝 NaN 且保留 Unicode 的稳定紧凑 JSON。"""
try:
return json.dumps(
value,
ensure_ascii=False,
allow_nan=False,
sort_keys=True,
separators=(",", ":"),
)
except (TypeError, ValueError, ValidationError) as error:
raise CanonicalizationError("调用参数无法规范化") from error
def _accepted_argument_names(args_schema: type[BaseModel]) -> set[str]:
"""返回严格入口可接受的字段名与字符串别名。"""
accepted = set(args_schema.model_fields)
for field in args_schema.model_fields.values():
for alias in (field.alias, field.validation_alias):
if isinstance(alias, str):
accepted.add(alias)
return accepted
def canonicalize_invocation(
*,
tool: Any,
arguments: Mapping[str, Any],
policy: ActionPolicy,
tool_revision: ToolRevision,
) -> CanonicalInvocation:
"""使用当前 Pydantic schema 与静态设置定义生成不可变调用摘要。"""
tool_name = str(getattr(tool, "name", "") or "")
args_schema = getattr(tool, "args_schema", None)
if not tool_name or not isinstance(args_schema, type) or not issubclass(
args_schema, BaseModel
):
raise CanonicalizationError("工具缺少严格 Pydantic 参数契约")
raw_arguments = dict(arguments or {})
unknown_arguments = set(raw_arguments) - _accepted_argument_names(args_schema)
if unknown_arguments:
raise CanonicalizationError("工具参数校验失败")
try:
validated = args_schema.model_validate(raw_arguments)
normalized = validated.model_dump(
mode="json",
exclude_unset=False,
exclude_none=False,
)
schema_json = _stable_json(args_schema.model_json_schema())
except (TypeError, ValueError) as error:
raise CanonicalizationError("工具参数校验失败") from error
preconditions: tuple[tuple[str, str], ...] = ()
setting_key = normalized.get("setting_key")
if tool_name == "query_system_settings":
if setting_key:
spec = resolve_setting_spec(setting_key)
if spec is None:
raise CanonicalizationError("系统设置项不存在")
specs = [spec]
else:
try:
specs = list_setting_specs(
group=normalized.get("group"),
keyword=normalized.get("keyword"),
)
except ValueError as error:
raise CanonicalizationError("系统设置选择器无效") from error
if not specs:
raise CanonicalizationError("系统设置选择器没有匹配项")
preconditions = tuple(
(
"setting",
f"{spec.source}:{spec.key}:{spec.group}",
)
for spec in specs
)
payload = {
"canonical_version": "p1-g2a2-canonical-v1",
"action_subtype": policy.effect.value,
"arguments": normalized,
"policy_version": policy.policy_version,
"preconditions": preconditions,
"schema_digest": hashlib.sha256(schema_json.encode("utf-8")).hexdigest(),
"tool_name": tool_name,
"tool_revision": {
"factory": tool_revision.factory,
"implementation": tool_revision.implementation,
"plugin": tool_revision.plugin,
},
}
canonical_json = _stable_json(payload)
return CanonicalInvocation(
tool_name=tool_name,
arguments=normalized,
canonical_json=canonical_json,
digest=hashlib.sha256(canonical_json.encode("utf-8")).hexdigest(),
policy_version=policy.policy_version,
tool_revision=tool_revision,
schema_digest=payload["schema_digest"],
preconditions=preconditions,
)
__all__ = ["CanonicalizationError", "canonicalize_invocation"]

View File

@@ -2,9 +2,23 @@
from dataclasses import dataclass, field
from enum import Enum
from types import MappingProxyType
from typing import Any, Mapping, MutableMapping, Optional
def _freeze_contract_value(value: Any) -> Any:
"""递归冻结确认边界中的容器,避免等待期间被调用方修改。"""
if isinstance(value, Mapping):
return MappingProxyType(
{str(key): _freeze_contract_value(item) for key, item in value.items()}
)
if isinstance(value, (list, tuple)):
return tuple(_freeze_contract_value(item) for item in value)
if isinstance(value, set):
return frozenset(_freeze_contract_value(item) for item in value)
return value
class ToolOrigin(str, Enum):
"""工具调用的宿主可信入口。"""
@@ -35,6 +49,24 @@ class AuthSource(str, Enum):
AGENT_TOKEN = "agent_token"
class InboundProvenance(str, Enum):
"""入站事实经宿主验证后的可信等级。"""
WEB_SESSION = "web_session"
VERIFIED_ADAPTER = "verified_adapter"
ADMIN_INTEGRATION = "admin_integration"
UNTRUSTED = "untrusted"
class ConversationKind(str, Enum):
"""目标会话的隐私边界。"""
WEB = "web"
PRIVATE = "private"
GROUP = "group"
UNKNOWN = "unknown"
class PrincipalRole(str, Enum):
"""策略授权使用的角色层级。"""
@@ -99,6 +131,79 @@ class ExecutionOutcome(str, Enum):
FAILED = "failed"
class ReceiptState(str, Enum):
"""严格执行回执的单向生命周期状态。"""
WAITING_CONFIRMATION = "waiting_confirmation"
VALIDATING = "validating"
EXECUTING = "executing"
DELIVERING = "delivering"
SUCCEEDED = "succeeded"
CANCELLED = "cancelled"
EXPIRED = "expired"
EXPIRED_RESTART = "expired_restart"
EXPIRED_ORPHANED = "expired_orphaned"
PREPARATION_FAILED = "preparation_failed"
PROMPT_DELIVERY_FAILED = "prompt_delivery_failed"
VALIDATION_FAILED = "validation_failed"
VALIDATION_RECORD_FAILED = "validation_record_failed"
EXECUTION_FAILED = "execution_failed"
DELIVERY_FAILED = "delivery_failed"
UNKNOWN_AFTER_RESTART = "unknown_after_restart"
UNKNOWN_ORPHANED = "unknown_orphaned"
TERMINAL_RECEIPT_STATES = frozenset({
ReceiptState.SUCCEEDED,
ReceiptState.CANCELLED,
ReceiptState.EXPIRED,
ReceiptState.EXPIRED_RESTART,
ReceiptState.EXPIRED_ORPHANED,
ReceiptState.PREPARATION_FAILED,
ReceiptState.PROMPT_DELIVERY_FAILED,
ReceiptState.VALIDATION_FAILED,
ReceiptState.VALIDATION_RECORD_FAILED,
ReceiptState.EXECUTION_FAILED,
ReceiptState.DELIVERY_FAILED,
ReceiptState.UNKNOWN_AFTER_RESTART,
ReceiptState.UNKNOWN_ORPHANED,
})
RECEIPT_STATE_TRANSITIONS = {
ReceiptState.WAITING_CONFIRMATION: frozenset({
ReceiptState.VALIDATING,
ReceiptState.CANCELLED,
ReceiptState.EXPIRED,
ReceiptState.EXPIRED_RESTART,
ReceiptState.EXPIRED_ORPHANED,
ReceiptState.PREPARATION_FAILED,
ReceiptState.PROMPT_DELIVERY_FAILED,
}),
ReceiptState.VALIDATING: frozenset({
ReceiptState.EXECUTING,
ReceiptState.CANCELLED,
ReceiptState.EXPIRED,
ReceiptState.EXPIRED_RESTART,
ReceiptState.EXPIRED_ORPHANED,
ReceiptState.VALIDATION_FAILED,
ReceiptState.VALIDATION_RECORD_FAILED,
}),
ReceiptState.EXECUTING: frozenset({
ReceiptState.DELIVERING,
ReceiptState.EXECUTION_FAILED,
ReceiptState.UNKNOWN_AFTER_RESTART,
ReceiptState.UNKNOWN_ORPHANED,
}),
ReceiptState.DELIVERING: frozenset({
ReceiptState.SUCCEEDED,
ReceiptState.DELIVERY_FAILED,
ReceiptState.UNKNOWN_AFTER_RESTART,
ReceiptState.UNKNOWN_ORPHANED,
}),
}
@dataclass(frozen=True)
class PolicyPrincipal:
"""由可信入口建立、不可由工具参数覆盖的调用主体。"""
@@ -124,6 +229,62 @@ class ToolInvocation:
source: Optional[str] = None
@dataclass(frozen=True)
class DeliveryTarget:
"""受保护结果的精确宿主路由actor 与 recipient 不得互相替代。"""
channel: str
source_instance_id: str
tenant_or_account_id: str
conversation_kind: ConversationKind
conversation_id: str
recipient_id: str
actor_id: str
server_session_id: str
@dataclass(frozen=True)
class InboundEnvelope:
"""由可信宿主入口创建的不可变入站事实。"""
provenance: InboundProvenance
target: DeliveryTarget
inbound_event_id: str
raw_text: str = field(repr=False)
normalized_text: str = field(repr=False)
has_images: bool = False
has_audio: bool = False
has_files: bool = False
is_callback: bool = False
@dataclass(frozen=True)
class ToolRevision:
"""严格调用绑定的工具实现、工厂和插件目录版本。"""
implementation: str
factory: str
plugin: str
@dataclass(frozen=True)
class CanonicalInvocation:
"""经当前工具 schema 校验、可绑定确认与版本前提的调用。"""
tool_name: str
arguments: Mapping[str, Any] = field(repr=False)
canonical_json: str = field(repr=False)
digest: str
policy_version: str
tool_revision: ToolRevision
schema_digest: str
preconditions: tuple[tuple[str, str], ...] = ()
def __post_init__(self) -> None:
"""阻止调用方在确认等待期间修改规范化参数。"""
object.__setattr__(self, "arguments", _freeze_contract_value(self.arguments))
@dataclass(frozen=True)
class ActionPolicy:
"""参数级动作策略及其兼容迁移状态。"""
@@ -230,9 +391,14 @@ __all__ = [
"ActionEffect",
"ActionPolicy",
"AuthSource",
"CanonicalInvocation",
"ConfirmationMode",
"ConversationKind",
"DeliveryTarget",
"ExecutionOutcome",
"ExecutionReceipt",
"InboundEnvelope",
"InboundProvenance",
"MigrationState",
"PolicyDecision",
"PolicyObservation",
@@ -240,8 +406,12 @@ __all__ = [
"PrincipalRole",
"PrincipalType",
"RecoveryMode",
"RECEIPT_STATE_TRANSITIONS",
"ReceiptState",
"ResultSensitivity",
"TERMINAL_RECEIPT_STATES",
"ToolInvocation",
"ToolOrigin",
"ToolPolicyContext",
"ToolRevision",
]

View File

@@ -9,6 +9,7 @@ from langchain_core.messages import ToolMessage
from pydantic import ValidationError
from app.agent.policy.contracts import (
ConfirmationMode,
ExecutionOutcome,
ExecutionReceipt,
MigrationState,
@@ -101,6 +102,14 @@ class AgentToolPolicyOrchestrator:
shadow=True,
reason_code="legacy_shadow_allow",
)
elif policy.confirmation is ConfirmationMode.REQUIRED:
# 严格运行时接管前保持既有调用能力,但不得把敏感动作记为安全读取。
decision = PolicyDecision(
allowed=True,
confirmation_required=False,
shadow=True,
reason_code="strict_runtime_pending",
)
else:
decision = PolicyDecision(
allowed=True,

View File

@@ -140,10 +140,25 @@ class ToolPolicyRegistry:
requires_admin: bool,
) -> ActionPolicy:
"""根据工具名和宿主权限声明解析当前迁移策略。"""
del arguments # 参数级迁移由后续领域 Goal 逐项加入。
required_role = (
PrincipalRole.SYSTEM_ADMIN if requires_admin else PrincipalRole.USER
)
if (
tool_name == "query_system_settings"
and arguments.get("show_secrets") is True
):
return ActionPolicy(
effect=ActionEffect.SENSITIVE_READ,
required_role=PrincipalRole.SYSTEM_ADMIN,
confirmation=ConfirmationMode.REQUIRED,
recovery=RecoveryMode.NONE,
result_sensitivity=ResultSensitivity.SECRET,
migration_state=MigrationState.ENFORCED,
policy_version="p1-g2a2-v1",
machine_allowed=True,
background_allowed=False,
subagent_allowed=False,
)
if tool_name in self._safe_read_tool_names:
return ActionPolicy(
effect=ActionEffect.SAFE_READ,

189
app/agent/tools/catalog.py Normal file
View File

@@ -0,0 +1,189 @@
"""Agent 本地工具目录的不可变快照与严格解析。"""
import hashlib
import json
from dataclasses import dataclass, field
from types import MappingProxyType
from typing import Any, Mapping, Optional
from pydantic import BaseModel
from app.agent.policy import ToolRevision
class ToolCatalogError(RuntimeError):
"""工具目录无法建立可信当前视图时的稳定失败。"""
class ToolIdentityAmbiguousError(ToolCatalogError):
"""同一工具名对应多个实现,无法进行严格解析。"""
def _stable_json(value: Any) -> str:
"""生成工具身份摘要使用的稳定 JSON。"""
return json.dumps(
value,
ensure_ascii=False,
allow_nan=False,
sort_keys=True,
separators=(",", ":"),
)
def _schema_digest(tool: Any) -> str:
"""计算工具当前 Pydantic 参数契约摘要。"""
args_schema = getattr(tool, "args_schema", None)
if isinstance(args_schema, type) and issubclass(args_schema, BaseModel):
schema = args_schema.model_json_schema()
elif isinstance(args_schema, Mapping):
schema = dict(args_schema)
else:
schema = {"type": "object", "properties": {}}
return hashlib.sha256(_stable_json(schema).encode("utf-8")).hexdigest()
def _implementation_identity(tool: Any) -> str:
"""返回不依赖对象地址、可区分动态绑定的工具实现身份。"""
tool_class = type(tool)
implementation = f"{tool_class.__module__}.{tool_class.__qualname__}"
binding = str(getattr(tool, "_agent_tool_binding", "") or "")
return f"{implementation}:{binding}" if binding else implementation
@dataclass(frozen=True)
class ToolCatalogEntry:
"""绑定一次目录构造中精确工具实例的身份记录。"""
name: str
source: str
identity: str
description_digest: str
schema_digest: str
revision: ToolRevision
tool: Any = field(repr=False, compare=False)
@dataclass(frozen=True)
class ToolCatalogSnapshot:
"""同一图构造与严格执行共享的本地工具事实源。"""
entries: tuple[ToolCatalogEntry, ...]
plugin_revision: int
factory_revision: str
_by_name: Mapping[str, tuple[ToolCatalogEntry, ...]] = field(
init=False,
repr=False,
compare=False,
)
def __post_init__(self) -> None:
"""建立只读名称索引并保留所有冲突项。"""
by_name: dict[str, list[ToolCatalogEntry]] = {}
for entry in self.entries:
by_name.setdefault(entry.name, []).append(entry)
object.__setattr__(
self,
"_by_name",
MappingProxyType(
{name: tuple(matches) for name, matches in by_name.items()}
),
)
@classmethod
def from_tools(
cls,
tools: list[Any],
*,
plugin_revision: int,
factory_revision: str,
) -> "ToolCatalogSnapshot":
"""从已完成上下文注入的精确实例建立不可变目录。"""
entries = []
for tool in tools:
name = str(getattr(tool, "name", "") or "")
if not name:
raise ToolCatalogError("工具缺少稳定名称")
source = str(getattr(tool, "_agent_tool_source", "builtin"))
schema_digest = _schema_digest(tool)
description_digest = hashlib.sha256(
str(getattr(tool, "description", "") or "").encode("utf-8")
).hexdigest()
implementation = _implementation_identity(tool)
revision = ToolRevision(
implementation=implementation,
factory=factory_revision,
plugin=str(plugin_revision),
)
entries.append(
ToolCatalogEntry(
name=name,
source=source,
identity=f"{source}:{implementation}:{schema_digest}",
description_digest=description_digest,
schema_digest=schema_digest,
revision=revision,
tool=tool,
)
)
return cls(
entries=tuple(entries),
plugin_revision=plugin_revision,
factory_revision=factory_revision,
)
@property
def tools(self) -> list[Any]:
"""按目录顺序返回精确工具实例。"""
return [entry.tool for entry in self.entries]
@property
def collisions(self) -> Mapping[str, tuple[ToolCatalogEntry, ...]]:
"""返回所有同名工具,不按注册顺序隐式选胜者。"""
return MappingProxyType(
{name: entries for name, entries in self._by_name.items() if len(entries) > 1}
)
@property
def signature(self) -> tuple[Any, ...]:
"""返回可参与 Agent 图缓存的完整目录签名。"""
return (
self.factory_revision,
self.plugin_revision,
tuple(
(
entry.name,
entry.identity,
entry.description_digest,
entry.schema_digest,
)
for entry in self.entries
),
)
def resolve_unique(self, name: str) -> Optional[ToolCatalogEntry]:
"""严格解析当前唯一实现;重名时拒绝继承 first/last-wins。"""
entries = self._by_name.get(name, ())
if len(entries) > 1:
raise ToolIdentityAmbiguousError("TOOL_IDENTITY_AMBIGUOUS")
return entries[0] if entries else None
def select(self, tools: list[Any]) -> "ToolCatalogSnapshot":
"""为子图保留所选工具名的全部候选身份与 revision 语义。"""
selected_names = {
str(getattr(tool, "name", "") or "") for tool in tools
}
return ToolCatalogSnapshot(
entries=tuple(
entry for entry in self.entries if entry.name in selected_names
),
plugin_revision=self.plugin_revision,
factory_revision=self.factory_revision,
)
__all__ = [
"ToolCatalogEntry",
"ToolCatalogError",
"ToolCatalogSnapshot",
"ToolIdentityAmbiguousError",
]

View File

@@ -1,3 +1,5 @@
import hashlib
from typing import Callable, List, Optional, Type
from app.agent.tools.impl.add_download_tasks import AddDownloadTasksTool
@@ -90,6 +92,7 @@ from app.log import logger
from app.schemas.message import ChannelCapabilityManager
from app.schemas.types import MessageChannel
from .base import MoviePilotTool
from .catalog import ToolCatalogError, ToolCatalogSnapshot
class MoviePilotToolFactory:
@@ -195,6 +198,17 @@ class MoviePilotToolFactory:
"query_agent_tasks",
)
CATALOG_BUILD_MAX_ATTEMPTS = 3
@classmethod
def catalog_factory_revision(cls) -> str:
"""返回当前内置工具工厂定义的稳定摘要。"""
identities = (
f"{tool_class.__module__}.{tool_class.__qualname__}"
for tool_class in cls.BUILTIN_TOOL_CLASSES
)
return hashlib.sha256("\n".join(identities).encode("utf-8")).hexdigest()
@staticmethod
def _should_enable_choice_tool(channel: Optional[str] = None) -> bool:
if not channel:
@@ -266,6 +280,7 @@ class MoviePilotToolFactory:
tool.set_message_attr(channel=channel, source=source, username=username)
tool.set_stream_handler(stream_handler=stream_handler)
tool.set_agent_context(agent_context=agent_context)
object.__setattr__(tool, "_agent_tool_source", "builtin")
tools.append(tool)
# 加载插件提供的工具
@@ -292,6 +307,11 @@ class MoviePilotToolFactory:
)
tool.set_stream_handler(stream_handler=stream_handler)
tool.set_agent_context(agent_context=agent_context)
object.__setattr__(
tool,
"_agent_tool_source",
f"plugin:{plugin_id or 'unknown'}",
)
tools.append(tool)
plugin_tools_count += 1
logger.debug(
@@ -310,3 +330,19 @@ class MoviePilotToolFactory:
else:
logger.debug(f"成功创建 {len(tools)} 个MoviePilot工具")
return tools
@classmethod
def create_catalog(cls, **tool_kwargs) -> ToolCatalogSnapshot:
"""在插件目录稳定窗口内构造一份完整本地工具快照。"""
plugin_manager = PluginManager()
for _attempt in range(cls.CATALOG_BUILD_MAX_ATTEMPTS):
before_revision = plugin_manager.get_plugin_agent_tools_revision()
tools = cls.create_tools(**tool_kwargs)
after_revision = plugin_manager.get_plugin_agent_tools_revision()
if before_revision == after_revision:
return ToolCatalogSnapshot.from_tools(
tools,
plugin_revision=after_revision,
factory_revision=cls.catalog_factory_revision(),
)
raise ToolCatalogError("插件工具目录持续变化,无法建立当前快照")

View File

@@ -1,5 +1,6 @@
"""外部 MCP 工具适配器。"""
import hashlib
import json
from typing import Any, Optional
@@ -77,6 +78,22 @@ class McpExternalTool(MoviePilotTool):
return json.dumps(result, ensure_ascii=False, indent=2, default=str)
def _mcp_binding_identity(spec: AgentMcpToolSpec) -> str:
"""生成不暴露端点配置的稳定 MCP 工具绑定身份。"""
payload = json.dumps(
{
"agent_tool_name": spec.agent_tool_name,
"server_id": spec.server.id,
"tool_name": spec.name,
},
ensure_ascii=False,
allow_nan=False,
sort_keys=True,
separators=(",", ":"),
)
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
async def create_external_mcp_tools(
*,
session_id: str,
@@ -86,13 +103,41 @@ async def create_external_mcp_tools(
username: Optional[str] = None,
stream_handler=None,
agent_context: Optional[dict] = None,
specs: Optional[list[AgentMcpToolSpec]] = None,
) -> list[McpExternalTool]:
"""创建当前已启用的外部 MCP Agent 工具列表。"""
tools = []
for spec in await agent_mcp_manager.list_enabled_tool_specs():
current_specs = specs
if current_specs is None:
current_specs = await agent_mcp_manager.list_enabled_tool_specs()
for spec in current_specs:
tool = McpExternalTool(spec=spec, session_id=session_id, user_id=user_id)
tool.set_message_attr(channel=channel, source=source, username=username)
tool.set_stream_handler(stream_handler=stream_handler)
tool.set_agent_context(agent_context=agent_context)
object.__setattr__(
tool,
"_agent_tool_source",
f"mcp:{spec.server.id}",
)
object.__setattr__(
tool,
"_agent_tool_binding",
_mcp_binding_identity(spec),
)
tools.append(tool)
return tools
def select_legacy_mcp_tools(
tools: list[McpExternalTool],
) -> list[McpExternalTool]:
"""保留跨服务器同名工具历史上的 first-wins 执行顺序。"""
selected = []
seen_names = set()
for tool in tools:
if tool.name in seen_names:
continue
selected.append(tool)
seen_names.add(tool.name)
return selected

View File

@@ -15,6 +15,7 @@ from app.agent.policy import (
)
from app.agent.tools.base import ToolExecutionTimeoutError, format_tool_result_for_agent
from app.agent.tools.factory import MoviePilotToolFactory
from app.agent.tools.catalog import ToolCatalogSnapshot
from app.core.plugin import PluginManager
from app.log import logger
@@ -66,6 +67,7 @@ class MoviePilotToolsManager:
agent_context={"is_admin": is_admin},
)
self.tools: List[Any] = []
self.catalog: Optional[ToolCatalogSnapshot] = None
self._tools_lock = threading.Lock()
self._plugin_agent_tools_revision = -1
self._load_tools()
@@ -75,31 +77,23 @@ class MoviePilotToolsManager:
加载所有MoviePilot工具
"""
try:
plugin_manager = PluginManager()
while True:
plugin_tools_revision = (
plugin_manager.get_plugin_agent_tools_revision()
)
tools = MoviePilotToolFactory.create_tools(
session_id=self.session_id,
user_id=self.user_id,
channel=None,
source="api",
username="API Client",
stream_handler=None,
agent_context={"is_admin": self.is_admin},
)
if (
plugin_tools_revision
== plugin_manager.get_plugin_agent_tools_revision()
):
break
self.tools = tools
self._plugin_agent_tools_revision = plugin_tools_revision
catalog = MoviePilotToolFactory.create_catalog(
session_id=self.session_id,
user_id=self.user_id,
channel=None,
source="api",
username="API Client",
stream_handler=None,
agent_context={"is_admin": self.is_admin},
)
self.catalog = catalog
self.tools = catalog.tools
self._plugin_agent_tools_revision = catalog.plugin_revision
logger.info(f"成功加载 {len(self.tools)} 个工具")
except Exception as e:
logger.error(f"加载工具失败: {summarize_error(e)}")
self.tools = []
self.catalog = None
self._plugin_agent_tools_revision = -1
def _ensure_tools_current(self) -> None:
@@ -162,10 +156,26 @@ class MoviePilotToolsManager:
工具实例如果未找到返回None
"""
self._ensure_tools_current()
for tool in self.tools:
if tool.name == tool_name:
return tool
return None
return next(
(tool for tool in self.tools if tool.name == tool_name),
None,
)
def get_strict_tool(self, tool_name: str) -> Optional[Any]:
"""按当前目录唯一身份解析严格调用,重名时稳定失败。"""
self._ensure_tools_current()
if self.catalog is None or [
id(tool) for tool in self.catalog.tools
] != [id(tool) for tool in self.tools]:
self.catalog = ToolCatalogSnapshot.from_tools(
self.tools,
plugin_revision=self._plugin_agent_tools_revision,
factory_revision=MoviePilotToolFactory.catalog_factory_revision(),
)
if self.catalog is None:
return None
entry = self.catalog.resolve_unique(tool_name)
return entry.tool if entry else None
@staticmethod
def _resolve_field_schema(field_info: Dict[str, Any]) -> Dict[str, Any]:

View File

@@ -41,6 +41,7 @@ from app.utils.system import SystemUtils
class PluginManager(ConfigReloadMixin, metaclass=Singleton):
"""插件管理器"""
CONFIG_WATCH = {"DEV", "PLUGIN_AUTO_RELOAD", "PLUGIN_LOCAL_REPO_PATHS"}
AGENT_TOOLS_BUILD_MAX_ATTEMPTS = 3
def __init__(self):
# 插件列表
@@ -1011,7 +1012,7 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton):
}]
"""
cache_key = pid or "__all__"
while True:
for _attempt in range(self.AGENT_TOOLS_BUILD_MAX_ATTEMPTS):
with self._plugin_agent_tools_cache_lock:
cache_revision = self._plugin_agent_tools_revision
cached_tools = self._plugin_agent_tools_cache.get(cache_key)
@@ -1047,6 +1048,7 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton):
ret_tools
)
return ret_tools
raise RuntimeError("插件工具注册表持续变化,无法建立当前快照")
@staticmethod
def get_plugin_remote_entry(plugin_id: str, dist_path: str) -> str:

View File

@@ -1,6 +1,7 @@
"""Agent 图缓存行为测试。"""
from datetime import datetime
from contextlib import ExitStack
from datetime import datetime, timedelta
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
@@ -8,7 +9,15 @@ import pytest
from langchain_core.messages import AIMessage, HumanMessage
from app.agent import MoviePilotAgent, ReplyMode, _CompiledAgentBundle
from app.agent.mcp import AgentMcpToolSpec
from app.agent.middleware.policy import AgentPolicyMiddleware
from app.agent.tools.catalog import (
ToolCatalogSnapshot,
ToolIdentityAmbiguousError,
)
from app.agent.tools.impl.mcp import create_external_mcp_tools
from app.core.config import settings
from app.schemas.agent import AgentMcpServerConfig
@pytest.fixture
@@ -45,18 +54,32 @@ class _CapturingAgent:
async def test_create_agent_reuses_cached_graph_when_signature_matches():
"""构造签名一致时应直接复用已编译 Agent 图。"""
cached_graph = object()
catalog = ToolCatalogSnapshot.from_tools(
[], plugin_revision=0, factory_revision="factory-v1"
)
agent = MoviePilotAgent(session_id="cache-hit", user_id="user-1")
agent._compiled_agent_bundle = _CompiledAgentBundle(
signature=("sig",),
agent=cached_graph,
streaming=False,
created_at=datetime.now(),
tool_catalog=catalog,
subagent_catalog=catalog,
plugin_revision=0,
mcp_config_signature="mcp-config",
catalog_checked_at=datetime.now(),
)
with patch.object(
agent,
"_agent_bundle_signature",
new=AsyncMock(return_value=("sig",)),
), patch(
"app.agent.PluginManager.get_plugin_agent_tools_revision",
return_value=0,
), patch(
"app.agent.agent_mcp_manager.config_signature",
return_value="mcp-config",
), patch("app.agent.create_agent") as create_agent:
graph = await agent._create_agent(streaming=False)
@@ -65,6 +88,130 @@ async def test_create_agent_reuses_cached_graph_when_signature_matches():
create_agent.assert_not_called()
@pytest.mark.anyio
async def test_fresh_catalog_cache_hit_skips_tool_and_mcp_discovery() -> None:
"""目录仍在 freshness 窗口内时,缓存命中不得重建工具或访问 MCP。"""
cached_graph = object()
catalog = ToolCatalogSnapshot.from_tools(
[], plugin_revision=0, factory_revision="factory-v1"
)
agent = MoviePilotAgent(session_id="catalog-cache-hit", user_id="user-1")
agent._compiled_agent_bundle = _CompiledAgentBundle(
signature=("sig",),
agent=cached_graph,
streaming=False,
created_at=datetime.now(),
tool_catalog=catalog,
subagent_catalog=catalog,
plugin_revision=0,
mcp_config_signature="mcp-config",
catalog_checked_at=datetime.now(),
)
with patch.object(
agent,
"_agent_bundle_signature",
new=AsyncMock(return_value=("sig",)),
), patch.object(
agent,
"_initialize_local_tool_catalogs",
side_effect=AssertionError("tool catalog rebuilt"),
), patch(
"app.agent.PluginManager.get_plugin_agent_tools_revision",
return_value=0,
), patch(
"app.agent.agent_mcp_manager.config_signature",
return_value="mcp-config",
), patch(
"app.agent.agent_mcp_manager.list_enabled_tool_specs",
new=AsyncMock(side_effect=AssertionError("MCP discovery called")),
):
graph = await agent._create_agent(streaming=False)
assert graph is cached_graph
assert agent._last_agent_cache_hit is True
@pytest.mark.anyio
async def test_expired_unchanged_catalog_renews_freshness() -> None:
"""过期目录复核后若签名未变,应续期缓存而不是每轮重复 discovery。"""
cached_graph = object()
catalog = ToolCatalogSnapshot.from_tools(
[], plugin_revision=0, factory_revision="factory-v1"
)
expired_at = datetime.now() - timedelta(minutes=5)
agent = MoviePilotAgent(session_id="catalog-refresh", user_id="user-1")
agent._compiled_agent_bundle = _CompiledAgentBundle(
signature=("sig",),
agent=cached_graph,
streaming=False,
created_at=datetime.now(),
tool_catalog=catalog,
subagent_catalog=catalog,
plugin_revision=0,
mcp_config_signature="mcp-config",
catalog_checked_at=expired_at,
)
fake_llm = SimpleNamespace(
_llm_type="openai-chat",
model="fake",
profile={"max_input_tokens": 64000},
)
with patch.object(
agent,
"_agent_bundle_signature",
new=AsyncMock(return_value=("sig",)),
), patch.object(
agent,
"_initialize_local_tool_catalogs",
return_value=(catalog, catalog),
), patch.object(
agent,
"_initialize_mcp_tools",
new=AsyncMock(return_value=[]),
), patch.object(
agent,
"_initialize_subagent_mcp_tools",
new=AsyncMock(return_value=[]),
), patch.object(
agent,
"_initialize_llm",
new=AsyncMock(return_value=fake_llm),
), patch.object(
agent,
"_sync_model_profile",
), patch(
"app.agent.ServerToolRegistry.resolve_web_search",
return_value=SimpleNamespace(use_local_web_search=True),
), patch(
"app.agent.LLMHelper.get_server_tools",
return_value=[],
), patch(
"app.agent.prompt_manager.get_agent_prompt",
return_value="prompt",
), patch(
"app.agent.SkillsMiddleware",
return_value=SimpleNamespace(name="skills", tools=[]),
), patch(
"app.agent.create_subagent_middlewares",
return_value=([], []),
), patch(
"app.agent.PluginManager.get_plugin_agent_tools_revision",
return_value=0,
), patch(
"app.agent.agent_mcp_manager.config_signature",
return_value="mcp-config",
), patch(
"app.agent.agent_mcp_manager.list_enabled_tool_specs",
new=AsyncMock(return_value=[]),
):
graph = await agent._create_agent(streaming=False)
assert graph is cached_graph
assert agent._compiled_agent_bundle.catalog_checked_at > expired_at
@pytest.mark.anyio
async def test_agent_bundle_signature_changes_with_temperature(monkeypatch) -> None:
"""温度配置变化时应使会话内 Agent 图缓存失效。"""
@@ -94,6 +241,256 @@ async def test_agent_bundle_signature_changes_with_temperature(monkeypatch) -> N
assert updated_signature != initial_signature
@pytest.mark.anyio
async def test_agent_bundle_signature_changes_with_tool_catalog() -> None:
"""工具目录 revision 必须参与会话内 Agent 图缓存签名。"""
agent = MoviePilotAgent(session_id="tool-revision", user_id="user-1")
runtime_config = {"provider": "openai", "model": "gpt-test"}
first_catalog = ToolCatalogSnapshot.from_tools(
[], plugin_revision=1, factory_revision="factory-v1"
)
second_catalog = ToolCatalogSnapshot.from_tools(
[], plugin_revision=2, factory_revision="factory-v1"
)
with patch.object(
agent,
"_resolve_llm_runtime_config",
new=AsyncMock(return_value=runtime_config),
):
first_signature = await agent._agent_bundle_signature(
streaming=False,
tool_catalog=first_catalog,
subagent_catalog=first_catalog,
)
second_signature = await agent._agent_bundle_signature(
streaming=False,
tool_catalog=second_catalog,
subagent_catalog=second_catalog,
)
assert second_signature != first_signature
@pytest.mark.anyio
@pytest.mark.parametrize("max_tools", [0, 5])
async def test_graph_keeps_mcp_first_winner_and_catalogs_all_collisions(
max_tools: int,
) -> None:
"""主图与子图保持 MCP first-wins严格目录覆盖全部客户端工具。"""
servers = [
AgentMcpServerConfig(
id=server_id,
name="Shared Name",
transport="stdio",
command=server_id,
)
for server_id in ("one", "two")
]
specs = [
AgentMcpToolSpec(
server=server,
name="echo",
agent_tool_name="shared_echo",
description="echo",
input_schema={"type": "object", "properties": {}},
)
for server in servers
]
main_tools = await create_external_mcp_tools(
session_id="session",
user_id="user",
specs=specs,
)
subagent_tools = await create_external_mcp_tools(
session_id="session",
user_id="user",
specs=specs,
)
empty_catalog = ToolCatalogSnapshot.from_tools(
[], plugin_revision=0, factory_revision="factory-v1"
)
fake_llm = SimpleNamespace(
_llm_type="openai-chat",
model="fake",
profile={"max_input_tokens": 64000},
)
skill_tool = SimpleNamespace(
name="shared_echo",
description="skill collision",
args_schema={"type": "object", "properties": {}},
_agent_tool_source="middleware:skills",
)
activity_tool = SimpleNamespace(
name="query_activity_log",
description="activity log",
args_schema={"type": "object", "properties": {}},
_agent_tool_source="middleware:activity_log",
)
subagent_task_tool = SimpleNamespace(
name="task",
description="subagent task",
args_schema={"type": "object", "properties": {}},
_agent_tool_source="middleware:subagents",
)
captured = {}
agent = MoviePilotAgent(
session_id="mcp-collision",
user_id="user",
channel="web",
source="test",
)
def _capture_subagents(**kwargs):
captured["subagent_tools"] = kwargs["tools"]
captured["subagent_catalog"] = kwargs["catalog"]
return [], [subagent_task_tool]
def _capture_selector(**kwargs):
captured["selection_tools"] = kwargs["selection_tools"]
return SimpleNamespace(name="selector")
def _capture_agent(**kwargs):
captured["agent_tools"] = kwargs["tools"]
captured["middlewares"] = kwargs["middleware"]
return object()
patchers = [
patch.object(
agent,
"_resolve_llm_runtime_config",
new=AsyncMock(return_value={"provider": "openai", "model": "fake"}),
),
patch.object(
agent,
"_initialize_local_tool_catalogs",
return_value=(empty_catalog, empty_catalog),
),
patch.object(
agent,
"_initialize_mcp_tools",
new=AsyncMock(return_value=main_tools),
),
patch.object(
agent,
"_initialize_subagent_mcp_tools",
new=AsyncMock(return_value=subagent_tools),
),
patch.object(
agent,
"_agent_bundle_signature",
new=AsyncMock(return_value=("mcp-collision", max_tools)),
),
patch.object(
agent,
"_initialize_llm",
new=AsyncMock(return_value=fake_llm),
),
patch.object(agent, "_sync_model_profile"),
patch(
"app.agent.PluginManager.get_plugin_agent_tools_revision",
return_value=0,
),
patch(
"app.agent.agent_mcp_manager.config_signature",
return_value="mcp-config",
),
patch(
"app.agent.agent_mcp_manager.list_enabled_tool_specs",
new=AsyncMock(return_value=specs),
),
patch(
"app.agent.ServerToolRegistry.resolve_web_search",
return_value=SimpleNamespace(use_local_web_search=True),
),
patch("app.agent.LLMHelper.get_server_tools", return_value=[]),
patch("app.agent.prompt_manager.get_agent_prompt", return_value="prompt"),
patch(
"app.agent.create_subagent_middlewares",
side_effect=_capture_subagents,
),
patch(
"app.agent.MoviePilotToolFactory.get_tool_selector_always_include_names",
return_value=[],
),
patch(
"app.agent.SkillsMiddleware",
return_value=SimpleNamespace(name="skills", tools=[skill_tool]),
),
patch(
"app.agent.ActivityLogMiddleware",
return_value=SimpleNamespace(name="activity", tools=[activity_tool]),
),
patch(
"app.agent.JobsMiddleware",
return_value=SimpleNamespace(name="jobs"),
),
patch(
"app.agent.RuntimeConfigMiddleware",
return_value=SimpleNamespace(name="runtime"),
),
patch(
"app.agent.MemoryMiddleware",
return_value=SimpleNamespace(name="memory"),
),
patch(
"app.agent.SummarizationMiddleware",
return_value=SimpleNamespace(name="summary"),
),
patch(
"app.agent.PatchToolCallsMiddleware",
return_value=SimpleNamespace(name="patch"),
),
patch(
"app.agent.UsageMiddleware",
return_value=SimpleNamespace(name="usage"),
),
patch(
"app.agent.ToolSelectorMiddleware",
side_effect=_capture_selector,
),
patch("app.agent.InMemorySaver", return_value=object()),
patch("app.agent.create_agent", side_effect=_capture_agent),
patch.object(settings, "LLM_MAX_TOOLS", max_tools),
]
with ExitStack() as stack:
for patcher in patchers:
stack.enter_context(patcher)
await agent._create_agent(streaming=False)
assert captured["agent_tools"] == [main_tools[0], skill_tool, activity_tool]
assert captured["subagent_tools"] == [subagent_tools[0]]
if max_tools:
assert captured["selection_tools"] == [
main_tools[0],
skill_tool,
activity_tool,
subagent_task_tool,
]
else:
assert "selection_tools" not in captured
policy_middleware = next(
middleware
for middleware in captured["middlewares"]
if isinstance(middleware, AgentPolicyMiddleware)
)
assert [
entry.source
for entry in policy_middleware.catalog.collisions["shared_echo"]
] == ["mcp:one", "mcp:two", "middleware:skills"]
assert (
policy_middleware.catalog.resolve_unique("query_activity_log").tool
is activity_tool
)
assert policy_middleware.catalog.resolve_unique("task").tool is subagent_task_tool
with pytest.raises(ToolIdentityAmbiguousError, match="TOOL_IDENTITY_AMBIGUOUS"):
policy_middleware.catalog.resolve_unique("shared_echo")
assert [
entry.source
for entry in captured["subagent_catalog"].collisions["shared_echo"]
] == ["mcp:one", "mcp:two"]
@pytest.mark.anyio
async def test_execute_agent_sends_only_latest_message_on_cache_hit():
"""缓存命中时只把本轮新消息交给 LangGraph避免重复提交历史。"""

View File

@@ -1,10 +1,16 @@
import sys
import textwrap
from unittest.mock import AsyncMock, patch
import pytest
from app.agent.mcp import AgentMcpManager, AgentMcpToolSpec
from app.agent.tools.impl.mcp import McpExternalTool
from app.agent.tools.catalog import ToolCatalogSnapshot
from app.agent.tools.impl.mcp import (
McpExternalTool,
create_external_mcp_tools,
select_legacy_mcp_tools,
)
from app.schemas.agent import AgentMcpServerConfig
@@ -80,6 +86,122 @@ async def test_stdio_mcp_server_lists_tools(tmp_path):
assert tools[0].input_schema["properties"]["text"]["type"] == "string"
@pytest.mark.anyio
async def test_enabled_specs_preserve_cross_server_name_collisions() -> None:
"""跨 MCP 服务器生成相同 Agent 名时必须把全部身份交给目录判断。"""
manager = AgentMcpManager()
first = AgentMcpServerConfig(id="one", name="one", transport="stdio", command="one")
second = AgentMcpServerConfig(id="two", name="two", transport="stdio", command="two")
def _spec(server):
return AgentMcpToolSpec(
server=server,
name="echo",
agent_tool_name="shared_echo",
description="echo",
input_schema={"type": "object", "properties": {}},
)
with patch.object(manager, "get_servers", return_value=[first, second]), patch.object(
manager,
"list_server_tools",
new=AsyncMock(side_effect=lambda server: [_spec(server)]),
):
specs = await manager.list_enabled_tool_specs()
assert [spec.server.id for spec in specs] == ["one", "two"]
@pytest.mark.anyio
async def test_mcp_catalog_uses_server_id_and_legacy_execution_keeps_first() -> None:
"""目录应区分同显示名服务器,普通执行仍保留首个同名工具。"""
first_server = AgentMcpServerConfig(
id="one",
name="Shared Name",
transport="stdio",
command="one",
)
second_server = AgentMcpServerConfig(
id="two",
name="Shared Name",
transport="stdio",
command="two",
)
def _spec(server):
return AgentMcpToolSpec(
server=server,
name="echo",
agent_tool_name="shared_echo",
description="echo",
input_schema={"type": "object", "properties": {}},
)
tools = await create_external_mcp_tools(
session_id="session",
user_id="user",
specs=[_spec(first_server), _spec(second_server)],
)
catalog = ToolCatalogSnapshot.from_tools(
tools,
plugin_revision=0,
factory_revision="factory-v1",
)
assert [entry.source for entry in catalog.collisions["shared_echo"]] == [
"mcp:one",
"mcp:two",
]
assert len({entry.identity for entry in catalog.collisions["shared_echo"]}) == 2
assert select_legacy_mcp_tools(tools) == [tools[0]]
@pytest.mark.anyio
async def test_mcp_catalog_distinguishes_normalized_names_within_server() -> None:
"""同一服务内规范化重名的原始工具仍应具有不同绑定身份。"""
server = AgentMcpServerConfig(
id="shared",
name="Shared",
transport="stdio",
command="shared",
)
def _spec(name: str) -> AgentMcpToolSpec:
return AgentMcpToolSpec(
server=server,
name=name,
agent_tool_name="mcp_shared_foo_bar",
description="same description",
input_schema={"type": "object", "properties": {}},
)
tools = await create_external_mcp_tools(
session_id="session",
user_id="user",
specs=[_spec("foo-bar"), _spec("foo_bar")],
)
catalog = ToolCatalogSnapshot.from_tools(
tools,
plugin_revision=0,
factory_revision="factory-v1",
)
first_only = ToolCatalogSnapshot.from_tools(
[tools[0]],
plugin_revision=0,
factory_revision="factory-v1",
)
second_only = ToolCatalogSnapshot.from_tools(
[tools[1]],
plugin_revision=0,
factory_revision="factory-v1",
)
collisions = catalog.collisions["mcp_shared_foo_bar"]
assert len({entry.identity for entry in collisions}) == 2
assert len({entry.revision.implementation for entry in collisions}) == 2
assert first_only.signature != second_only.signature
@pytest.mark.anyio
async def test_stdio_mcp_server_calls_tool(tmp_path):
"""stdio MCP 工具应能通过 tools/call 返回内容。"""

View File

@@ -0,0 +1,228 @@
import math
from dataclasses import FrozenInstanceError
import pytest
from pydantic import BaseModel, ConfigDict, Field
from app.agent.policy.canonical import (
CanonicalizationError,
canonicalize_invocation,
)
from app.agent.policy.contracts import (
ConversationKind,
DeliveryTarget,
InboundEnvelope,
InboundProvenance,
ToolRevision,
)
from app.agent.policy.registry import DEFAULT_TOOL_POLICY_REGISTRY
from app.agent.tools.impl.query_system_settings import QuerySystemSettingsTool
def _secret_policy():
return DEFAULT_TOOL_POLICY_REGISTRY.resolve(
tool_name="query_system_settings",
arguments={"show_secrets": True},
requires_admin=True,
)
def _tool_revision() -> ToolRevision:
return ToolRevision(
implementation="query-system-settings:1",
factory="builtin-factory:1",
plugin="plugin-catalog:1",
)
def test_canonical_invocation_includes_defaults_and_is_stable() -> None:
"""省略的默认值必须进入相同规范化参数与摘要。"""
tool = QuerySystemSettingsTool(session_id="session-1", user_id="admin")
omitted = canonicalize_invocation(
tool=tool,
arguments={"setting_key": "COOKIECLOUD_KEY", "show_secrets": True},
policy=_secret_policy(),
tool_revision=_tool_revision(),
)
explicit = canonicalize_invocation(
tool=tool,
arguments={
"setting_key": "COOKIECLOUD_KEY",
"group": "all",
"keyword": None,
"include_values": None,
"show_secrets": True,
},
policy=_secret_policy(),
tool_revision=_tool_revision(),
)
assert omitted.digest == explicit.digest
assert omitted.arguments == explicit.arguments
assert omitted.preconditions == (
("setting", "settings:COOKIECLOUD_KEY:settings"),
)
def test_canonical_invocation_repr_hides_arguments_and_json() -> None:
"""调用对象的 repr 不得泄露确认参数或完整规范化 JSON。"""
marker = "canonical-private-marker"
class _Input(BaseModel):
model_config = ConfigDict(extra="forbid")
value: str
class _Tool:
name = "private_tool"
args_schema = _Input
invocation = canonicalize_invocation(
tool=_Tool(),
arguments={"value": marker},
policy=_secret_policy(),
tool_revision=_tool_revision(),
)
assert marker not in repr(invocation)
assert "canonical_json" not in repr(invocation)
def test_canonical_arguments_are_recursively_immutable() -> None:
"""确认等待期间不能修改嵌套参数后复用旧 digest。"""
class _Input(BaseModel):
payload: dict[str, list[str]]
class _Tool:
name = "nested_tool"
args_schema = _Input
invocation = canonicalize_invocation(
tool=_Tool(),
arguments={"payload": {"items": ["one"]}},
policy=_secret_policy(),
tool_revision=_tool_revision(),
)
with pytest.raises(TypeError):
invocation.arguments["payload"]["items"] += ("two",)
def test_inbound_contract_hides_raw_text_and_keeps_target_identities_separate() -> None:
"""入站原文不可进入 repractor、recipient 和 conversation 独立绑定。"""
target = DeliveryTarget(
channel="telegram",
source_instance_id="source-1",
tenant_or_account_id="account-1",
conversation_kind=ConversationKind.PRIVATE,
conversation_id="chat-1",
recipient_id="recipient-1",
actor_id="actor-1",
server_session_id="session-1",
)
envelope = InboundEnvelope(
provenance=InboundProvenance.VERIFIED_ADAPTER,
target=target,
inbound_event_id="event-1",
raw_text=" confirm K7P4-M2Q8 ",
normalized_text="confirm K7P4-M2Q8",
)
rendered = repr(envelope)
assert "K7P4-M2Q8" not in rendered
assert target.actor_id != target.recipient_id
assert target.recipient_id != target.conversation_id
with pytest.raises(FrozenInstanceError):
envelope.inbound_event_id = "forged"
def test_canonical_invocation_rejects_unknown_arguments() -> None:
"""严格确认不能把 schema 外参数降级为原始字典。"""
tool = QuerySystemSettingsTool(session_id="session-1", user_id="admin")
with pytest.raises(CanonicalizationError, match="参数校验失败"):
canonicalize_invocation(
tool=tool,
arguments={"show_secrets": True, "forged": "value"},
policy=_secret_policy(),
tool_revision=_tool_revision(),
)
def test_canonical_invocation_rejects_missing_pydantic_schema() -> None:
"""动态 dict schema 不能进入严格确认路径。"""
class _Tool:
name = "dynamic_tool"
args_schema = {"type": "object"}
with pytest.raises(CanonicalizationError, match="Pydantic"):
canonicalize_invocation(
tool=_Tool(),
arguments={},
policy=_secret_policy(),
tool_revision=_tool_revision(),
)
def test_canonical_invocation_preserves_unicode_and_rejects_nan() -> None:
"""稳定 JSON 保留 Unicode同时禁止非标准 NaN。"""
class _Input(BaseModel):
label: str
value: float = Field(allow_inf_nan=True)
class _Tool:
name = "unicode_tool"
args_schema = _Input
with pytest.raises(CanonicalizationError, match="无法规范化"):
canonicalize_invocation(
tool=_Tool(),
arguments={"label": "中文", "value": math.nan},
policy=_secret_policy(),
tool_revision=_tool_revision(),
)
def test_canonicalization_reads_no_setting_value(monkeypatch) -> None:
"""确认前只能读取静态 SettingSpec不能访问实际设置值。"""
tool = QuerySystemSettingsTool(session_id="session-1", user_id="admin")
load_value = monkeypatch.setattr(
QuerySystemSettingsTool,
"_load_setting_value",
lambda *_: pytest.fail("canonicalization must not read setting value"),
)
invocation = canonicalize_invocation(
tool=tool,
arguments={"setting_key": "COOKIECLOUD_KEY", "show_secrets": True},
policy=_secret_policy(),
tool_revision=_tool_revision(),
)
assert invocation.digest
assert load_value is None
def test_group_selector_binds_static_setting_set_without_loading_values(
monkeypatch,
) -> None:
"""列表型密钥读取必须绑定匹配的静态设置集合。"""
tool = QuerySystemSettingsTool(session_id="session-1", user_id="admin")
monkeypatch.setattr(
QuerySystemSettingsTool,
"_load_setting_value",
lambda *_: pytest.fail("canonicalization must not read setting value"),
)
invocation = canonicalize_invocation(
tool=tool,
arguments={"group": "ai_agent", "show_secrets": True},
policy=_secret_policy(),
tool_revision=_tool_revision(),
)
assert len(invocation.preconditions) > 1
assert all(kind == "setting" for kind, _identity in invocation.preconditions)

View File

@@ -0,0 +1,159 @@
"""Agent 本地工具目录身份、冲突与版本窗口测试。"""
from types import SimpleNamespace
from unittest.mock import patch
import pytest
from pydantic import BaseModel
from app.agent.tools.catalog import (
ToolCatalogSnapshot,
ToolIdentityAmbiguousError,
)
from app.agent.tools.factory import MoviePilotToolFactory
from app.core.plugin import PluginManager
class _Arguments(BaseModel):
"""目录测试工具的参数契约。"""
query: str = ""
def _tool(name: str, source: str = "builtin") -> SimpleNamespace:
"""构造带稳定 schema 与来源的最小工具替身。"""
return SimpleNamespace(
name=name,
args_schema=_Arguments,
_agent_tool_source=source,
)
def test_catalog_preserves_order_and_resolves_exact_instance() -> None:
"""目录应保持构造顺序,并返回同一次构造的精确实例。"""
first = _tool("first")
second = _tool("second", "plugin:demo")
catalog = ToolCatalogSnapshot.from_tools(
[first, second],
plugin_revision=7,
factory_revision="factory-v1",
)
assert catalog.tools == [first, second]
assert catalog.resolve_unique("second").tool is second
assert catalog.resolve_unique("missing") is None
assert catalog.signature[0:2] == ("factory-v1", 7)
def test_catalog_records_all_duplicate_names_and_strict_lookup_fails() -> None:
"""内置与插件同名时必须保留双方身份并拒绝隐式选胜者。"""
builtin = _tool("query_system_settings")
plugin = _tool("query_system_settings", "plugin:demo")
catalog = ToolCatalogSnapshot.from_tools(
[builtin, plugin],
plugin_revision=3,
factory_revision="factory-v1",
)
assert [entry.tool for entry in catalog.collisions["query_system_settings"]] == [
builtin,
plugin,
]
with pytest.raises(
ToolIdentityAmbiguousError,
match="TOOL_IDENTITY_AMBIGUOUS",
):
catalog.resolve_unique("query_system_settings")
def test_catalog_subset_keeps_all_identities_for_selected_name() -> None:
"""子图执行只选首个实例时,严格目录仍必须保留全部同名身份。"""
first = _tool("shared", "mcp:one")
second = _tool("shared", "mcp:two")
catalog = ToolCatalogSnapshot.from_tools(
[first, second],
plugin_revision=0,
factory_revision="factory-v1",
)
selected = catalog.select([first])
assert [entry.tool for entry in selected.collisions["shared"]] == [first, second]
def test_catalog_signature_changes_with_schema_and_plugin_revision() -> None:
"""schema 或插件目录 revision 变化必须使图缓存签名失效。"""
tool = _tool("demo")
first = ToolCatalogSnapshot.from_tools(
[tool], plugin_revision=1, factory_revision="factory-v1"
)
class _UpdatedArguments(BaseModel):
"""模拟热加载后的参数契约。"""
query: str = ""
limit: int = 10
tool.args_schema = _UpdatedArguments
schema_changed = ToolCatalogSnapshot.from_tools(
[tool], plugin_revision=1, factory_revision="factory-v1"
)
revision_changed = ToolCatalogSnapshot.from_tools(
[tool], plugin_revision=2, factory_revision="factory-v1"
)
assert schema_changed.signature != first.signature
assert revision_changed.signature != schema_changed.signature
def test_catalog_signature_changes_with_json_schema_mapping() -> None:
"""MCP 使用的 dict JSON Schema 变化必须使目录签名失效。"""
tool = _tool("mcp_demo", "mcp:demo")
tool.args_schema = {"type": "object", "properties": {"query": {"type": "string"}}}
first = ToolCatalogSnapshot.from_tools(
[tool], plugin_revision=1, factory_revision="factory-v1"
)
tool.args_schema = {
"type": "object",
"properties": {
"query": {"type": "string"},
"limit": {"type": "integer"},
},
}
second = ToolCatalogSnapshot.from_tools(
[tool], plugin_revision=1, factory_revision="factory-v1"
)
assert second.signature != first.signature
def test_catalog_signature_changes_with_tool_description() -> None:
"""影响模型选择的工具描述变化必须使目录签名失效。"""
tool = _tool("demo")
tool.description = "first description"
first = ToolCatalogSnapshot.from_tools(
[tool], plugin_revision=1, factory_revision="factory-v1"
)
tool.description = "updated description"
second = ToolCatalogSnapshot.from_tools(
[tool], plugin_revision=1, factory_revision="factory-v1"
)
assert second.signature != first.signature
def test_factory_catalog_retries_plugin_revision_churn_with_bound() -> None:
"""插件构造期间持续 reload 时只能有界重试并失败关闭。"""
revisions = iter([1, 2, 3, 4, 5, 6])
plugin_manager = PluginManager()
with patch.object(
plugin_manager,
"get_plugin_agent_tools_revision",
side_effect=lambda: next(revisions),
), patch.object(MoviePilotToolFactory, "create_tools", return_value=[]):
with pytest.raises(RuntimeError, match="持续变化"):
MoviePilotToolFactory.create_catalog(
session_id="session",
user_id="user",
)

View File

@@ -1,5 +1,6 @@
from types import SimpleNamespace
from typing import Iterator, Optional, Type
from unittest.mock import patch
import pytest
from pydantic import BaseModel
@@ -147,6 +148,24 @@ def test_plugin_agent_tools_cache_can_be_cleared(
assert len(calls) == 2
def test_plugin_agent_tools_revision_churn_is_bounded(
plugin_manager: PluginManager,
) -> None:
"""插件状态持续变化时注册表构造必须有界失败,不能卡住调用线程。"""
def _changing_tools() -> list[type[MoviePilotTool]]:
plugin_manager.clear_plugin_agent_tools_cache()
return [DemoAgentTool]
plugin_manager.running_plugins["DemoPlugin"] = SimpleNamespace(
plugin_name="Demo Plugin",
get_state=lambda: True,
get_agent_tools=_changing_tools,
)
with pytest.raises(RuntimeError, match="持续变化"):
plugin_manager.get_plugin_agent_tools()
def test_factory_reuses_plugin_registry_but_creates_new_tool_instances(
plugin_manager: PluginManager,
) -> None:
@@ -191,3 +210,25 @@ def test_factory_suppresses_plugin_message_tools_for_subagents(
assert "demo_agent_tool" in tool_names
assert "demo_message_agent_tool" not in tool_names
def test_factory_catalog_records_two_plugin_duplicate_names(
plugin_manager: PluginManager,
) -> None:
"""两个插件声明同名工具时,目录必须保留两个插件身份。"""
plugin_manager.running_plugins["PluginOne"] = _build_plugin([DemoAgentTool])
plugin_manager.running_plugins["PluginTwo"] = _build_plugin([DemoAgentTool])
with patch.object(
MoviePilotToolFactory,
"_get_builtin_tool_classes",
return_value=[],
):
catalog = MoviePilotToolFactory.create_catalog(
session_id="session-1",
user_id="10001",
)
assert [
entry.source for entry in catalog.collisions["demo_agent_tool"]
] == ["plugin:PluginOne", "plugin:PluginTwo"]

View File

@@ -171,6 +171,38 @@ def test_registry_separates_safe_read_from_legacy_shadow() -> None:
assert dynamic_policy.result_sensitivity is ResultSensitivity.UNKNOWN
@pytest.mark.parametrize("show_secrets", [None, False])
def test_system_settings_without_secret_values_stays_legacy_shadow(
show_secrets,
) -> None:
"""普通设置读取维持既有兼容路径,不增加确认。"""
policy = DEFAULT_TOOL_POLICY_REGISTRY.resolve(
tool_name="query_system_settings",
arguments={"show_secrets": show_secrets},
requires_admin=True,
)
assert policy.migration_state is MigrationState.LEGACY_SHADOW
assert policy.effect is ActionEffect.UNKNOWN
def test_system_settings_secret_read_has_enforced_sensitive_policy() -> None:
"""显式读取密钥只能进入宿主强制确认策略。"""
policy = DEFAULT_TOOL_POLICY_REGISTRY.resolve(
tool_name="query_system_settings",
arguments={"show_secrets": True},
requires_admin=True,
)
assert policy.migration_state is MigrationState.ENFORCED
assert policy.effect is ActionEffect.SENSITIVE_READ
assert policy.required_role is PrincipalRole.SYSTEM_ADMIN
assert policy.confirmation.value == "required"
assert policy.result_sensitivity is ResultSensitivity.SECRET
assert policy.background_allowed is False
assert policy.subagent_allowed is False
def test_legacy_shadow_decision_allows_without_claiming_enforcement() -> None:
"""G1 的 shadow 决策只能观测,不能拒绝或要求确认。"""
context = _interactive_context()
@@ -187,6 +219,24 @@ def test_legacy_shadow_decision_allows_without_claiming_enforcement() -> None:
assert observation.decision.reason_code == "legacy_shadow_allow"
def test_sensitive_policy_does_not_claim_safe_read_before_strict_runtime() -> None:
"""严格运行时接管前,敏感读取只能以明确的兼容 shadow 语义通过。"""
tool = QuerySystemSettingsTool(session_id="session-1", user_id="admin")
tool.set_agent_context({"is_admin": True})
observation = DEFAULT_TOOL_POLICY_ORCHESTRATOR.start(
context=_interactive_context(),
tool=tool,
arguments={"setting_key": "COOKIECLOUD_KEY", "show_secrets": True},
)
assert observation.policy.migration_state is MigrationState.ENFORCED
assert observation.decision.allowed is True
assert observation.decision.shadow is True
assert observation.decision.confirmation_required is False
assert observation.decision.reason_code == "strict_runtime_pending"
def test_policy_context_reads_mutable_admin_state_without_model_fields() -> None:
"""缓存图复用时权限取当前宿主状态,模型参数不能伪造 principal。"""
context = _interactive_context(is_admin=False)

View File

@@ -9,6 +9,7 @@ import pytest
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.factory import MoviePilotToolFactory
from app.agent.tools.manager import MoviePilotToolsManager
from app.agent.tools.catalog import ToolCatalogSnapshot
from app.api.endpoints import mcp
from app.core.plugin import PluginManager
from app.utils.singleton import Singleton
@@ -96,3 +97,23 @@ def test_mcp_refreshes_tools_after_plugin_lifecycle_change(
)
missing_payload = json.loads(missing_result["content"][0]["text"])
assert "未找到" in missing_payload["error"]
def test_direct_manager_preserves_legacy_lookup_and_exposes_strict_resolution() -> None:
"""普通 direct 调用保留 first-wins严格调用可拒绝同名身份。"""
first = DemoPluginTool(session_id="session", user_id="user")
second = DemoPluginTool(session_id="session", user_id="user")
manager = MoviePilotToolsManager(session_id="session", user_id="user")
manager.tools = [first, second]
manager.catalog = ToolCatalogSnapshot.from_tools(
manager.tools,
plugin_revision=manager._plugin_agent_tools_revision,
factory_revision=MoviePilotToolFactory.catalog_factory_revision(),
)
assert manager.get_tool("demo_plugin_tool") is first
with pytest.raises(RuntimeError, match="TOOL_IDENTITY_AMBIGUOUS"):
manager.get_strict_tool("demo_plugin_tool")
result = asyncio.run(manager.call_tool("demo_plugin_tool", {}))
assert result == "plugin-ok"