Skip to content

Commit c603ca4

Browse files
joshkyhthinkallBeibinLisonichiqingyun-wu
authored
Graph group chat (#857)
* 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 <[email protected]> * 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 <[email protected]> * 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 <[email protected]> * Update autogen/agentchat/groupchat.py Co-authored-by: Chi Wang <[email protected]> * Update autogen/agentchat/groupchat.py Co-authored-by: Chi Wang <[email protected]> * Update autogen/agentchat/groupchat.py Co-authored-by: Chi Wang <[email protected]> * 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 <[email protected]> Co-authored-by: Beibin Li <[email protected]> Co-authored-by: Chi Wang <[email protected]> Co-authored-by: Qingyun Wu <[email protected]> Co-authored-by: Yishen Sun <freedeaths@FREEDEATHS-XPS> Co-authored-by: freedeaths <[email protected]>
1 parent feed806 commit c603ca4

10 files changed

+772
-775
lines changed

autogen/agentchat/groupchat.py

+151-12
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,27 @@
22
import random
33
import re
44
import sys
5-
from dataclasses import dataclass
5+
from dataclasses import dataclass, field
66
from typing import Dict, List, Optional, Union, Tuple
77

8+
89
from ..code_utils import content_str
910
from .agent import Agent
1011
from .conversable_agent import ConversableAgent
12+
from ..graph_utils import check_graph_validity, invert_disallowed_to_allowed, has_self_loops
13+
1114

1215
logger = logging.getLogger(__name__)
1316

1417

18+
class NoEligibleSpeakerException(Exception):
19+
"""Exception raised for early termination of a GroupChat."""
20+
21+
def __init__(self, message="No eligible speakers."):
22+
self.message = message
23+
super().__init__(self.message)
24+
25+
1526
@dataclass
1627
class GroupChat:
1728
"""(In preview) A group chat class that contains the following data fields:
@@ -30,7 +41,10 @@ class GroupChat:
3041
- "manual": the next speaker is selected manually by user input.
3142
- "random": the next speaker is selected randomly.
3243
- "round_robin": the next speaker is selected in a round robin fashion, i.e., iterating in the same order as provided in `agents`.
33-
- 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.
44+
45+
- 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.
46+
- 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.
47+
- 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.
3448
- enable_clear_history: enable possibility to clear history of messages for agents manually by providing
3549
"clear history" phrase in user prompt. This is experimental feature.
3650
See description of GroupChatManager.clear_agents_history function for more info.
@@ -42,10 +56,95 @@ class GroupChat:
4256
admin_name: Optional[str] = "Admin"
4357
func_call_filter: Optional[bool] = True
4458
speaker_selection_method: Optional[str] = "auto"
45-
allow_repeat_speaker: Optional[Union[bool, List[Agent]]] = True
59+
allow_repeat_speaker: Optional[
60+
Union[bool, List[Agent]]
61+
] = True # It would be set to True if allowed_or_disallowed_speaker_transitions is None
62+
allowed_or_disallowed_speaker_transitions: Optional[Dict] = None
63+
speaker_transitions_type: Optional[str] = None
4664
enable_clear_history: Optional[bool] = False
4765

4866
_VALID_SPEAKER_SELECTION_METHODS = ["auto", "manual", "random", "round_robin"]
67+
_VALID_SPEAKER_TRANSITIONS_TYPE = ["allowed", "disallowed", None]
68+
69+
allowed_speaker_transitions_dict: Dict = field(init=False)
70+
71+
def __post_init__(self):
72+
# Post init steers clears of the automatically generated __init__ method from dataclass
73+
# Here, we create allowed_speaker_transitions_dict from the supplied allowed_or_disallowed_speaker_transitions and is_allowed_graph, and lastly checks for validity.
74+
75+
# Check input
76+
if self.speaker_transitions_type is not None:
77+
self.speaker_transitions_type = self.speaker_transitions_type.lower()
78+
79+
assert self.speaker_transitions_type in self._VALID_SPEAKER_TRANSITIONS_TYPE, (
80+
f"GroupChat speaker_transitions_type is set to '{self.speaker_transitions_type}'. "
81+
f"It should be one of {self._VALID_SPEAKER_TRANSITIONS_TYPE} (case insensitive). "
82+
)
83+
84+
# 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
85+
# Discussed in https://github.com/microsoft/autogen/pull/857#discussion_r1451541204
86+
if self.allowed_or_disallowed_speaker_transitions is None and self.allow_repeat_speaker is None:
87+
self.allow_repeat_speaker = True
88+
89+
# self.allowed_or_disallowed_speaker_transitions and self.allow_repeat_speaker are mutually exclusive parameters.
90+
# Discussed in https://github.com/microsoft/autogen/pull/857#discussion_r1451266661
91+
if self.allowed_or_disallowed_speaker_transitions is not None and self.allow_repeat_speaker is not None:
92+
raise ValueError(
93+
"Don't provide both allowed_or_disallowed_speaker_transitions and allow_repeat_speaker in group chat. "
94+
"Please set one of them to None."
95+
)
96+
97+
# Asks the user to specify whether the speaker_transitions_type is allowed or disallowed if speaker_transitions_type is supplied
98+
# Discussed in https://github.com/microsoft/autogen/pull/857#discussion_r1451259524
99+
if self.allowed_or_disallowed_speaker_transitions is not None and self.speaker_transitions_type is None:
100+
raise ValueError(
101+
"GroupChat allowed_or_disallowed_speaker_transitions is not None, but speaker_transitions_type is None. "
102+
"Please set speaker_transitions_type to either 'allowed' or 'disallowed'."
103+
)
104+
105+
# Inferring self.allowed_speaker_transitions_dict
106+
# Create self.allowed_speaker_transitions_dict if allowed_or_disallowed_speaker_transitions is None, using allow_repeat_speaker
107+
if self.allowed_or_disallowed_speaker_transitions is None:
108+
self.allowed_speaker_transitions_dict = {}
109+
110+
# Create a fully connected allowed_speaker_transitions_dict not including self loops
111+
for agent in self.agents:
112+
self.allowed_speaker_transitions_dict[agent] = [
113+
other_agent for other_agent in self.agents if other_agent != agent
114+
]
115+
116+
# If self.allow_repeat_speaker is True, add self loops to all agents
117+
if self.allow_repeat_speaker:
118+
for agent in self.agents:
119+
self.allowed_speaker_transitions_dict[agent].append(agent)
120+
121+
# Else if self.allow_repeat_speaker is a list of Agents, add self loops to the agents in the list
122+
elif isinstance(self.allow_repeat_speaker, list):
123+
for agent in self.allow_repeat_speaker:
124+
self.allowed_speaker_transitions_dict[agent].append(agent)
125+
126+
# Create self.allowed_speaker_transitions_dict if allowed_or_disallowed_speaker_transitions is not None, using allowed_or_disallowed_speaker_transitions
127+
else:
128+
# Process based on is_allowed_graph
129+
if self.speaker_transitions_type == "allowed":
130+
self.allowed_speaker_transitions_dict = self.allowed_or_disallowed_speaker_transitions
131+
else:
132+
# Logic for processing disallowed allowed_or_disallowed_speaker_transitions to allowed_speaker_transitions_dict
133+
self.allowed_speaker_transitions_dict = invert_disallowed_to_allowed(
134+
self.allowed_or_disallowed_speaker_transitions, self.agents
135+
)
136+
137+
# Inferring self.allow_repeat_speaker from allowed_speaker_transitions_dict using has_self_loops
138+
# Finally, self.allow_repeat_speaker shouldn't be None, so it is set from the the graph.
139+
if self.allow_repeat_speaker is None:
140+
self.allow_repeat_speaker = has_self_loops(self.allowed_speaker_transitions_dict)
141+
142+
# Check for validity
143+
check_graph_validity(
144+
allowed_speaker_transitions_dict=self.allowed_speaker_transitions_dict,
145+
agents=self.agents,
146+
allow_repeat_speaker=self.allow_repeat_speaker,
147+
)
49148

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

236+
def random_select_speaker(self, agents: Optional[List[Agent]] = None) -> Union[Agent, None]:
237+
"""Randomly select the next speaker."""
238+
if agents is None:
239+
agents = self.agents
240+
return random.choice(agents)
241+
137242
def _prepare_and_select_agents(
138243
self, last_speaker: Agent
139244
) -> Tuple[Optional[Agent], List[Agent], Optional[List[Dict]]]:
@@ -198,13 +303,40 @@ def _prepare_and_select_agents(
198303
# remove the last speaker from the list to avoid selecting the same speaker if allow_repeat_speaker is False
199304
agents = agents if allow_repeat_speaker else [agent for agent in agents if agent != last_speaker]
200305

306+
# Filter agents with allowed_speaker_transitions_dict
307+
308+
is_last_speaker_in_group = last_speaker in self.agents
309+
310+
# this condition means last_speaker is a sink in the graph, then no agents are eligible
311+
if last_speaker not in self.allowed_speaker_transitions_dict and is_last_speaker_in_group:
312+
raise NoEligibleSpeakerException(
313+
f"Last speaker {last_speaker.name} is not in the allowed_speaker_transitions_dict."
314+
)
315+
# last_speaker is not in the group, so all agents are eligible
316+
elif last_speaker not in self.allowed_speaker_transitions_dict and not is_last_speaker_in_group:
317+
graph_eligible_agents = []
318+
else:
319+
# Extract agent names from the list of agents
320+
graph_eligible_agents = [
321+
agent for agent in agents if agent in self.allowed_speaker_transitions_dict[last_speaker]
322+
]
323+
324+
# If there is only one eligible agent, just return it to avoid the speaker selection prompt
325+
if len(graph_eligible_agents) == 1:
326+
return graph_eligible_agents[0], graph_eligible_agents, None
327+
328+
# If there are no eligible agents, return None, which means all agents will be taken into consideration in the next step
329+
if len(graph_eligible_agents) == 0:
330+
graph_eligible_agents = None
331+
332+
# Use the selected speaker selection method
201333
select_speaker_messages = None
202334
if self.speaker_selection_method.lower() == "manual":
203-
selected_agent = self.manual_select_speaker(agents)
335+
selected_agent = self.manual_select_speaker(graph_eligible_agents)
204336
elif self.speaker_selection_method.lower() == "round_robin":
205-
selected_agent = self.next_agent(last_speaker, agents)
337+
selected_agent = self.next_agent(last_speaker, graph_eligible_agents)
206338
elif self.speaker_selection_method.lower() == "random":
207-
selected_agent = random.choice(agents)
339+
selected_agent = self.random_select_speaker(graph_eligible_agents)
208340
else:
209341
selected_agent = None
210342
select_speaker_messages = self.messages.copy()
@@ -214,11 +346,11 @@ def _prepare_and_select_agents(
214346
if select_speaker_messages[-1].get("tool_calls", False):
215347
select_speaker_messages[-1] = dict(select_speaker_messages[-1], tool_calls=None)
216348
select_speaker_messages = select_speaker_messages + [
217-
{"role": "system", "content": self.select_speaker_prompt(agents)}
349+
{"role": "system", "content": self.select_speaker_prompt(graph_eligible_agents)}
218350
]
219-
return selected_agent, agents, select_speaker_messages
351+
return selected_agent, graph_eligible_agents, select_speaker_messages
220352

221-
def select_speaker(self, last_speaker: Agent, selector: ConversableAgent):
353+
def select_speaker(self, last_speaker: Agent, selector: ConversableAgent) -> Agent:
222354
"""Select the next speaker."""
223355
selected_agent, agents, messages = self._prepare_and_select_agents(last_speaker)
224356
if selected_agent:
@@ -228,7 +360,7 @@ def select_speaker(self, last_speaker: Agent, selector: ConversableAgent):
228360
final, name = selector.generate_oai_reply(messages)
229361
return self._finalize_speaker(last_speaker, final, name, agents)
230362

231-
async def a_select_speaker(self, last_speaker: Agent, selector: ConversableAgent):
363+
async def a_select_speaker(self, last_speaker: Agent, selector: ConversableAgent) -> Agent:
232364
"""Select the next speaker."""
233365
selected_agent, agents, messages = self._prepare_and_select_agents(last_speaker)
234366
if selected_agent:
@@ -238,7 +370,7 @@ async def a_select_speaker(self, last_speaker: Agent, selector: ConversableAgent
238370
final, name = await selector.a_generate_oai_reply(messages)
239371
return self._finalize_speaker(last_speaker, final, name, agents)
240372

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

275-
def _mentioned_agents(self, message_content: Union[str, List], agents: List[Agent]) -> Dict:
407+
def _mentioned_agents(self, message_content: Union[str, List], agents: Optional[List[Agent]]) -> Dict:
276408
"""Counts the number of times each agent is mentioned in the provided message content.
277409
278410
Args:
@@ -282,6 +414,9 @@ def _mentioned_agents(self, message_content: Union[str, List], agents: List[Agen
282414
Returns:
283415
Dict: a counter for mentioned agents.
284416
"""
417+
if agents is None:
418+
agents = self.agents
419+
285420
# Cast message content to str
286421
if isinstance(message_content, dict):
287422
message_content = message_content["content"]
@@ -387,6 +522,10 @@ def run_chat(
387522
else:
388523
# admin agent is not found in the participants
389524
raise
525+
except NoEligibleSpeakerException:
526+
# No eligible speaker, terminate the conversation
527+
break
528+
390529
if reply is None:
391530
# no reply is generated, exit the chat
392531
break

0 commit comments

Comments
 (0)