diff --git a/python/packages/autogen-agentchat/src/autogen_agentchat/teams/_group_chat/_selector_group_chat.py b/python/packages/autogen-agentchat/src/autogen_agentchat/teams/_group_chat/_selector_group_chat.py index 480dc6b71641..d6a77e589abf 100644 --- a/python/packages/autogen-agentchat/src/autogen_agentchat/teams/_group_chat/_selector_group_chat.py +++ b/python/packages/autogen-agentchat/src/autogen_agentchat/teams/_group_chat/_selector_group_chat.py @@ -299,13 +299,24 @@ async def _select_speaker(self, roles: str, participants: List[str], max_attempt trace_logger.debug(f"Model selected a valid name: {agent_name} (attempt {num_attempts})") return agent_name - if self._previous_speaker is not None: + # When repeated speakers are disallowed, returning the previous speaker + # would violate `allow_repeated_speaker=False` and cause a livelock if + # the model keeps picking the excluded speaker + # (see https://github.com/microsoft/autogen/issues/7471). + # The no-`candidate_func` path pre-filters `participants` to exclude + # the previous speaker; the `candidate_func` path does not, so we + # also skip the previous speaker explicitly here. + if self._allow_repeated_speaker and self._previous_speaker is not None: trace_logger.warning(f"Model failed to select a speaker after {max_attempts}, using the previous speaker.") return self._previous_speaker + fallback = next( + (p for p in participants if p != self._previous_speaker), + participants[0], + ) trace_logger.warning( - f"Model failed to select a speaker after {max_attempts} and there was no previous speaker, using the first participant." + f"Model failed to select a speaker after {max_attempts}, using {fallback} as the fallback." ) - return participants[0] + return fallback def _mentioned_agents(self, message_content: str, agent_names: List[str]) -> Dict[str, int]: """Counts the number of times each agent is mentioned in the provided message content. @@ -395,8 +406,9 @@ class SelectorGroupChat(BaseGroupChat, Component[SelectorGroupChatConfig]): allow_repeated_speaker (bool, optional): Whether to include the previous speaker in the list of candidates to be selected for the next turn. Defaults to False. The model may still select the previous speaker -- a warning will be logged if this happens. max_selector_attempts (int, optional): The maximum number of attempts to select a speaker using the model. Defaults to 3. - If the model fails to select a speaker after the maximum number of attempts, the previous speaker will be used if available, - otherwise the first participant will be used. + If the model fails to select a speaker after the maximum number of attempts, the fallback depends on ``allow_repeated_speaker``: + when ``True`` and a previous speaker exists, the previous speaker is reused; otherwise the first participant other than + the previous speaker is used. selector_func (Callable[[Sequence[BaseAgentEvent | BaseChatMessage]], str | None], Callable[[Sequence[BaseAgentEvent | BaseChatMessage]], Awaitable[str | None]], optional): A custom selector function that takes the conversation history and returns the name of the next speaker. If provided, this function will be used to override the model to select the next speaker. diff --git a/python/packages/autogen-agentchat/tests/test_group_chat.py b/python/packages/autogen-agentchat/tests/test_group_chat.py index 3ded2e0c2e60..277bd73c5834 100644 --- a/python/packages/autogen-agentchat/tests/test_group_chat.py +++ b/python/packages/autogen-agentchat/tests/test_group_chat.py @@ -1137,6 +1137,8 @@ async def test_selector_group_chat_fall_back_to_first_after_3_attempts(runtime: @pytest.mark.asyncio async def test_selector_group_chat_fall_back_to_previous_after_3_attempts(runtime: AgentRuntime | None) -> None: + # When allow_repeated_speaker=True, falling back to the previous speaker + # after exhausting max_selector_attempts is the expected behavior. model_client = ReplayChatCompletionClient( ["agent2", "agent2", "agent2", "agent2"], ) @@ -1147,6 +1149,7 @@ async def test_selector_group_chat_fall_back_to_previous_after_3_attempts(runtim participants=[agent1, agent2, agent3], model_client=model_client, max_turns=2, + allow_repeated_speaker=True, runtime=runtime, ) result = await team.run(task="Write a program that prints 'Hello, world!'") @@ -1157,6 +1160,80 @@ async def test_selector_group_chat_fall_back_to_previous_after_3_attempts(runtim assert result.messages[2].source == "agent2" +@pytest.mark.asyncio +async def test_selector_group_chat_fall_back_excludes_previous_when_disallowed( + runtime: AgentRuntime | None, +) -> None: + # Regression test for https://github.com/microsoft/autogen/issues/7471 + # When allow_repeated_speaker=False, the fallback after exhausting + # max_selector_attempts must NOT return the previous speaker; otherwise + # a livelock can occur (same agent picked forever). Instead the fallback + # must pick from the pre-filtered candidate list that already excludes + # the previous speaker. + model_client = ReplayChatCompletionClient( + # First selection has no previous speaker; pick agent2. + # Subsequent selections all attempt to repeat agent2, which is now + # the excluded previous speaker -- the fallback must pick someone else. + ["agent2", "agent2", "agent2", "agent2"], + ) + agent1 = _EchoAgent("agent1", description="echo agent 1") + agent2 = _EchoAgent("agent2", description="echo agent 2") + agent3 = _EchoAgent("agent3", description="echo agent 3") + team = SelectorGroupChat( + participants=[agent1, agent2, agent3], + model_client=model_client, + max_turns=2, + allow_repeated_speaker=False, + runtime=runtime, + ) + result = await team.run(task="Write a program that prints 'Hello, world!'") + assert len(result.messages) == 3 + assert isinstance(result.messages[0], TextMessage) + assert result.messages[0].content == "Write a program that prints 'Hello, world!'" + assert result.messages[1].source == "agent2" + # Second speaker must NOT be agent2 (the previous speaker). + assert result.messages[2].source != "agent2" + assert result.messages[2].source in {"agent1", "agent3"} + + +@pytest.mark.asyncio +async def test_selector_group_chat_fall_back_excludes_previous_with_candidate_func( + runtime: AgentRuntime | None, +) -> None: + # Regression test for https://github.com/microsoft/autogen/issues/7471 + # When `candidate_func` supplies candidates, `allow_repeated_speaker=False` + # is documented to be ignored for filtering, but the post-exhaustion + # fallback must still not return the previous speaker -- otherwise the + # livelock reappears on candidate_func paths. Here candidate_func returns + # [previous, other] and the model keeps picking previous; the fallback + # must pick the non-previous candidate. + model_client = ReplayChatCompletionClient( + ["agent2", "agent2", "agent2", "agent2"], + ) + agent1 = _EchoAgent("agent1", description="echo agent 1") + agent2 = _EchoAgent("agent2", description="echo agent 2") + agent3 = _EchoAgent("agent3", description="echo agent 3") + + def _candidate_func(messages: Sequence[BaseAgentEvent | BaseChatMessage]) -> List[str]: + # Put the previous speaker (agent2) first to trigger the worst case. + return ["agent2", "agent1", "agent3"] + + team = SelectorGroupChat( + participants=[agent1, agent2, agent3], + model_client=model_client, + max_turns=2, + allow_repeated_speaker=False, + candidate_func=_candidate_func, + runtime=runtime, + ) + result = await team.run(task="Write a program that prints 'Hello, world!'") + assert len(result.messages) == 3 + assert result.messages[1].source == "agent2" + # Even though candidate_func returned agent2 first, the fallback must skip it. + assert result.messages[2].source != "agent2" + assert result.messages[2].source in {"agent1", "agent3"} + + @pytest.mark.asyncio async def test_selector_group_chat_custom_selector(runtime: AgentRuntime | None) -> None: model_client = ReplayChatCompletionClient(["agent3"])