Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
77 changes: 77 additions & 0 deletions python/packages/autogen-agentchat/tests/test_group_chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
)
Expand All @@ -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!'")
Expand All @@ -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"])
Expand Down