Files
MoviePilot/tests/test_agent_tool_policy.py

880 lines
31 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import asyncio
import json
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from langchain_core.messages import ToolMessage
from pydantic import BaseModel, Field
import app.agent.orchestrator as agent_module
from app.agent.middleware.activity import ActivityLogMiddleware
from app.agent.middleware.memory import MemoryMiddleware
from app.agent.middleware.policy import AgentPolicyMiddleware
from app.agent.middleware.summarization import FinalRequestCompactionMiddleware
# pylint: disable=no-name-in-module # 策略包根通过 __getattr__ 惰性导出Pylint 无法静态解析。
from app.agent.policy import (
DEFAULT_TOOL_POLICY_ORCHESTRATOR,
DEFAULT_TOOL_POLICY_REGISTRY,
ActionEffect,
AuthSource,
ConfirmationMode,
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.api import MoviePilotApiTool
from app.agent.tools.impl.ask_user_choice import AskUserChoiceTool
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 NotificationChannel
class _EchoInput(BaseModel):
"""策略测试工具的输入契约。"""
query: str = Field(description="待回显文本")
class _AgentFactoryLLM:
"""满足 Agent 构建与摘要中间件所需合同的测试模型。"""
_llm_type = "openai-chat"
model = "fake"
profile = {"max_input_tokens": 64000}
def with_retry(self):
"""返回可供摘要中间件调用的 Runnable。"""
return self
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):
"""声明管理员门禁的动态测试工具。"""
name: str = "admin_test_tool"
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_inventory_covers_every_fixed_tool() -> None:
"""固定内置工具 inventory 必须随工厂入口同步。"""
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),
_tool_class_name(MoviePilotApiTool),
}
)
fixed_tool_names.update(
_tool_class_name(tool_class)
for tool_class in MoviePilotToolFactory.EXTERNAL_SERVICE_TOOL_CLASSES
)
assert DEFAULT_TOOL_POLICY_REGISTRY.builtin_tool_inventory == fixed_tool_names
def test_registry_applies_safe_read_exceptions_and_defaults_to_shadow() -> None:
"""SAFE_READ 仅用于明确例外,其他固定和动态工具默认 shadow。"""
safe_policy = DEFAULT_TOOL_POLICY_REGISTRY.resolve(
tool_name="persona",
arguments={"action": "list"},
requires_admin=False,
)
admin_safe_policy = DEFAULT_TOOL_POLICY_REGISTRY.resolve(
tool_name="moviepilot_api",
arguments={"operation_id": "scheduler.list"},
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.ENFORCED
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_is_enforced_read(
show_secrets,
) -> None:
"""普通设置读取维持既有兼容路径,不增加确认。"""
policy = DEFAULT_TOOL_POLICY_REGISTRY.resolve(
tool_name="moviepilot_api",
arguments={
"operation_id": "config.system.get",
"body": {"show_secrets": show_secrets},
},
requires_admin=True,
)
assert policy.migration_state is MigrationState.ENFORCED
assert policy.effect is ActionEffect.SAFE_READ
def test_system_settings_secret_read_has_enforced_sensitive_policy() -> None:
"""显式读取密钥只能进入宿主强制确认策略。"""
policy = DEFAULT_TOOL_POLICY_REGISTRY.resolve(
tool_name="moviepilot_api",
arguments={
"operation_id": "config.system.get",
"body": {"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_system_settings_secret_read_query_cannot_bypass_confirmation() -> None:
"""GET 查询参数不能绕过敏感设置读取的确认策略。"""
policy = DEFAULT_TOOL_POLICY_REGISTRY.resolve(
tool_name="moviepilot_api",
arguments={
"operation_id": "config.system.get",
"query": {"show_secrets": True},
},
requires_admin=True,
)
assert policy.effect is ActionEffect.SENSITIVE_READ
assert policy.required_role is PrincipalRole.SYSTEM_ADMIN
assert policy.confirmation is ConfirmationMode.REQUIRED
assert policy.result_sensitivity is ResultSensitivity.SECRET
def test_legacy_shadow_decision_allows_without_claiming_enforcement() -> None:
"""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_stays_shadow_in_generic_orchestrator() -> None:
"""API 敏感读取在通用编排器中等待交互入口完成确认。"""
tool = MoviePilotApiTool(session_id="session-1", user_id="1")
tool.set_agent_context({"is_admin": True})
observation = DEFAULT_TOOL_POLICY_ORCHESTRATOR.start(
context=_interactive_context(),
tool=tool,
arguments={
"operation_id": "config.system.get",
"body": {"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 == "confirmation_policy_shadow_allow"
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": NotificationChannel.Web.value, "source": "openai"},
ToolOrigin.AGENT_API,
PrincipalType.SYSTEM_ADMIN_INTEGRATION,
AuthSource.API_TOKEN,
),
(
{"channel": NotificationChannel.Web.value, "source": "openai.responses"},
ToolOrigin.AGENT_API,
PrincipalType.SYSTEM_ADMIN_INTEGRATION,
AuthSource.API_TOKEN,
),
(
{"channel": NotificationChannel.Web.value, "source": "anthropic"},
ToolOrigin.AGENT_API,
PrincipalType.SYSTEM_ADMIN_INTEGRATION,
AuthSource.API_TOKEN,
),
(
{"channel": NotificationChannel.Web.value, "source": "browser"},
ToolOrigin.AGENT_INTERACTIVE,
PrincipalType.HUMAN,
AuthSource.WEB_SESSION,
),
(
{"channel": NotificationChannel.WebAgent.value, "source": "web-agent"},
ToolOrigin.AGENT_INTERACTIVE,
PrincipalType.HUMAN,
AuthSource.WEB_SESSION,
),
(
{"channel": NotificationChannel.Telegram.value, "source": "telegram"},
ToolOrigin.AGENT_INTERACTIVE,
PrincipalType.HUMAN,
AuthSource.CHANNEL,
),
(
{"channel": NotificationChannel.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=NotificationChannel.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 = SimpleNamespace(decision=SimpleNamespace(allowed=True))
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 = SimpleNamespace(decision=SimpleNamespace(allowed=True))
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
result = asyncio.run(middleware.awrap_tool_call(request, _handler))
assert result.status == "error"
assert "ValueError" in result.content
assert "original-tool-failure" not in result.content
assert "policy-fail-hook-failure" not in result.content
def test_middleware_keeps_shadow_observation_without_enforcing_decision() -> None:
"""普通 ToolNode 不得因 shadow 观测决策改变既有行为。"""
orchestrator = MagicMock()
orchestrator.start.return_value = SimpleNamespace(decision=SimpleNamespace(allowed=False))
middleware = AgentPolicyMiddleware(
context=_interactive_context(),
orchestrator=orchestrator,
)
request = SimpleNamespace(
tool=_EchoTool(session_id="session-1", user_id="user-1"),
tool_call={"id": "call-1", "name": "policy_echo", "args": {"query": "same"}},
)
handler = AsyncMock(return_value=ToolMessage(content="same", tool_call_id="call-1"))
result = asyncio.run(middleware.awrap_tool_call(request, handler))
assert result.content == "same"
handler.assert_awaited_once_with(request)
orchestrator.finish.assert_called_once()
orchestrator.fail.assert_not_called()
def test_enforced_policy_start_failure_blocks_handler() -> None:
"""强制策略无法形成决定时不得继续执行受保护工具。"""
orchestrator = MagicMock()
orchestrator.start.side_effect = RuntimeError("policy-start-failure")
middleware = AgentPolicyMiddleware(
context=_interactive_context(),
orchestrator=orchestrator,
)
tool = _EchoTool(session_id="session-1", user_id="user-1")
handler = AsyncMock(return_value="secret-result")
executed, result = asyncio.run(
middleware.execute_tool_call(
tool=tool,
arguments={"query": "secret"},
invocation_id="strict-call-1",
handler=handler,
)
)
assert executed is False
assert result == "宿主策略暂时不可用,未执行该工具。"
handler.assert_not_awaited()
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_dynamic_tool_keeps_existing_authorization_authority(
legacy_admin: bool,
expected_result: str,
expected_run_count: int,
) -> None:
"""动态工具仍由既有管理员门禁决定是否进入 handler。"""
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=NotificationChannel.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))
if legacy_admin:
assert result.content == expected_result
assert result.status == "success"
else:
assert json.loads(result.content) == {"success": False, "error": expected_result}
assert result.status == "error"
assert events.count("run") == expected_run_count
assert len(observations) == 1
assert observations[0].policy.effect is ActionEffect.UNKNOWN
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": "抱歉,您没有执行此工具的权限。只有系统管理员才能执行工具操作。",
"execution_outcome": "failed",
"recovery": "当前身份无权执行该工具;改用允许的只读工具或请求具备权限的用户确认。",
}
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"
executor = AsyncMock()
executor.execute.return_value = json.dumps(
{"success": True, "data": {"value": secret_marker}},
ensure_ascii=False,
)
tool = MoviePilotApiTool(
session_id="session-1",
user_id="1",
executor=executor,
)
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": {
"operation_id": "config.system.get",
"body": {
"setting_key": "COOKIECLOUD_KEY",
"show_secrets": True,
},
},
},
)
mock_logger = MagicMock()
async def _handler(_request):
result = await tool._arun(
operation_id="config.system.get",
body={"setting_key": "COOKIECLOUD_KEY", "show_secrets": True},
)
return ToolMessage(content=result, tool_call_id="call-secret-setting")
with 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 '"protected_result": "***"' 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 = _AgentFactoryLLM()
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=NotificationChannel.WebAgent.value,
source="web-agent",
)
fake_llm = _AgentFactoryLLM()
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)
)
compaction_index = next(
index
for index, middleware in enumerate(middlewares)
if isinstance(middleware, FinalRequestCompactionMiddleware)
)
assert policy_index == 0
assert activity_index == memory_index + 1
assert compaction_index > activity_index