Skip to content

Commit 0da531f

Browse files
Fix loop detection: don't count looped-on agent's runs as progress
When Coordinator keeps picking the same agent A and A keeps running, A's own completions were bumping _progress_counter. Loop detection compares the counter snapshot taken at the previous identical Coordinator pick against the current value; if it changed, the streak was reset to 1. So the 3-strike threshold was never reached and the Coordinator->A->A pattern ran until max_rounds. Now we only treat a non-Coordinator completion as 'progress' when the completing agent is different from the agent the Coordinator is currently latching onto (_last_coordinator_selection[0]). A different agent stepping in still resets the streak; A repeating itself does not. Adds two regression tests covering both cases. Also updates an existing termination test whose name described 'other agent makes progress' but actually used the same agent, hard-coding the buggy semantics. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent d5da788 commit 0da531f

3 files changed

Lines changed: 108 additions & 3 deletions

File tree

src/processor/src/libs/agent_framework/groupchat_orchestrator.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1018,8 +1018,27 @@ async def _complete_agent_response(
10181018
# Mark progress on any non-Coordinator completion. This is used to ensure loop
10191019
# detection only triggers when the Coordinator is repeating itself *and* the
10201020
# rest of the conversation is not advancing.
1021+
#
1022+
# IMPORTANT: we must NOT count the looped-on agent's own runs as "progress".
1023+
# If we did, then the pattern "Coordinator picks A -> A runs -> Coordinator
1024+
# picks A -> A runs -> ..." would keep bumping the progress counter, which
1025+
# would reset the loop-detection streak on every check, and the streak would
1026+
# never grow past 1. The loop would then never be detected.
1027+
#
1028+
# Real progress means a DIFFERENT agent ran since the last identical Coordinator
1029+
# selection. So we only increment when the completing agent is not the one the
1030+
# Coordinator is currently latching onto.
10211031
if agent_name != self.coordinator_name:
1022-
self._progress_counter += 1
1032+
last_selected = (
1033+
self._last_coordinator_selection[0]
1034+
if self._last_coordinator_selection
1035+
else None
1036+
)
1037+
if (
1038+
last_selected is None
1039+
or agent_name.lower() != last_selected.lower()
1040+
):
1041+
self._progress_counter += 1
10231042

10241043
# Detect manager termination signal (finish=true) from Coordinator.
10251044
# NOTE: The underlying GroupChatBuilder does not automatically stop on finish,

src/processor/src/tests/unit/libs/agent_framework/test_groupchat_orchestrator_internals.py

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -700,6 +700,92 @@ def _select(participant: str, instruction: str = "do"):
700700

701701
assert orch._forced_termination_requested is True
702702

703+
def test_loop_breaker_triggered_when_looped_agent_runs_between_selections(
704+
self,
705+
):
706+
"""Regression: when Coordinator keeps picking the same agent, that agent's
707+
own runs MUST NOT count as progress, or the streak resets and the loop
708+
never breaks.
709+
"""
710+
orch = _make_orch()
711+
orch._conversation = []
712+
713+
def _select(participant: str, instruction: str = "do"):
714+
orch._current_agent_response = [
715+
json.dumps(
716+
{
717+
"selected_participant": participant,
718+
"instruction": instruction,
719+
"finish": False,
720+
"final_message": "",
721+
}
722+
)
723+
]
724+
orch._current_agent_start_time = datetime.now()
725+
726+
def _agent_runs(name: str, text: str = "ok"):
727+
orch._current_agent_response = [text]
728+
orch._current_agent_start_time = datetime.now()
729+
730+
# Simulate production sequence: Coordinator picks A, then A runs,
731+
# then Coordinator picks A again, then A runs, etc.
732+
_select("A")
733+
_run(orch._complete_agent_response("Coordinator", None))
734+
_agent_runs("A")
735+
_run(orch._complete_agent_response("A", None))
736+
_select("A")
737+
_run(orch._complete_agent_response("Coordinator", None))
738+
_agent_runs("A")
739+
_run(orch._complete_agent_response("A", None))
740+
_select("A")
741+
_run(orch._complete_agent_response("Coordinator", None))
742+
743+
assert orch._forced_termination_requested is True
744+
745+
def test_loop_breaker_resets_when_different_agent_responds(self):
746+
"""If a different agent responds between identical Coordinator selections,
747+
treat that as real progress and reset the streak.
748+
"""
749+
orch = _make_orch()
750+
orch._conversation = []
751+
752+
def _select(participant: str, instruction: str = "do"):
753+
orch._current_agent_response = [
754+
json.dumps(
755+
{
756+
"selected_participant": participant,
757+
"instruction": instruction,
758+
"finish": False,
759+
"final_message": "",
760+
}
761+
)
762+
]
763+
orch._current_agent_start_time = datetime.now()
764+
765+
def _agent_runs(name: str, text: str = "ok"):
766+
orch._current_agent_response = [text]
767+
orch._current_agent_start_time = datetime.now()
768+
769+
# Sequence: A, A, B, A, A (a different agent B interrupts -> streak resets)
770+
_select("A")
771+
_run(orch._complete_agent_response("Coordinator", None))
772+
_agent_runs("A")
773+
_run(orch._complete_agent_response("A", None))
774+
_select("A")
775+
_run(orch._complete_agent_response("Coordinator", None))
776+
_agent_runs("B")
777+
_run(orch._complete_agent_response("B", None))
778+
_select("A")
779+
_run(orch._complete_agent_response("Coordinator", None))
780+
_agent_runs("A")
781+
_run(orch._complete_agent_response("A", None))
782+
_select("A")
783+
_run(orch._complete_agent_response("Coordinator", None))
784+
785+
# Only 2 consecutive A selections without progress (one streak of 2
786+
# before B reset it, one streak of 2 after). Loop NOT detected.
787+
assert orch._forced_termination_requested is False
788+
703789

704790
# -----------------------------------------------------------------------------
705791
# _build_groupchat

src/processor/src/tests/unit/libs/agent_framework/test_groupchat_orchestrator_termination.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,9 +113,9 @@ def _agent_reply(text: str = "ok"):
113113
_coordinator_select("Chief Architect")
114114
await orch._complete_agent_response("Coordinator", callback=None)
115115

116-
# 2) The participant responds (progress).
116+
# 2) A DIFFERENT participant responds (real progress, not the looped-on one).
117117
_agent_reply("progress")
118-
await orch._complete_agent_response("Chief Architect", callback=None)
118+
await orch._complete_agent_response("AKS Expert", callback=None)
119119

120120
# 3) Coordinator repeats the same selection twice.
121121
_coordinator_select("Chief Architect")

0 commit comments

Comments
 (0)