Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
31 changes: 19 additions & 12 deletions src/agentdiff/engine/loop_detector.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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
Expand Down
34 changes: 30 additions & 4 deletions tests/test_loop_detector.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,17 +28,43 @@ 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",
[
make_step("query_db", input_payload={"q": 1}),
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():
Expand Down
Loading