Files
confident-ai--deepeval/tests/test_agent_loop_detection.py
2026-07-13 13:32:05 +08:00

373 lines
13 KiB
Python

"""Tests for AgentLoopDetectionMetric.
All tests are fully deterministic — no API key, no network, no LLM required.
"""
import pytest
from deepeval.test_case import LLMTestCase
from deepeval.metrics.agent_loop_detection import AgentLoopDetectionMetric
# ---------------------------------------------------------------------------
# Trace / test-case builder helpers
# ---------------------------------------------------------------------------
def _make_tool_span(name: str, input_data: dict, children=None) -> dict:
"""Build a minimal tool span dict matching DeepEval's _trace_dict format."""
span = {
"type": "tool",
"name": name,
"input": input_data,
"output": f"result of {name}",
"children": children or [],
}
return span
def _make_llm_span(output: str, children=None) -> dict:
"""Build a minimal LLM span dict."""
return {
"type": "llm",
"name": "llm_call",
"input": "some prompt",
"output": output,
"children": children or [],
}
def _make_agent_span(name: str, children: list) -> dict:
"""Build a root agent span wrapping child spans."""
return {
"type": "agent",
"name": name,
"input": "user query",
"output": "agent answer",
"children": children,
}
def _make_test_case(trace_dict: dict) -> LLMTestCase:
"""Create an LLMTestCase with a pre-built _trace_dict (no API needed)."""
tc = LLMTestCase(input="test input", actual_output="test output")
tc._trace_dict = trace_dict
return tc
# ---------------------------------------------------------------------------
# Test 1: Clean trace → score 1.0
# ---------------------------------------------------------------------------
def test_clean_trace_passes():
"""A well-behaved agent with distinct tool calls should score 1.0."""
trace = _make_agent_span(
"planner",
[
_make_tool_span("search_web", {"query": "Paris weather"}),
_make_tool_span("get_forecast", {"city": "Paris"}),
_make_tool_span("summarize", {"text": "It is sunny"}),
],
)
metric = AgentLoopDetectionMetric(threshold=0.5)
tc = _make_test_case(trace)
metric._calculate_metric(tc)
assert metric.score == 1.0
assert metric.success is True
# ---------------------------------------------------------------------------
# Test 2: Repeated tool calls → score ≤ 0.5
# ---------------------------------------------------------------------------
def test_repeated_tool_calls_detected():
"""Four identical tool calls with repetition_threshold=3 should fail.
The metric scores 0.5 for moderate repetition (count >= threshold but
< 2x threshold). Using threshold=0.7 ensures 0.5 < threshold → failure.
"""
trace = _make_agent_span(
"looping_agent",
[
_make_tool_span("search_web", {"query": "Paris weather"}),
_make_tool_span("search_web", {"query": "Paris weather"}),
_make_tool_span("search_web", {"query": "Paris weather"}),
_make_tool_span("search_web", {"query": "Paris weather"}),
],
)
metric = AgentLoopDetectionMetric(
threshold=0.7, # > 0.5 so that a moderate repetition score fails
repetition_threshold=3,
check_reasoning_stagnation=False,
check_call_graph_cycles=False,
)
tc = _make_test_case(trace)
metric._calculate_metric(tc)
assert metric.score_breakdown["tool_repetition"] <= 0.5
assert metric.success is False
# ---------------------------------------------------------------------------
# Test 3: No trace data → score 0.0 with "No trace data" in reason
# ---------------------------------------------------------------------------
def test_no_trace_returns_zero():
"""Missing trace should immediately return 0.0 with a descriptive reason."""
metric = AgentLoopDetectionMetric()
tc = LLMTestCase(input="x", actual_output="y")
tc._trace_dict = None
metric._calculate_metric(tc)
assert metric.score == 0.0
assert "No trace data" in metric.reason
# ---------------------------------------------------------------------------
# Test 4: Stagnating LLM outputs → stagnation score < 1.0
# ---------------------------------------------------------------------------
def test_reasoning_stagnation_detected():
"""Identical long LLM outputs should be flagged as stagnating."""
repeated_output = (
"I need to search for Paris weather using the search tool. "
"The user wants current weather conditions in Paris France. "
"I will call the search tool with query Paris weather forecast today. "
"After retrieving results I will summarize them clearly for the user. "
"Paris weather search results will help answer the question completely."
)
# Build a trace with 3 identical LLM outputs (all > 20 meaningful words)
trace = _make_agent_span(
"stagnating_agent",
[
_make_llm_span(repeated_output),
_make_llm_span(repeated_output),
_make_llm_span(repeated_output),
],
)
metric = AgentLoopDetectionMetric(
similarity_threshold=0.75,
check_tool_repetition=False,
check_call_graph_cycles=False,
)
tc = _make_test_case(trace)
metric._calculate_metric(tc)
assert metric.score_breakdown["reasoning_stagnation"] < 1.0
# ---------------------------------------------------------------------------
# Test 5: Selective sub-signal disabling
# ---------------------------------------------------------------------------
def test_disable_tool_repetition_check():
"""When check_tool_repetition=False, repeated tools must not penalise score."""
trace = _make_agent_span(
"agent",
[
_make_tool_span("search_web", {"query": "q"}),
_make_tool_span("search_web", {"query": "q"}),
_make_tool_span("search_web", {"query": "q"}),
_make_tool_span("search_web", {"query": "q"}),
],
)
metric = AgentLoopDetectionMetric(
check_tool_repetition=False,
check_reasoning_stagnation=False,
check_call_graph_cycles=False,
)
tc = _make_test_case(trace)
metric._calculate_metric(tc)
# All checks disabled → should default to 1.0
assert metric.score_breakdown["tool_repetition"] == 1.0
assert metric.score == 1.0
# ---------------------------------------------------------------------------
# Test 6: Score combining weights normalise correctly
# ---------------------------------------------------------------------------
def test_score_combines_with_correct_weights():
"""When cycles are disabled, rep+stag weights must sum to 1.0 after norm."""
metric = AgentLoopDetectionMetric(
check_tool_repetition=True,
check_reasoning_stagnation=True,
check_call_graph_cycles=False,
)
# rep=1.0 * 0.40, stag=0.0 * 0.35 → total=0.40 / 0.75
combined = metric._combine_scores(1.0, 0.0, 1.0)
expected = 0.40 / 0.75
assert abs(combined - expected) < 0.001
# ---------------------------------------------------------------------------
# Test 7: Call graph cycle (true recursive span) → cycle score 0.0
# ---------------------------------------------------------------------------
def test_call_graph_cycle_detected():
"""A true recursive loop passes the same input back to itself.
The inner agent has the same type, name, AND input as the outer —
this is the signature of a genuine call graph cycle where the agent
re-invoked itself with the same request.
"""
# agent("user query") → tool_A → agent("user query") ← true cycle
inner_agent = {
"type": "agent",
"name": "planner",
"input": "user query", # same input as outer → recursive loop
"output": "stuck",
"children": [],
}
outer_agent = {
"type": "agent",
"name": "planner",
"input": "user query", # same input as inner
"output": "answer",
"children": [
_make_tool_span("tool_a", {"x": "1"}, children=[inner_agent]),
],
}
metric = AgentLoopDetectionMetric(
check_tool_repetition=False,
check_reasoning_stagnation=False,
check_call_graph_cycles=True,
)
tc = _make_test_case(outer_agent)
metric._calculate_metric(tc)
assert metric.score_breakdown["call_graph_cycles"] == 0.0
assert metric.success is False
assert "Cycle" in metric.reason
# ---------------------------------------------------------------------------
# Test 8: Sequential same-name calls are NOT flagged as call graph cycles
# ---------------------------------------------------------------------------
def test_sequential_same_name_not_a_cycle():
"""
Calling tool A then tool B then tool A again is NOT a cycle — tool A appears
twice at sibling positions, not on the same ancestry path.
"""
trace = _make_agent_span(
"agent",
[
_make_tool_span("search_web", {"q": "foo"}),
_make_tool_span("other_tool", {"x": "bar"}),
_make_tool_span(
"search_web", {"q": "baz"}
), # same name, different args
],
)
metric = AgentLoopDetectionMetric(
check_tool_repetition=False,
check_reasoning_stagnation=False,
check_call_graph_cycles=True,
)
tc = _make_test_case(trace)
metric._calculate_metric(tc)
# No cycle — these are siblings, not ancestors of each other
assert metric.score_breakdown["call_graph_cycles"] == 1.0
# ---------------------------------------------------------------------------
# Test 9: Same-name agents with DIFFERENT inputs are NOT false-positived
# ---------------------------------------------------------------------------
def test_same_name_different_input_not_a_cycle():
"""Two agents with the same type:name but different inputs should not
be flagged as a cycle — input_hash disambiguates them.
This is the edge case where the old label-only approach would have
false-positived. Including a truncated input hash in the DFS label
makes the detection resilient to legitimate same-name delegation.
"""
# outer planner delegates to an inner planner with different input
inner_planner = {
"type": "agent",
"name": "planner",
"input": "subtask: book hotel in Paris",
"output": "hotel booked",
"children": [],
}
outer_planner = {
"type": "agent",
"name": "planner", # same name!
"input": "plan trip to Paris", # but different input
"output": "trip planned",
"children": [
_make_tool_span(
"delegate", {"to": "planner"}, children=[inner_planner]
),
],
}
metric = AgentLoopDetectionMetric(
check_tool_repetition=False,
check_reasoning_stagnation=False,
check_call_graph_cycles=True,
)
tc = _make_test_case(outer_planner)
metric._calculate_metric(tc)
# Different inputs → different labels → no cycle
assert metric.score_breakdown["call_graph_cycles"] == 1.0
assert metric.success is True
# ---------------------------------------------------------------------------
# Test 10: Reordered-but-identical reasoning is caught by SequenceMatcher
# ---------------------------------------------------------------------------
def test_reordered_stagnation_detected():
"""SequenceMatcher should catch stagnation even when the agent shuffles
its phrasing but conveys the same content.
Bigram Jaccard alone would miss this because the bigram sets differ
when words are reordered. SequenceMatcher, being sequence-aware,
produces a high ratio and triggers the stagnation flag.
"""
output_a = (
"I need to search for the current weather conditions in Paris France "
"using the search tool. After retrieving the results I will summarize "
"them clearly for the user. The Paris weather search results will help "
"me answer the question completely and accurately for the user today."
)
# Same semantic content, moderate word reordering
output_b = (
"Let me search for the current weather conditions in Paris France "
"using the search tool. After I retrieve the results I will clearly "
"summarize them for the user. The search results for Paris weather "
"will help me completely and accurately answer the question for today."
)
trace = _make_agent_span(
"agent",
[
_make_llm_span(output_a),
_make_llm_span(output_b),
],
)
metric = AgentLoopDetectionMetric(
similarity_threshold=0.75,
check_tool_repetition=False,
check_call_graph_cycles=False,
)
tc = _make_test_case(trace)
metric._calculate_metric(tc)
# SequenceMatcher should push the similarity above 0.75
assert metric.score_breakdown["reasoning_stagnation"] < 1.0