diff --git a/CHANGELOG.md b/CHANGELOG.md index ed2d219b..3dea60d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 the product/website PR-split rules are retired; the token-service `worker/` remains here as product infrastructure. +## [Unreleased] + +### Fixed +- **Sequence-loop detector no longer blocks legitimate iteration**: repetition + of a step-name pattern was treated as a loop even when the repeated steps had + different inputs and outputs, so a tool-calling agent that iterates one tool + across items (`decide -> call -> decide -> call -> decide -> call`, e.g. one + call per state) was hard-blocked with `Blocked by: loops` while its + trajectory divergence was `0.0`. Repetition now counts as a loop only when + the repeated steps are **stagnant** (identical input payloads and outputs), + matching the identical-call invariant's standard. Runaway behaviour with + drifting arguments remains covered by `max_tool_repeats` and the resource + bands. Found by this project's own live gate running against the public demo + repository. + ## [0.5.0] - 2026-09-02 AgentDiff v0.5.0 — the **Circuit Breaker** release: statistical baselines, an diff --git a/src/agentdiff/engine/loop_detector.py b/src/agentdiff/engine/loop_detector.py index 87718172..58e384cf 100644 --- a/src/agentdiff/engine/loop_detector.py +++ b/src/agentdiff/engine/loop_detector.py @@ -24,8 +24,14 @@ def detect_graph_cycles(trace: AgentTrace) -> list[list[str]]: def detect_sequence_loops(trace: AgentTrace) -> list[dict[str, Any]]: """Detects consecutive repeating sub-sequences of steps (e.g., A -> B -> A -> B). - A loop is defined as a sequence of step names of length k repeating consecutively - 2 or more times. + A loop is a sequence of step names of length k repeating consecutively 2 or + more times **with stagnant state**: the repeated steps must have identical + input payloads and outputs. + + Repetition with *different* inputs is legitimate iteration (an agent + querying three states, paginating, or walking a list), so it is not a loop. + Runaway behaviour with drifting arguments is covered by the tool-repeat cap + (``max_tool_repeats``) and by the resource bands, not by this detector. """ steps = sorted(trace.steps, key=lambda s: s.step_index) names = [s.name for s in steps] @@ -68,16 +74,17 @@ def detect_sequence_loops(trace: AgentTrace) -> list[dict[str, Any]]: if not stagnant: break - loops.append( - { - "steps": pattern, - "step_ids": loop_step_ids, - "iterations": count, - "start_index": i, - "length": k, - "stagnant": stagnant, - } - ) + if stagnant: + loops.append( + { + "steps": pattern, + "step_ids": loop_step_ids, + "iterations": count, + "start_index": i, + "length": k, + "stagnant": stagnant, + } + ) # Advance pointer past the repeated patterns i += count * k diff --git a/tests/test_loop_detector.py b/tests/test_loop_detector.py index 816956c2..d1750cdf 100644 --- a/tests/test_loop_detector.py +++ b/tests/test_loop_detector.py @@ -28,7 +28,8 @@ def test_single_step_stagnant_loop(): assert loops[0]["stagnant"] is True -def test_non_stagnant_loop(): +def test_non_stagnant_repetition_is_iteration_not_a_loop(): + """Different inputs across repeats = walking a list, not a runaway loop.""" trace = make_trace( "t", [ @@ -36,9 +37,34 @@ def test_non_stagnant_loop(): make_step("query_db", input_payload={"q": 2}), ], ) - loops = detect_sequence_loops(trace) - assert len(loops) == 1 - assert loops[0]["stagnant"] is False + assert detect_sequence_loops(trace) == [] + + +def test_iterating_one_tool_over_items_is_not_a_loop(): + """The shape of most tool-calling agents: decide -> call -> decide -> call.""" + trace = make_trace( + "t", + [ + make_step("decide", input_payload={"item": "NY"}), + make_step("query_db", input_payload={"q": "NY"}), + make_step("decide", input_payload={"item": "CA"}), + make_step("query_db", input_payload={"q": "CA"}), + make_step("decide", input_payload={"item": "TX"}), + make_step("query_db", input_payload={"q": "TX"}), + ], + ) + assert detect_sequence_loops(trace) == [] + + +def test_repeats_with_changed_outputs_are_not_a_loop(): + trace = make_trace( + "t", + [ + make_step("fetch", output_payload={"page": 1, "next": True}), + make_step("fetch", output_payload={"page": 2, "next": True}), + ], + ) + assert detect_sequence_loops(trace) == [] def test_multi_step_repeating_pattern():