Skip to content

Commit

Permalink
Graph group chat (#857)
Browse files Browse the repository at this point in the history
* Move contrib-openai.yml

* Moved groupgroupchat

* From #753

* Removed local test references

* Added ignore=test/agentchat/contrib

* Trying to pass contrib-openai tests

* More specific in unit testing.

* Update .github/workflows/contrib-tests.yml

Co-authored-by: Li Jiang <lijiang1@microsoft.com>

* Remove coverage as it is included in test dependencies

* Improved docstring with overview of GraphGroupChat

* Iterate on feedback

* Precommit pass

* user just use pip install pyautogen[graphs]

* Pass precommit

* Pas precommit

* Graph utils an test completed

* Added inversion tests

* Added inversion util

* allow_repeat_speaker can be a list of Agents

* Remove unnessary imports

* Expect ValueError with 1 and 0 agents

* Check that main passes all tests

* Check main

* Pytest all in main

* All done

* pre-commit changes

* noqa E402

* precommit pass

* Removed bin

* Removed old unit test

* Test test_graph_utils

* minor cleanup

* restore tests

* Correct documentation

* Special case of only one agent remaining.

* Improved pytest

* precommit pass

* Delete OAI_CONFIG_LIST_sample copy

* Returns a filtered list for auto to work

* Rename var speaker_order_dict

* To write test cases

* Added check for a list of Agents to repeat

* precommit pass

* Update documentation

* Extract names in allow_repeat_speaker

* Post review changes

* hange "pull_request_target" into "pull_request" temporarily.

* 3 return values from main

* pre-commit changes

* PC edits

* docstr changes

* PC edits

* Rest of changes from main

* Update autogen/agentchat/groupchat.py

Co-authored-by: Chi Wang <wang.chi@microsoft.com>

* Remove unnecessary script files from tracking

* Non empty scripts files from main

* Revert changes in script files to match main branch

* Removed link from website as notebook is removed.

* test/test_graph_utils.py is tested as part of L52 of build.yml

* GroupChat ValueError check

* docstr update

* More clarification in docstr

* Update autogen/agentchat/groupchat.py

Co-authored-by: Chi Wang <wang.chi@microsoft.com>

* Update autogen/agentchat/groupchat.py

Co-authored-by: Chi Wang <wang.chi@microsoft.com>

* Update autogen/agentchat/groupchat.py

Co-authored-by: Chi Wang <wang.chi@microsoft.com>

* Update autogen/agentchat/groupchat.py

Co-authored-by: Chi Wang <wang.chi@microsoft.com>

* 1.add commit to line138 in groupchat.py;2.fix bug if random choice [];3.return selected_agent if len(graph_eligible_agents) is 1;4.replace all speaker_order to speaker_transitions;5.format

* fix graph_modelling notebook in the last cell

* fix failure in test_groupchat.py

* fix agent out of group to initiate a chat like SocietyOfMind

* add a warning rule in graph_utils to check duplicates in any lists

* refactor allowed_or_disallowed_speaker_transitions to Dict[Agent, List[Agent]] and modify the tests and notebook

* delete Rule 4 in graph_utils and related test case. Add a test to resolve https://github.com/microsoft/autogen/pull/857/files/993fd006e922c8efe5e50bd0700e355994c6d337#r1460726831

* fix as the final comments

* modify setup option from graphs to graph and add texts in optional-dependencies.md

* Update autogen/graph_utils.py

---------

Co-authored-by: Li Jiang <lijiang1@microsoft.com>
Co-authored-by: Beibin Li <BeibinLi@users.noreply.github.com>
Co-authored-by: Chi Wang <wang.chi@microsoft.com>
Co-authored-by: Qingyun Wu <qingyun0327@gmail.com>
Co-authored-by: Yishen Sun <freedeaths@FREEDEATHS-XPS>
Co-authored-by: freedeaths <register917@gmail.com>
  • Loading branch information
7 people authored Feb 6, 2024
1 parent feed806 commit c603ca4
Show file tree
Hide file tree
Showing 10 changed files with 772 additions and 775 deletions.
163 changes: 151 additions & 12 deletions autogen/agentchat/groupchat.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,27 @@
import random
import re
import sys
from dataclasses import dataclass
from dataclasses import dataclass, field
from typing import Dict, List, Optional, Union, Tuple


from ..code_utils import content_str
from .agent import Agent
from .conversable_agent import ConversableAgent
from ..graph_utils import check_graph_validity, invert_disallowed_to_allowed, has_self_loops


logger = logging.getLogger(__name__)


class NoEligibleSpeakerException(Exception):
"""Exception raised for early termination of a GroupChat."""

def __init__(self, message="No eligible speakers."):
self.message = message
super().__init__(self.message)


@dataclass
class GroupChat:
"""(In preview) A group chat class that contains the following data fields:
Expand All @@ -30,7 +41,10 @@ class GroupChat:
- "manual": the next speaker is selected manually by user input.
- "random": the next speaker is selected randomly.
- "round_robin": the next speaker is selected in a round robin fashion, i.e., iterating in the same order as provided in `agents`.
- allow_repeat_speaker: whether to allow the same speaker to speak consecutively. Default is True, in which case all speakers are allowed to speak consecutively. If allow_repeat_speaker is a list of Agents, then only those listed agents are allowed to repeat. If set to False, then no speakers are allowed to repeat.
- allow_repeat_speaker: whether to allow the same speaker to speak consecutively. Default is True, in which case all speakers are allowed to speak consecutively. If allow_repeat_speaker is a list of Agents, then only those listed agents are allowed to repeat. If set to False, then no speakers are allowed to repeat. allow_repeat_speaker and allowed_or_disallowed_speaker_transitions are mutually exclusive.
- allowed_or_disallowed_speaker_transitions: a dictionary of keys and list as values. The keys are the source agents, and the values are the agents that the key agent can transition to. Default is None, in which case a fully connected allowed_speaker_transitions_dict is assumed. allow_repeat_speaker and allowed_or_disallowed_speaker_transitions are mutually exclusive.
- speaker_transitions_type: whether the speaker_transitions_type is a dictionary containing lists of allowed agents or disallowed agents. allowed means the allowed_or_disallowed_speaker_transitions is a dictionary containing lists of allowed agents. If set to disallowed, then the allowed_or_disallowed_speaker_transitions is a dictionary containing lists of disallowed agents. Must be supplied if allowed_or_disallowed_speaker_transitions is not None.
- enable_clear_history: enable possibility to clear history of messages for agents manually by providing
"clear history" phrase in user prompt. This is experimental feature.
See description of GroupChatManager.clear_agents_history function for more info.
Expand All @@ -42,10 +56,95 @@ class GroupChat:
admin_name: Optional[str] = "Admin"
func_call_filter: Optional[bool] = True
speaker_selection_method: Optional[str] = "auto"
allow_repeat_speaker: Optional[Union[bool, List[Agent]]] = True
allow_repeat_speaker: Optional[
Union[bool, List[Agent]]
] = True # It would be set to True if allowed_or_disallowed_speaker_transitions is None
allowed_or_disallowed_speaker_transitions: Optional[Dict] = None
speaker_transitions_type: Optional[str] = None
enable_clear_history: Optional[bool] = False

_VALID_SPEAKER_SELECTION_METHODS = ["auto", "manual", "random", "round_robin"]
_VALID_SPEAKER_TRANSITIONS_TYPE = ["allowed", "disallowed", None]

allowed_speaker_transitions_dict: Dict = field(init=False)

def __post_init__(self):
# Post init steers clears of the automatically generated __init__ method from dataclass
# Here, we create allowed_speaker_transitions_dict from the supplied allowed_or_disallowed_speaker_transitions and is_allowed_graph, and lastly checks for validity.

# Check input
if self.speaker_transitions_type is not None:
self.speaker_transitions_type = self.speaker_transitions_type.lower()

assert self.speaker_transitions_type in self._VALID_SPEAKER_TRANSITIONS_TYPE, (
f"GroupChat speaker_transitions_type is set to '{self.speaker_transitions_type}'. "
f"It should be one of {self._VALID_SPEAKER_TRANSITIONS_TYPE} (case insensitive). "
)

# If both self.allowed_or_disallowed_speaker_transitions is None and self.allow_repeat_speaker is None, set allow_repeat_speaker to True to ensure backward compatibility
# Discussed in https://github.com/microsoft/autogen/pull/857#discussion_r1451541204
if self.allowed_or_disallowed_speaker_transitions is None and self.allow_repeat_speaker is None:
self.allow_repeat_speaker = True

# self.allowed_or_disallowed_speaker_transitions and self.allow_repeat_speaker are mutually exclusive parameters.
# Discussed in https://github.com/microsoft/autogen/pull/857#discussion_r1451266661
if self.allowed_or_disallowed_speaker_transitions is not None and self.allow_repeat_speaker is not None:
raise ValueError(
"Don't provide both allowed_or_disallowed_speaker_transitions and allow_repeat_speaker in group chat. "
"Please set one of them to None."
)

# Asks the user to specify whether the speaker_transitions_type is allowed or disallowed if speaker_transitions_type is supplied
# Discussed in https://github.com/microsoft/autogen/pull/857#discussion_r1451259524
if self.allowed_or_disallowed_speaker_transitions is not None and self.speaker_transitions_type is None:
raise ValueError(
"GroupChat allowed_or_disallowed_speaker_transitions is not None, but speaker_transitions_type is None. "
"Please set speaker_transitions_type to either 'allowed' or 'disallowed'."
)

# Inferring self.allowed_speaker_transitions_dict
# Create self.allowed_speaker_transitions_dict if allowed_or_disallowed_speaker_transitions is None, using allow_repeat_speaker
if self.allowed_or_disallowed_speaker_transitions is None:
self.allowed_speaker_transitions_dict = {}

# Create a fully connected allowed_speaker_transitions_dict not including self loops
for agent in self.agents:
self.allowed_speaker_transitions_dict[agent] = [
other_agent for other_agent in self.agents if other_agent != agent
]

# If self.allow_repeat_speaker is True, add self loops to all agents
if self.allow_repeat_speaker:
for agent in self.agents:
self.allowed_speaker_transitions_dict[agent].append(agent)

# Else if self.allow_repeat_speaker is a list of Agents, add self loops to the agents in the list
elif isinstance(self.allow_repeat_speaker, list):
for agent in self.allow_repeat_speaker:
self.allowed_speaker_transitions_dict[agent].append(agent)

# Create self.allowed_speaker_transitions_dict if allowed_or_disallowed_speaker_transitions is not None, using allowed_or_disallowed_speaker_transitions
else:
# Process based on is_allowed_graph
if self.speaker_transitions_type == "allowed":
self.allowed_speaker_transitions_dict = self.allowed_or_disallowed_speaker_transitions
else:
# Logic for processing disallowed allowed_or_disallowed_speaker_transitions to allowed_speaker_transitions_dict
self.allowed_speaker_transitions_dict = invert_disallowed_to_allowed(
self.allowed_or_disallowed_speaker_transitions, self.agents
)

# Inferring self.allow_repeat_speaker from allowed_speaker_transitions_dict using has_self_loops
# Finally, self.allow_repeat_speaker shouldn't be None, so it is set from the the graph.
if self.allow_repeat_speaker is None:
self.allow_repeat_speaker = has_self_loops(self.allowed_speaker_transitions_dict)

# Check for validity
check_graph_validity(
allowed_speaker_transitions_dict=self.allowed_speaker_transitions_dict,
agents=self.agents,
allow_repeat_speaker=self.allow_repeat_speaker,
)

@property
def agent_names(self) -> List[str]:
Expand Down Expand Up @@ -134,6 +233,12 @@ def manual_select_speaker(self, agents: Optional[List[Agent]] = None) -> Union[A
print(f"Invalid input. Please enter a number between 1 and {_n_agents}.")
return None

def random_select_speaker(self, agents: Optional[List[Agent]] = None) -> Union[Agent, None]:
"""Randomly select the next speaker."""
if agents is None:
agents = self.agents
return random.choice(agents)

def _prepare_and_select_agents(
self, last_speaker: Agent
) -> Tuple[Optional[Agent], List[Agent], Optional[List[Dict]]]:
Expand Down Expand Up @@ -198,13 +303,40 @@ def _prepare_and_select_agents(
# remove the last speaker from the list to avoid selecting the same speaker if allow_repeat_speaker is False
agents = agents if allow_repeat_speaker else [agent for agent in agents if agent != last_speaker]

# Filter agents with allowed_speaker_transitions_dict

is_last_speaker_in_group = last_speaker in self.agents

# this condition means last_speaker is a sink in the graph, then no agents are eligible
if last_speaker not in self.allowed_speaker_transitions_dict and is_last_speaker_in_group:
raise NoEligibleSpeakerException(
f"Last speaker {last_speaker.name} is not in the allowed_speaker_transitions_dict."
)
# last_speaker is not in the group, so all agents are eligible
elif last_speaker not in self.allowed_speaker_transitions_dict and not is_last_speaker_in_group:
graph_eligible_agents = []
else:
# Extract agent names from the list of agents
graph_eligible_agents = [
agent for agent in agents if agent in self.allowed_speaker_transitions_dict[last_speaker]
]

# If there is only one eligible agent, just return it to avoid the speaker selection prompt
if len(graph_eligible_agents) == 1:
return graph_eligible_agents[0], graph_eligible_agents, None

# If there are no eligible agents, return None, which means all agents will be taken into consideration in the next step
if len(graph_eligible_agents) == 0:
graph_eligible_agents = None

# Use the selected speaker selection method
select_speaker_messages = None
if self.speaker_selection_method.lower() == "manual":
selected_agent = self.manual_select_speaker(agents)
selected_agent = self.manual_select_speaker(graph_eligible_agents)
elif self.speaker_selection_method.lower() == "round_robin":
selected_agent = self.next_agent(last_speaker, agents)
selected_agent = self.next_agent(last_speaker, graph_eligible_agents)
elif self.speaker_selection_method.lower() == "random":
selected_agent = random.choice(agents)
selected_agent = self.random_select_speaker(graph_eligible_agents)
else:
selected_agent = None
select_speaker_messages = self.messages.copy()
Expand All @@ -214,11 +346,11 @@ def _prepare_and_select_agents(
if select_speaker_messages[-1].get("tool_calls", False):
select_speaker_messages[-1] = dict(select_speaker_messages[-1], tool_calls=None)
select_speaker_messages = select_speaker_messages + [
{"role": "system", "content": self.select_speaker_prompt(agents)}
{"role": "system", "content": self.select_speaker_prompt(graph_eligible_agents)}
]
return selected_agent, agents, select_speaker_messages
return selected_agent, graph_eligible_agents, select_speaker_messages

def select_speaker(self, last_speaker: Agent, selector: ConversableAgent):
def select_speaker(self, last_speaker: Agent, selector: ConversableAgent) -> Agent:
"""Select the next speaker."""
selected_agent, agents, messages = self._prepare_and_select_agents(last_speaker)
if selected_agent:
Expand All @@ -228,7 +360,7 @@ def select_speaker(self, last_speaker: Agent, selector: ConversableAgent):
final, name = selector.generate_oai_reply(messages)
return self._finalize_speaker(last_speaker, final, name, agents)

async def a_select_speaker(self, last_speaker: Agent, selector: ConversableAgent):
async def a_select_speaker(self, last_speaker: Agent, selector: ConversableAgent) -> Agent:
"""Select the next speaker."""
selected_agent, agents, messages = self._prepare_and_select_agents(last_speaker)
if selected_agent:
Expand All @@ -238,7 +370,7 @@ async def a_select_speaker(self, last_speaker: Agent, selector: ConversableAgent
final, name = await selector.a_generate_oai_reply(messages)
return self._finalize_speaker(last_speaker, final, name, agents)

def _finalize_speaker(self, last_speaker: Agent, final: bool, name: str, agents: List[Agent]) -> Agent:
def _finalize_speaker(self, last_speaker: Agent, final: bool, name: str, agents: Optional[List[Agent]]) -> Agent:
if not final:
# the LLM client is None, thus no reply is generated. Use round robin instead.
return self.next_agent(last_speaker, agents)
Expand Down Expand Up @@ -272,7 +404,7 @@ def _participant_roles(self, agents: List[Agent] = None) -> str:
roles.append(f"{agent.name}: {agent.description}".strip())
return "\n".join(roles)

def _mentioned_agents(self, message_content: Union[str, List], agents: List[Agent]) -> Dict:
def _mentioned_agents(self, message_content: Union[str, List], agents: Optional[List[Agent]]) -> Dict:
"""Counts the number of times each agent is mentioned in the provided message content.
Args:
Expand All @@ -282,6 +414,9 @@ def _mentioned_agents(self, message_content: Union[str, List], agents: List[Agen
Returns:
Dict: a counter for mentioned agents.
"""
if agents is None:
agents = self.agents

# Cast message content to str
if isinstance(message_content, dict):
message_content = message_content["content"]
Expand Down Expand Up @@ -387,6 +522,10 @@ def run_chat(
else:
# admin agent is not found in the participants
raise
except NoEligibleSpeakerException:
# No eligible speaker, terminate the conversation
break

if reply is None:
# no reply is generated, exit the chat
break
Expand Down
Loading

0 comments on commit c603ca4

Please sign in to comment.