Files
MoviePilot/tests/test_agent_guardrail_controller.py
DDSRem 2de976f6b3 Merge pull request #7050 from DDSRem-Dev/codex-comment-migration
docs: 统一全项目中文注释和结构化契约说明
2026-10-17 19:26:29 +08:00

491 lines
19 KiB
Python
Raw Permalink 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 json
from app.agent.guardrails.controller import (
LoopCapConfig,
ToolCallGuardrailConfig,
ToolCallGuardrailController,
ToolCallSignature,
canonical_tool_args,
classify_tool_failure,
)
_PYTEST = {'command': 'pytest tests/test_x.py -q'}
_RED = '{"output": "1 failed", "exit_code": 1}'
def _HARD():
"""
构造无人值守的默认强制停止策略。
"""
return ToolCallGuardrailController(ToolCallGuardrailConfig(hard_stop_enabled=True))
def test_tool_call_signature_hashes_canonical_nested_unicode_args_without_exposing_raw_args():
"""
验证循环检测合同,保持阈值、恢复及回执语义。
"""
args_a = {
"z": [{"β": "☤", "a": 1}],
"a": {"y": 2, "x": "secret-token-value"},
}
args_b = {
"a": {"x": "secret-token-value", "y": 2},
"z": [{"a": 1, "β": "☤"}],
}
assert canonical_tool_args(args_a) == canonical_tool_args(args_b)
sig_a = ToolCallSignature.from_call("web_search", args_a)
sig_b = ToolCallSignature.from_call("web_search", args_b)
assert sig_a == sig_b
assert len(sig_a.args_hash) == 64
metadata = sig_a.to_metadata()
assert metadata == {"tool_name": "web_search", "args_hash": sig_a.args_hash}
assert "secret-token-value" not in json.dumps(metadata)
assert "☤" not in json.dumps(metadata)
def test_config_parses_nested_warn_and_hard_stop_thresholds():
"""
验证循环检测合同,保持阈值、恢复及回执语义。
"""
cfg = ToolCallGuardrailConfig.from_mapping(
{
"warnings_enabled": False,
"hard_stop_enabled": True,
"warn_after": {
"exact_failure": 3,
"same_tool_failure": 4,
"idempotent_no_progress": 5,
},
"hard_stop_after": {
"exact_failure": 6,
"same_tool_failure": 7,
"idempotent_no_progress": 8,
},
}
)
assert cfg.warnings_enabled is False
assert cfg.hard_stop_enabled is True
assert cfg.exact_failure_warn_after == 3
assert cfg.same_tool_failure_warn_after == 4
assert cfg.no_progress_warn_after == 5
assert cfg.exact_failure_block_after == 6
assert cfg.same_tool_failure_halt_after == 7
assert cfg.no_progress_block_after == 8
def test_gateway_platform_defaults_to_hard_stop_without_changing_interactive_defaults():
"""
验证循环检测合同,保持阈值、恢复及回执语义。
"""
interactive_configs = [
ToolCallGuardrailConfig.from_mapping({}, platform=platform)
for platform in ("cli", "tui", "desktop", "acp")
]
telegram_cfg = ToolCallGuardrailConfig.from_mapping({}, platform="telegram")
cron_cfg = ToolCallGuardrailConfig.from_mapping({}, platform="cron")
assert all(cfg.hard_stop_enabled is False for cfg in interactive_configs)
assert telegram_cfg.hard_stop_enabled is True
assert cron_cfg.hard_stop_enabled is True
def test_non_interactive_hard_stop_can_be_disabled_explicitly():
"""
验证循环检测合同,保持阈值、恢复及回执语义。
"""
cfg = ToolCallGuardrailConfig.from_mapping(
{"non_interactive_hard_stop_enabled": False},
platform="telegram",
)
assert cfg.hard_stop_enabled is False
assert cfg.non_interactive_hard_stop_enabled is False
def test_default_repeated_identical_failed_call_warns_without_blocking():
"""
验证循环检测合同,保持阈值、恢复及回执语义。
"""
controller = ToolCallGuardrailController()
args = {"query": "same"}
decisions = []
for _ in range(5):
assert controller.before_call("web_search", args).action == "allow"
decisions.append(
controller.after_call("web_search", args, '{"error":"boom"}', failed=True)
)
assert decisions[0].action == "allow"
assert [d.action for d in decisions[1:]] == ["warn", "warn", "warn", "warn"]
assert {d.code for d in decisions[1:]} == {"repeated_exact_failure_warning"}
assert controller.before_call("web_search", args).action == "allow"
assert controller.halt_decision is None
def test_hard_stop_enabled_blocks_repeated_exact_failure_before_next_execution():
"""
验证循环检测合同,保持阈值、恢复及回执语义。
"""
controller = ToolCallGuardrailController(
ToolCallGuardrailConfig(
hard_stop_enabled=True,
exact_failure_warn_after=2,
exact_failure_block_after=2,
same_tool_failure_halt_after=99,
)
)
args = {"query": "same"}
assert controller.before_call("web_search", args).action == "allow"
first = controller.after_call("web_search", args, '{"error":"boom"}', failed=True)
assert first.action == "allow"
assert controller.before_call("web_search", args).action == "allow"
second = controller.after_call("web_search", args, '{"error":"boom"}', failed=True)
assert second.action == "warn"
assert second.code == "repeated_exact_failure_warning"
blocked = controller.before_call("web_search", args)
assert blocked.action == "block"
assert blocked.code == "repeated_exact_failure_block"
assert blocked.count == 2
def test_skill_read_tools_are_idempotent_and_block_repeated_identical_success_output():
"""
验证循环检测合同,保持阈值、恢复及回执语义。
"""
cases = [
(
"skill_view",
{"name": "gui-agent-ml-operations"},
'{"success":true,"name":"gui-agent-ml-operations","content":"same"}',
),
(
"skills_list",
{"category": "mlops"},
'{"success":true,"skills":[{"name":"gui-agent-ml-operations"}]}',
),
]
for tool_name, args, result in cases:
controller = ToolCallGuardrailController(
ToolCallGuardrailConfig(
hard_stop_enabled=True,
no_progress_warn_after=2,
no_progress_block_after=2,
)
)
assert controller.before_call(tool_name, args).action == "allow"
assert controller.after_call(tool_name, args, result, failed=False).action == "allow"
assert controller.before_call(tool_name, args).action == "allow"
warn = controller.after_call(tool_name, args, result, failed=False)
assert warn.action == "warn"
assert warn.code == "idempotent_no_progress_warning"
blocked = controller.before_call(tool_name, args)
assert blocked.action == "block"
assert blocked.code == "idempotent_no_progress_block"
def test_mutating_or_unknown_tools_are_not_blocked_for_repeated_identical_success_output_by_default():
"""
验证循环检测合同,保持阈值、恢复及回执语义。
"""
controller = ToolCallGuardrailController(
ToolCallGuardrailConfig(no_progress_warn_after=2, no_progress_block_after=2)
)
for _ in range(3):
assert controller.before_call("write_file", {"path": "/tmp/x", "content": "x"}).action == "allow"
assert controller.after_call("write_file", {"path": "/tmp/x", "content": "x"}, "ok", failed=False).action == "allow"
assert controller.before_call("custom_tool", {"x": 1}).action == "allow"
assert controller.after_call("custom_tool", {"x": 1}, "ok", failed=False).action == "allow"
def test_identical_call_streak_halts_any_tool_when_hard_stop_enabled():
# 对应 #89069 / #100849:重复成功的 terminal/skill_view 调用可能绕过幂等工具的无进展判定。
# observe_call 对所有工具统计连续相同的调用与结果,启用硬停止时必须阻断。
"""
验证循环检测合同,保持阈值、恢复及回执语义。
"""
controller = ToolCallGuardrailController(
ToolCallGuardrailConfig(hard_stop_enabled=True, no_progress_block_after=5)
)
args = {"command": "hermes config get memory.provider"}
for i in range(1, 5):
controller.after_call("terminal", args, "local\n", failed=False)
controller.observe_call("terminal", args, "local\n", failed=False)
assert controller.halt_decision is None, f"halted early at {i}"
controller.after_call("terminal", args, "local\n", failed=False)
controller.observe_call("terminal", args, "local\n", failed=False)
halt = controller.halt_decision
assert halt is not None and halt.should_halt
assert halt.code == "identical_call_streak_halt"
assert halt.tool_name == "terminal" and halt.count == 5
def test_identical_call_streak_never_halts_when_hard_stop_disabled_or_for_pollers():
"""
验证循环检测合同,保持阈值、恢复及回执语义。
"""
soft = ToolCallGuardrailController(
ToolCallGuardrailConfig(hard_stop_enabled=False, no_progress_block_after=2)
)
for _ in range(6):
soft.observe_call("terminal", {"command": "ls"}, "a\nb\n", failed=False)
# 交互会话中只发出提示。
assert soft.halt_decision is None
hard = ToolCallGuardrailController(
ToolCallGuardrailConfig(hard_stop_enabled=True, no_progress_block_after=2)
)
for _ in range(6):
hard.observe_call("process_manage", {"action": "poll", "session_id": "p1"}, "running", failed=False)
# 状态未变化的轮询仍是合法操作。
assert hard.halt_decision is None
# 结果变化后重新计算连续重复次数。
for i in range(6):
hard.observe_call("terminal", {"command": "date"}, f"t{i}", failed=False)
assert hard.halt_decision is None
def test_loop_cap_zero_disables_and_junk_falls_back():
# 0 表示不限制;负数或无效内容回退到默认限制。
"""
验证循环检测合同,保持阈值、恢复及回执语义。
"""
assert LoopCapConfig.from_mapping({"max_web_searches": 0}).max_web_searches == 0
assert LoopCapConfig.from_mapping({"max_web_searches": -5}).max_web_searches == LoopCapConfig().max_web_searches
assert LoopCapConfig.from_mapping({"max_subagents": "nope"}).max_subagents == LoopCapConfig().max_subagents
def test_web_search_cap_blocks_after_limit_regardless_of_hard_stop():
# 即使关闭单轮循环检测的硬停止开关,循环次数上限仍应生效。
# 每次使用不同查询避开重复调用检测,确保阻断来自总次数限制。
"""
验证循环检测合同,保持阈值、恢复及回执语义。
"""
controller = ToolCallGuardrailController(
ToolCallGuardrailConfig(
hard_stop_enabled=False,
loop_caps=LoopCapConfig(max_web_searches=3),
)
)
for i in range(3):
assert controller.before_call("web_search", {"query": f"q{i}"}).action == "allow"
decision = controller.before_call("web_search", {"query": "q4"})
assert decision.action == "block"
assert decision.code == "loop_web_search_cap"
assert decision.should_halt is True
def _run_red(c, args=_PYTEST):
"""
验证循环检测合同,保持阈值、恢复及回执语义。
"""
assert c.before_call("terminal", args).allows_execution
return c.after_call("terminal", args, _RED, failed=True)
def test_fix_retest_loop_is_never_hard_stopped():
"""
验证循环检测合同,保持阈值、恢复及回执语义。
"""
c = _HARD()
for i in range(12):
d = _run_red(c)
assert not d.should_halt, f"halted on red run {i + 1}"
# 两次执行之间发生实际修改,应视为取得进展。
c.after_call("patch", {"path": "x.py", "old_string": "a", "new_string": f"b{i}"},
'{"success": true, "diff": "..."}', failed=False)
assert c.halt_decision is None
assert c.before_call("terminal", _PYTEST).allows_execution
def test_pure_replay_with_no_intervening_change_is_still_blocked():
"""
验证循环检测合同,保持阈值、恢复及回执语义。
"""
c = _HARD()
for _ in range(5):
_run_red(c)
d = c.before_call("terminal", _PYTEST)
assert d.action == "block" and d.code == "repeated_exact_failure_block"
def test_intervening_mutation_resets_the_replay_streak_only_once():
# 四次失败后修改一次,再连续失败四次;修改会重置计数。
# 后续第五次没有修改的重试才应被阻断。
"""
验证循环检测合同,保持阈值、恢复及回执语义。
"""
c = _HARD()
for _ in range(4):
_run_red(c)
c.after_call("write_file", {"path": "x.py", "content": "y"}, '{"bytes_written": 1}', failed=False)
for _ in range(5):
assert c.before_call("terminal", _PYTEST).allows_execution
c.after_call("terminal", _PYTEST, _RED, failed=True)
assert c.before_call("terminal", _PYTEST).action == "block"
def test_distinct_failing_terminal_commands_warn_but_never_halt():
# 模拟诊断检查中的无匹配搜索、缺失命令和构建失败。
"""
验证循环检测合同,保持阈值、恢复及回执语义。
"""
c = _HARD()
for i in range(12):
args = {"command": f"grep -q needle{i} haystack.txt"}
d = c.after_call("terminal", args, _RED, failed=True)
assert not d.should_halt, f"same_tool halt on distinct command #{i + 1}"
assert c.halt_decision is None
# 不允许容忍失败的工具,即使八次失败各不相同也会停止。
c2 = _HARD()
last = None
for i in range(8):
last = c2.after_call("send_message", {"to": f"u{i}"}, '{"error": "no route"}', failed=True)
assert last.should_halt and last.code == "same_tool_failure_halt"
def test_browser_retry_after_action_is_not_a_replay():
"""
验证循环检测合同,保持阈值、恢复及回执语义。
"""
c = _HARD()
nav = {"url": "https://example.test/app"}
for _ in range(8):
assert c.before_call("browser_navigate", nav).allows_execution
c.after_call("browser_navigate", nav, '{"error": "timeout"}', failed=True)
c.after_call("browser_click", {"selector": "#retry"}, '{"ok": true}', failed=False)
assert c.halt_decision is None
def test_supervised_task_platforms_keep_warning_only_default():
"""
验证循环检测合同,保持阈值、恢复及回执语义。
"""
for platform in ("subagent", "api_server", "cli"):
cfg = ToolCallGuardrailConfig.from_mapping({}, platform=platform)
assert cfg.hard_stop_enabled is False, platform
for platform in ("telegram", "discord", "cron", "kanban"):
cfg = ToolCallGuardrailConfig.from_mapping({}, platform=platform)
assert cfg.hard_stop_enabled is True, platform
def test_a_real_tool_error_is_still_a_failure():
"""
豁免以专用标记为依据,不凭文本内容判断。
真实失败仍需计入连续次数,避免失去阻止重复失败循环的能力。
"""
real = '{"error": "ENOENT: no such file"}'
assert classify_tool_failure("read_file", real)[0] is True
# 豁免标记仅接受布尔值 True,不能把非空文本视为有效标记。
assert classify_tool_failure("read_file", '{"error": "x", "guardrail_refusal": "yes"}')[0] is True
def test_identical_streak_ignores_volatile_execution_metadata():
# execute_code 每次执行都会改变 execution_count、duration_seconds 等元数据。
# 若将这些值纳入哈希,重复空调用会被误判为新结果,曾导致同一探测执行 147 次而未停止。
"""
验证循环检测合同,保持阈值、恢复及回执语义。
"""
controller = ToolCallGuardrailController(
ToolCallGuardrailConfig(hard_stop_enabled=True, no_progress_block_after=5)
)
args = {"code": "import subprocess\nprint(subprocess.run(['true']).returncode) if False else None"}
def result(n):
"""
验证循环检测合同,保持阈值、恢复及回执语义。
"""
return json.dumps({
"status": "success", "output": "", "exit_code": 0, "tool_calls_made": 0,
"duration_seconds": 1.0 + n / 100,
"kernel": {"mode": "session", "reused": True, "execution_count": 100 + n, "state_reset": False},
"stdout_truncated": False, "stdout_bytes_captured": 0,
})
for i in range(4):
controller.observe_call("execute_code", args, result(i), failed=False)
assert controller.halt_decision is None, f"halted early at {i}"
controller.observe_call("execute_code", args, result(4), failed=False)
halt = controller.halt_decision
assert halt is not None, "volatile metadata defeated the identical-call streak"
assert halt.code == "identical_call_streak_halt"
def test_identical_streak_still_resets_when_real_output_changes():
"""
验证循环检测合同,保持阈值、恢复及回执语义。
"""
controller = ToolCallGuardrailController(
ToolCallGuardrailConfig(hard_stop_enabled=True, no_progress_block_after=3)
)
args = {"code": "print(1)"}
for i in range(6):
out = json.dumps({"status": "success", "output": f"line {i}", "duration_seconds": 1.0,
"kernel": {"execution_count": i}})
controller.observe_call("execute_code", args, out, failed=False)
assert controller.halt_decision is None, "different real output must not count as a replay"
def test_other_tools_keep_duration_and_execution_count_as_real_output():
# 仅 execute_code 的已知元数据位置可忽略;其他工具的同名字段可能就是业务结果。
# 例如任务状态中的耗时发生变化,应视为新结果而非连续重复。
"""
验证循环检测合同,保持阈值、恢复及回执语义。
"""
controller = ToolCallGuardrailController(
ToolCallGuardrailConfig(hard_stop_enabled=True, no_progress_block_after=3)
)
args = {"job": "build-42"}
for i in range(6):
out = json.dumps({"status": "done", "duration_seconds": 10 + i,
"stats": {"execution_count": 100 + i}})
controller.observe_call("mcp_ci_job_status", args, out, failed=False)
assert controller.halt_decision is None, "a real change in duration_seconds/execution_count was treated as a replay"
def test_execute_code_replay_streak_notice_fires_on_warn_only_desktop_config():
# 对应 #124072:交互界面关闭硬停止后,只能通过追加提示提醒模型。
# 过去 186 次空操作仅改变执行计数和耗时,却未触发任何重复提示。
"""
验证循环检测合同,保持阈值、恢复及回执语义。
"""
controller = ToolCallGuardrailController(ToolCallGuardrailConfig.from_mapping({}, platform="desktop"))
args = {"code": 'print("...")'}
def result(n):
"""
验证循环检测合同,保持阈值、恢复及回执语义。
"""
return json.dumps({
"status": "success", "output": "...\n", "exit_code": 0, "tool_calls_made": 0,
"duration_seconds": 0.001 * n,
"kernel": {"mode": "session", "reused": True, "execution_count": n, "state_reset": False},
"stdout_truncated": False, "stdout_bytes_captured": 4, "stdout_bytes_total": 4,
"stdout_bytes_omitted": 0,
})
notices = [
controller.observe_call("execute_code", args, result(i), tool_call_id=f"c{i}").notice
for i in range(1, 7)
]
assert notices[:2] == [None, None]
assert all(n is not None and "consecutive identical call to execute_code" in n for n in notices[2:]), notices
assert controller.halt_decision is None, "warn-only surfaces must not halt"