mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-14 02:05:13 +08:00
795 lines
27 KiB
Python
795 lines
27 KiB
Python
import asyncio
|
||
import json
|
||
from types import SimpleNamespace
|
||
from unittest.mock import AsyncMock, MagicMock, patch
|
||
|
||
import pytest
|
||
from langchain.agents.middleware import SummarizationMiddleware
|
||
from langchain_core.messages import ToolMessage
|
||
from pydantic import BaseModel, Field
|
||
|
||
import app.agent as agent_module
|
||
from app.agent.middleware.activity_log import ActivityLogMiddleware
|
||
from app.agent.middleware.memory import MemoryMiddleware
|
||
from app.agent.middleware.policy import AgentPolicyMiddleware
|
||
from app.agent.policy import (
|
||
DEFAULT_TOOL_POLICY_ORCHESTRATOR,
|
||
DEFAULT_TOOL_POLICY_REGISTRY,
|
||
ActionEffect,
|
||
AuthSource,
|
||
MigrationState,
|
||
PolicyPrincipal,
|
||
PrincipalRole,
|
||
PrincipalType,
|
||
ResultSensitivity,
|
||
ToolOrigin,
|
||
ToolPolicyContext,
|
||
call_policy_hook,
|
||
)
|
||
from app.agent.tools.base import MoviePilotTool
|
||
from app.agent.tools.factory import MoviePilotToolFactory
|
||
from app.agent.tools.impl.ask_user_choice import AskUserChoiceTool
|
||
from app.agent.tools.impl.query_system_settings import QuerySystemSettingsTool
|
||
from app.agent.tools.impl.send_local_file import SendLocalFileTool
|
||
from app.agent.tools.impl.send_voice_message import SendVoiceMessageTool
|
||
from app.agent.tools.manager import MoviePilotToolsManager
|
||
from app.schemas.types import MessageChannel
|
||
|
||
|
||
class _EchoInput(BaseModel):
|
||
"""策略测试工具的输入契约。"""
|
||
|
||
query: str = Field(description="待回显文本")
|
||
|
||
|
||
class _EchoTool(MoviePilotTool):
|
||
"""返回输入文本的测试工具。"""
|
||
|
||
name: str = "policy_echo"
|
||
description: str = "Echo policy test input."
|
||
args_schema: type[BaseModel] = _EchoInput
|
||
|
||
async def run(self, query: str) -> str:
|
||
"""返回输入文本。"""
|
||
return query
|
||
|
||
|
||
class _OverrideArunTool(_EchoTool):
|
||
"""模拟插件覆盖宿主工具基类 `_arun` 的实现。"""
|
||
|
||
name: str = "plugin_override_arun"
|
||
|
||
def __init__(self, events: list[str], **kwargs):
|
||
super().__init__(**kwargs)
|
||
self._events = events
|
||
|
||
async def _arun(self, *args, **kwargs) -> str:
|
||
"""绕过基类实现并记录真实执行顺序。"""
|
||
self._events.append("tool")
|
||
return str(kwargs.get("query") or "ok")
|
||
|
||
|
||
class _FailingTool(_EchoTool):
|
||
"""抛出固定异常以验证 direct manager 的 observation fail-open。"""
|
||
|
||
name: str = "policy_failure"
|
||
|
||
async def run(self, query: str) -> str:
|
||
"""模拟真实工具失败。"""
|
||
raise ValueError(f"tool-error:{query}")
|
||
|
||
|
||
class _AdminSafeReadTool(_EchoTool):
|
||
"""复用 safe-read 名称并保留旧 require_admin 门禁的测试工具。"""
|
||
|
||
name: str = "list_slash_commands"
|
||
require_admin: bool = True
|
||
|
||
def __init__(self, events: list[str], **kwargs):
|
||
super().__init__(**kwargs)
|
||
self._events = events
|
||
|
||
async def run(self, query: str) -> str:
|
||
"""记录实际执行,区分旧门禁放行与拒绝。"""
|
||
self._events.append("run")
|
||
return query
|
||
|
||
|
||
def _tool_class_name(tool_class: type[MoviePilotTool]) -> str:
|
||
"""从 Pydantic 字段默认值读取工具类的稳定名称。"""
|
||
return str(tool_class.model_fields["name"].default)
|
||
|
||
|
||
def _interactive_context(*, is_admin: bool = True) -> ToolPolicyContext:
|
||
"""构造会随本轮管理员上下文刷新的交互式策略上下文。"""
|
||
return ToolPolicyContext(
|
||
session_id="session-1",
|
||
user_id="user-1",
|
||
origin=ToolOrigin.AGENT_INTERACTIVE,
|
||
principal_type=PrincipalType.HUMAN,
|
||
auth_source=AuthSource.CHANNEL,
|
||
channel="telegram",
|
||
source="user",
|
||
agent_context={"is_admin": is_admin},
|
||
)
|
||
|
||
|
||
def test_builtin_policy_registry_covers_every_fixed_tool() -> None:
|
||
"""固定内置工具必须全部具有显式 migration registry 条目。"""
|
||
fixed_tool_names = {
|
||
_tool_class_name(tool_class)
|
||
for tool_class in MoviePilotToolFactory.BUILTIN_TOOL_CLASSES
|
||
}
|
||
fixed_tool_names.update(
|
||
{
|
||
_tool_class_name(AskUserChoiceTool),
|
||
_tool_class_name(SendLocalFileTool),
|
||
_tool_class_name(SendVoiceMessageTool),
|
||
}
|
||
)
|
||
|
||
assert DEFAULT_TOOL_POLICY_REGISTRY.builtin_tool_names == fixed_tool_names
|
||
|
||
|
||
def test_registry_separates_safe_read_from_legacy_shadow() -> None:
|
||
"""少量安全读取可直接迁移,其余高影响工具保持 shadow allow。"""
|
||
safe_policy = DEFAULT_TOOL_POLICY_REGISTRY.resolve(
|
||
tool_name="query_personas",
|
||
arguments={},
|
||
requires_admin=False,
|
||
)
|
||
admin_safe_policy = DEFAULT_TOOL_POLICY_REGISTRY.resolve(
|
||
tool_name="list_slash_commands",
|
||
arguments={},
|
||
requires_admin=True,
|
||
)
|
||
shadow_policy = DEFAULT_TOOL_POLICY_REGISTRY.resolve(
|
||
tool_name="update_system_settings",
|
||
arguments={"updates": []},
|
||
requires_admin=True,
|
||
)
|
||
dynamic_policy = DEFAULT_TOOL_POLICY_REGISTRY.resolve(
|
||
tool_name="plugin_unknown_tool",
|
||
arguments={"action": "custom"},
|
||
requires_admin=False,
|
||
)
|
||
|
||
assert safe_policy.effect is ActionEffect.SAFE_READ
|
||
assert safe_policy.result_sensitivity is ResultSensitivity.NORMAL
|
||
assert safe_policy.migration_state is MigrationState.ENFORCED
|
||
|
||
assert admin_safe_policy.effect is ActionEffect.SAFE_READ
|
||
assert admin_safe_policy.required_role is PrincipalRole.SYSTEM_ADMIN
|
||
assert admin_safe_policy.migration_state is MigrationState.LEGACY_SHADOW
|
||
|
||
assert shadow_policy.migration_state is MigrationState.LEGACY_SHADOW
|
||
assert shadow_policy.effect is ActionEffect.UNKNOWN
|
||
assert shadow_policy.required_role is PrincipalRole.SYSTEM_ADMIN
|
||
|
||
assert dynamic_policy.migration_state is MigrationState.LEGACY_SHADOW
|
||
assert dynamic_policy.effect is ActionEffect.UNKNOWN
|
||
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()
|
||
tool = _EchoTool(session_id="session-1", user_id="user-1")
|
||
|
||
observation = DEFAULT_TOOL_POLICY_ORCHESTRATOR.start(
|
||
context=context,
|
||
tool=tool,
|
||
arguments={"query": "hello"},
|
||
)
|
||
|
||
assert observation.decision.allowed is True
|
||
assert observation.decision.shadow is True
|
||
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)
|
||
forged_arguments = {
|
||
"query": "hello",
|
||
"origin": "operator_direct",
|
||
"principal_type": "system_admin_integration",
|
||
"is_admin": True,
|
||
}
|
||
|
||
assert context.principal.role is PrincipalRole.USER
|
||
context.agent_context["is_admin"] = True
|
||
assert context.principal.role is PrincipalRole.SYSTEM_ADMIN
|
||
assert "origin" not in PolicyPrincipal.__dataclass_fields__
|
||
assert forged_arguments["origin"] != context.origin.value
|
||
|
||
|
||
def test_policy_context_maps_trusted_host_origins() -> None:
|
||
"""各入口必须由宿主稳定映射 origin、主体类型和认证来源。"""
|
||
cases = [
|
||
(
|
||
{"channel": MessageChannel.Web.value, "source": "openai"},
|
||
ToolOrigin.AGENT_API,
|
||
PrincipalType.SYSTEM_ADMIN_INTEGRATION,
|
||
AuthSource.API_TOKEN,
|
||
),
|
||
(
|
||
{"channel": MessageChannel.Web.value, "source": "openai.responses"},
|
||
ToolOrigin.AGENT_API,
|
||
PrincipalType.SYSTEM_ADMIN_INTEGRATION,
|
||
AuthSource.API_TOKEN,
|
||
),
|
||
(
|
||
{"channel": MessageChannel.Web.value, "source": "anthropic"},
|
||
ToolOrigin.AGENT_API,
|
||
PrincipalType.SYSTEM_ADMIN_INTEGRATION,
|
||
AuthSource.API_TOKEN,
|
||
),
|
||
(
|
||
{"channel": MessageChannel.Web.value, "source": "browser"},
|
||
ToolOrigin.AGENT_INTERACTIVE,
|
||
PrincipalType.HUMAN,
|
||
AuthSource.WEB_SESSION,
|
||
),
|
||
(
|
||
{"channel": MessageChannel.WebAgent.value, "source": "web-agent"},
|
||
ToolOrigin.AGENT_INTERACTIVE,
|
||
PrincipalType.HUMAN,
|
||
AuthSource.WEB_SESSION,
|
||
),
|
||
(
|
||
{"channel": MessageChannel.Telegram.value, "source": "telegram"},
|
||
ToolOrigin.AGENT_INTERACTIVE,
|
||
PrincipalType.HUMAN,
|
||
AuthSource.CHANNEL,
|
||
),
|
||
(
|
||
{"channel": MessageChannel.Feishu.value, "source": "feishu"},
|
||
ToolOrigin.AGENT_INTERACTIVE,
|
||
PrincipalType.HUMAN,
|
||
AuthSource.CHANNEL,
|
||
),
|
||
(
|
||
{"channel": None, "source": None, "output_callback": lambda _text: None},
|
||
ToolOrigin.BACKGROUND,
|
||
PrincipalType.BACKGROUND,
|
||
AuthSource.INTERNAL,
|
||
),
|
||
]
|
||
|
||
for kwargs, expected_origin, expected_principal, expected_auth_source in cases:
|
||
context = agent_module.MoviePilotAgent(
|
||
session_id="origin-session",
|
||
user_id="user-1",
|
||
**kwargs,
|
||
)._build_policy_context()
|
||
|
||
assert context.origin is expected_origin
|
||
assert context.principal_type is expected_principal
|
||
assert context.auth_source is expected_auth_source
|
||
|
||
subagent_context = agent_module.MoviePilotAgent(
|
||
session_id="subagent-session",
|
||
user_id="user-1",
|
||
channel=MessageChannel.Telegram.value,
|
||
source="telegram",
|
||
)._build_policy_context().for_subagent()
|
||
assert subagent_context.origin is ToolOrigin.SUBAGENT
|
||
assert subagent_context.principal_type is PrincipalType.SUBAGENT
|
||
assert subagent_context.auth_source is AuthSource.INTERNAL
|
||
|
||
|
||
def test_host_middleware_observes_plugin_before_overridden_arun() -> None:
|
||
"""插件覆盖 `_arun` 时,宿主 middleware 仍必须先执行策略。"""
|
||
events: list[str] = []
|
||
tool = _OverrideArunTool(
|
||
events,
|
||
session_id="session-1",
|
||
user_id="user-1",
|
||
)
|
||
middleware = AgentPolicyMiddleware(context=_interactive_context())
|
||
request = SimpleNamespace(
|
||
tool=tool,
|
||
tool_call={
|
||
"id": "call-1",
|
||
"name": tool.name,
|
||
"args": {"query": "ok"},
|
||
},
|
||
)
|
||
original_start = DEFAULT_TOOL_POLICY_ORCHESTRATOR.start
|
||
|
||
def _record_start(**kwargs):
|
||
events.append("policy")
|
||
return original_start(**kwargs)
|
||
|
||
async def _handler(_request):
|
||
result = await tool._arun(query="ok")
|
||
return ToolMessage(content=result, tool_call_id="call-1")
|
||
|
||
with patch.object(
|
||
DEFAULT_TOOL_POLICY_ORCHESTRATOR,
|
||
"start",
|
||
side_effect=_record_start,
|
||
):
|
||
result = asyncio.run(middleware.awrap_tool_call(request, _handler))
|
||
|
||
assert result.content == "ok"
|
||
assert events == ["policy", "tool"]
|
||
|
||
|
||
@pytest.mark.parametrize("failed_phase", ["start", "finish"])
|
||
def test_middleware_observation_failure_does_not_replace_success(
|
||
failed_phase: str,
|
||
) -> None:
|
||
"""shadow start/finish 故障不能阻止 handler 或替换成功结果。"""
|
||
orchestrator = MagicMock()
|
||
orchestrator.start.return_value = object()
|
||
getattr(orchestrator, failed_phase).side_effect = RuntimeError(
|
||
f"policy-{failed_phase}-failure"
|
||
)
|
||
middleware = AgentPolicyMiddleware(
|
||
context=_interactive_context(),
|
||
orchestrator=orchestrator,
|
||
)
|
||
request = SimpleNamespace(
|
||
tool=_EchoTool(session_id="session-1", user_id="user-1"),
|
||
tool_call={"id": "call-1", "args": {"query": "same"}},
|
||
)
|
||
expected = ToolMessage(content="same", tool_call_id="call-1")
|
||
handler_called = False
|
||
|
||
async def _handler(_request):
|
||
nonlocal handler_called
|
||
handler_called = True
|
||
return expected
|
||
|
||
result = asyncio.run(middleware.awrap_tool_call(request, _handler))
|
||
|
||
assert handler_called is True
|
||
assert result is expected
|
||
|
||
|
||
def test_middleware_fail_observation_does_not_mask_tool_error() -> None:
|
||
"""shadow fail hook 故障后仍必须抛出原始工具异常。"""
|
||
orchestrator = MagicMock()
|
||
orchestrator.start.return_value = object()
|
||
orchestrator.fail.side_effect = RuntimeError("policy-fail-hook-failure")
|
||
middleware = AgentPolicyMiddleware(
|
||
context=_interactive_context(),
|
||
orchestrator=orchestrator,
|
||
)
|
||
request = SimpleNamespace(
|
||
tool=_EchoTool(session_id="session-1", user_id="user-1"),
|
||
tool_call={"id": "call-1", "args": {"query": "same"}},
|
||
)
|
||
tool_error = ValueError("original-tool-failure")
|
||
|
||
async def _handler(_request):
|
||
raise tool_error
|
||
|
||
with pytest.raises(ValueError) as error_info:
|
||
asyncio.run(middleware.awrap_tool_call(request, _handler))
|
||
|
||
assert error_info.value is tool_error
|
||
|
||
|
||
def test_policy_hook_failure_logs_only_stable_type_information() -> None:
|
||
"""fail-open 诊断只记录阶段和异常类型,不读取可能含凭据的异常文本。"""
|
||
mock_logger = MagicMock()
|
||
|
||
def _fail() -> None:
|
||
raise RuntimeError("DATABASE_PASSWORD=policy-secret-marker")
|
||
|
||
with patch("app.agent.policy.orchestrator.logger", mock_logger):
|
||
result = call_policy_hook("start", _fail)
|
||
|
||
logged = "\n".join(str(call) for call in mock_logger.warning.call_args_list)
|
||
assert result is None
|
||
assert "phase=start" in logged
|
||
assert "RuntimeError" in logged
|
||
assert "policy-secret-marker" not in logged
|
||
|
||
|
||
def test_policy_hook_hostile_error_type_is_fail_open() -> None:
|
||
"""异常类型名协议不可信时,观测故障仍不得逃出 fail-open 边界。"""
|
||
secret_marker = "hostile-policy-type-secret-6274"
|
||
|
||
class _HostileMeta(type):
|
||
def __getattribute__(cls, name):
|
||
if name == "__name__":
|
||
raise RuntimeError(f"DATABASE_PASSWORD={secret_marker}")
|
||
return super().__getattribute__(name)
|
||
|
||
class _HostilePolicyError(RuntimeError, metaclass=_HostileMeta):
|
||
pass
|
||
|
||
mock_logger = MagicMock()
|
||
|
||
def _fail() -> None:
|
||
raise _HostilePolicyError("visible policy failure")
|
||
|
||
escaped = False
|
||
with patch("app.agent.policy.orchestrator.logger", mock_logger):
|
||
try:
|
||
result = call_policy_hook("start", _fail)
|
||
except BaseException:
|
||
escaped = True
|
||
result = None
|
||
|
||
logged = "\n".join(str(call) for call in mock_logger.warning.call_args_list)
|
||
assert escaped is False
|
||
assert result is None
|
||
assert "phase=start" in logged
|
||
assert secret_marker not in logged
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
("legacy_admin", "expected_result", "expected_run_count"),
|
||
[
|
||
(True, "same", 1),
|
||
(
|
||
False,
|
||
(
|
||
"抱歉,您没有执行此工具的权限。"
|
||
"只有渠道管理员或系统管理员才能执行工具操作。"
|
||
"如需执行工具,请联系管理员将您的用户ID添加到渠道管理员列表中(设定 -> 通知 -> 对应渠道配置 -> 管理员名单),"
|
||
"或联系系统管理员为您设置管理员权限。"
|
||
),
|
||
0,
|
||
),
|
||
],
|
||
)
|
||
def test_agent_admin_safe_read_keeps_legacy_authorization_authority(
|
||
legacy_admin: bool,
|
||
expected_result: str,
|
||
expected_run_count: int,
|
||
) -> None:
|
||
"""渠道管理员与普通用户结果保持旧门禁语义,policy 只做 shadow passthrough。"""
|
||
events: list[str] = []
|
||
observations = []
|
||
original_start = DEFAULT_TOOL_POLICY_ORCHESTRATOR.start
|
||
tool = _AdminSafeReadTool(
|
||
events,
|
||
session_id="session-1",
|
||
user_id="user-1",
|
||
)
|
||
tool.set_message_attr(
|
||
channel=MessageChannel.Telegram.value,
|
||
source="user",
|
||
username="member",
|
||
)
|
||
tool.set_agent_context({"is_admin": False})
|
||
middleware = AgentPolicyMiddleware(context=_interactive_context(is_admin=False))
|
||
request = SimpleNamespace(
|
||
tool=tool,
|
||
tool_call={"id": "call-1", "args": {"query": "same"}},
|
||
)
|
||
|
||
def _capture_start(**kwargs):
|
||
observation = original_start(**kwargs)
|
||
observations.append(observation)
|
||
return observation
|
||
|
||
async def _handler(_request):
|
||
content = await tool._arun(query="same")
|
||
return ToolMessage(content=content, tool_call_id="call-1")
|
||
|
||
with (
|
||
patch.object(
|
||
_AdminSafeReadTool,
|
||
"is_admin_user",
|
||
new=AsyncMock(return_value=legacy_admin),
|
||
),
|
||
patch.object(
|
||
DEFAULT_TOOL_POLICY_ORCHESTRATOR,
|
||
"start",
|
||
side_effect=_capture_start,
|
||
),
|
||
):
|
||
result = asyncio.run(middleware.awrap_tool_call(request, _handler))
|
||
|
||
assert result.content == expected_result
|
||
assert events.count("run") == expected_run_count
|
||
assert len(observations) == 1
|
||
assert observations[0].policy.effect is ActionEffect.SAFE_READ
|
||
assert observations[0].policy.migration_state is MigrationState.LEGACY_SHADOW
|
||
assert observations[0].decision.allowed is True
|
||
assert observations[0].decision.shadow is True
|
||
assert observations[0].decision.reason_code == "legacy_shadow_allow"
|
||
|
||
|
||
def test_direct_non_admin_safe_read_rejects_before_policy_or_schema() -> None:
|
||
"""direct 未授权请求保持原 JSON 拒绝格式且不提前触发 policy/schema。"""
|
||
events: list[str] = []
|
||
orchestrator = MagicMock()
|
||
tool = _AdminSafeReadTool(
|
||
events,
|
||
session_id="session-1",
|
||
user_id="user-1",
|
||
)
|
||
manager = MoviePilotToolsManager(
|
||
is_admin=False,
|
||
policy_orchestrator=orchestrator,
|
||
)
|
||
manager.tools = [tool]
|
||
|
||
result = json.loads(
|
||
asyncio.run(manager.call_tool(tool.name, {"query": "same"}))
|
||
)
|
||
|
||
assert result == {
|
||
"error": "抱歉,您没有执行此工具的权限。只有系统管理员才能执行工具操作。"
|
||
}
|
||
assert events == []
|
||
orchestrator.start.assert_not_called()
|
||
|
||
|
||
@pytest.mark.parametrize("failed_phase", ["start", "finish"])
|
||
def test_direct_manager_observation_failure_does_not_replace_success(
|
||
failed_phase: str,
|
||
) -> None:
|
||
"""direct manager 的 shadow start/finish 故障不能改写真实返回值。"""
|
||
orchestrator = MagicMock()
|
||
orchestrator.start.return_value = object()
|
||
getattr(orchestrator, failed_phase).side_effect = RuntimeError(
|
||
f"policy-{failed_phase}-failure"
|
||
)
|
||
tool = _EchoTool(session_id="session-1", user_id="user-1")
|
||
manager = MoviePilotToolsManager(
|
||
is_admin=True,
|
||
policy_orchestrator=orchestrator,
|
||
)
|
||
manager.tools = [tool]
|
||
|
||
result = asyncio.run(manager.call_tool(tool.name, {"query": "same"}))
|
||
|
||
assert result == "same"
|
||
|
||
|
||
def test_direct_manager_fail_observation_does_not_mask_tool_error() -> None:
|
||
"""direct manager 的 fail hook 故障不能替换既有工具错误格式。"""
|
||
orchestrator = MagicMock()
|
||
orchestrator.start.return_value = object()
|
||
orchestrator.fail.side_effect = RuntimeError("policy-fail-hook-failure")
|
||
tool = _FailingTool(session_id="session-1", user_id="user-1")
|
||
manager = MoviePilotToolsManager(
|
||
is_admin=True,
|
||
policy_orchestrator=orchestrator,
|
||
)
|
||
manager.tools = [tool]
|
||
|
||
result = asyncio.run(manager.call_tool(tool.name, {"query": "same"}))
|
||
|
||
assert "ValueError" in result
|
||
assert "tool-error:same" in result
|
||
assert "policy-fail-hook-failure" not in result
|
||
|
||
|
||
def test_direct_manager_and_agent_middleware_share_policy_resolution() -> None:
|
||
"""相同工具参数在 Agent 与 direct 入口应获得相同动作策略。"""
|
||
observations = []
|
||
original_start = DEFAULT_TOOL_POLICY_ORCHESTRATOR.start
|
||
|
||
def _capture_start(**kwargs):
|
||
observation = original_start(**kwargs)
|
||
observations.append(observation)
|
||
return observation
|
||
|
||
tool = _EchoTool(session_id="session-1", user_id="user-1")
|
||
middleware = AgentPolicyMiddleware(context=_interactive_context())
|
||
request = SimpleNamespace(
|
||
tool=tool,
|
||
tool_call={
|
||
"id": "call-agent",
|
||
"name": tool.name,
|
||
"args": {"query": "same"},
|
||
},
|
||
)
|
||
|
||
async def _handler(_request):
|
||
return ToolMessage(content="same", tool_call_id="call-agent")
|
||
|
||
manager = MoviePilotToolsManager(
|
||
user_id="api-user",
|
||
session_id="api-session",
|
||
is_admin=True,
|
||
policy_orchestrator=DEFAULT_TOOL_POLICY_ORCHESTRATOR,
|
||
)
|
||
manager.tools = [tool]
|
||
|
||
async def _run_both():
|
||
await middleware.awrap_tool_call(request, _handler)
|
||
return await manager.call_tool(tool.name, {"query": "same"})
|
||
|
||
with patch.object(
|
||
DEFAULT_TOOL_POLICY_ORCHESTRATOR,
|
||
"start",
|
||
side_effect=_capture_start,
|
||
):
|
||
direct_result = asyncio.run(_run_both())
|
||
|
||
assert direct_result == "same"
|
||
assert [item.invocation.origin for item in observations] == [
|
||
ToolOrigin.AGENT_INTERACTIVE,
|
||
ToolOrigin.OPERATOR_DIRECT,
|
||
]
|
||
assert observations[0].policy == observations[1].policy
|
||
|
||
|
||
def test_agent_middleware_secret_setting_result_stays_out_of_receipt_logs() -> None:
|
||
"""Agent ToolNode 可接收管理员请求的原值,但策略回执不得记录该值。"""
|
||
secret_marker = "middleware-secret-setting-marker"
|
||
tool = QuerySystemSettingsTool(session_id="session-1", user_id="admin")
|
||
tool.set_agent_context({"is_admin": True})
|
||
middleware = AgentPolicyMiddleware(context=_interactive_context())
|
||
request = SimpleNamespace(
|
||
tool=tool,
|
||
tool_call={
|
||
"id": "call-secret-setting",
|
||
"name": tool.name,
|
||
"args": {"setting_key": "COOKIECLOUD_KEY", "show_secrets": True},
|
||
},
|
||
)
|
||
mock_logger = MagicMock()
|
||
|
||
async def _handler(_request):
|
||
result = await tool._arun(
|
||
setting_key="COOKIECLOUD_KEY",
|
||
show_secrets=True,
|
||
)
|
||
return ToolMessage(content=result, tool_call_id="call-secret-setting")
|
||
|
||
with (
|
||
patch.object(
|
||
QuerySystemSettingsTool,
|
||
"_load_setting_value",
|
||
return_value=secret_marker,
|
||
),
|
||
patch("app.agent.policy.orchestrator.logger", mock_logger),
|
||
):
|
||
result = asyncio.run(middleware.awrap_tool_call(request, _handler))
|
||
|
||
assert secret_marker in result.content
|
||
logged = "\n".join(
|
||
str(call)
|
||
for call in (
|
||
mock_logger.debug.call_args_list + mock_logger.info.call_args_list
|
||
)
|
||
)
|
||
assert secret_marker not in logged
|
||
assert '"value": "***"' in logged
|
||
assert '"value_preview": "***"' in logged
|
||
|
||
|
||
def test_main_agent_registers_policy_middleware_as_outermost() -> None:
|
||
"""主 Agent 必须把宿主策略中间件放在 middleware 链最外层。"""
|
||
agent = agent_module.MoviePilotAgent(session_id="session-1", user_id="user-1")
|
||
fake_llm = SimpleNamespace(
|
||
_llm_type="openai-chat",
|
||
model="fake",
|
||
profile={"max_input_tokens": 64000},
|
||
)
|
||
captured = {}
|
||
|
||
def _fake_create_agent(**kwargs):
|
||
captured.update(kwargs)
|
||
return object()
|
||
|
||
with (
|
||
patch.object(agent, "_initialize_llm", return_value=fake_llm),
|
||
patch.object(agent, "_initialize_tools", return_value=[]),
|
||
patch.object(agent_module.prompt_manager, "get_agent_prompt", return_value="prompt"),
|
||
patch.object(agent_module, "create_subagent_middlewares", return_value=([], [])),
|
||
patch.object(agent_module, "create_agent", side_effect=_fake_create_agent),
|
||
patch.object(agent_module.settings, "LLM_MAX_TOOLS", 0),
|
||
):
|
||
asyncio.run(agent._create_agent(streaming=False))
|
||
|
||
assert isinstance(captured["middleware"][0], AgentPolicyMiddleware)
|
||
|
||
|
||
def test_main_agent_preserves_activity_log_middleware_order() -> None:
|
||
"""策略层加入后,ActivityLog 仍应位于 Memory 后、摘要前。"""
|
||
agent = agent_module.MoviePilotAgent(
|
||
session_id="session-1",
|
||
user_id="user-1",
|
||
channel=MessageChannel.WebAgent.value,
|
||
source="web-agent",
|
||
)
|
||
fake_llm = SimpleNamespace(
|
||
_llm_type="openai-chat",
|
||
model="fake",
|
||
profile={"max_input_tokens": 64000},
|
||
)
|
||
captured = {}
|
||
|
||
def _fake_create_agent(**kwargs):
|
||
captured.update(kwargs)
|
||
return object()
|
||
|
||
with (
|
||
patch.object(agent, "_initialize_llm", return_value=fake_llm),
|
||
patch.object(agent, "_initialize_tools", return_value=[]),
|
||
patch.object(agent_module.prompt_manager, "get_agent_prompt", return_value="prompt"),
|
||
patch.object(agent_module, "create_subagent_middlewares", return_value=([], [])),
|
||
patch.object(agent_module, "create_agent", side_effect=_fake_create_agent),
|
||
patch.object(agent_module.settings, "LLM_MAX_TOOLS", 0),
|
||
):
|
||
asyncio.run(agent._create_agent(streaming=False))
|
||
|
||
middlewares = captured["middleware"]
|
||
policy_index = next(
|
||
index
|
||
for index, middleware in enumerate(middlewares)
|
||
if isinstance(middleware, AgentPolicyMiddleware)
|
||
)
|
||
memory_index = next(
|
||
index
|
||
for index, middleware in enumerate(middlewares)
|
||
if isinstance(middleware, MemoryMiddleware)
|
||
)
|
||
activity_index = next(
|
||
index
|
||
for index, middleware in enumerate(middlewares)
|
||
if isinstance(middleware, ActivityLogMiddleware)
|
||
)
|
||
summary_index = next(
|
||
index
|
||
for index, middleware in enumerate(middlewares)
|
||
if isinstance(middleware, SummarizationMiddleware)
|
||
)
|
||
|
||
assert policy_index == 0
|
||
assert activity_index == memory_index + 1
|
||
assert summary_index == activity_index + 1
|