diff --git a/autogen/agentchat/contrib/swarm_agent.py b/autogen/agentchat/contrib/swarm_agent.py index 93507529ee..4e084377c4 100644 --- a/autogen/agentchat/contrib/swarm_agent.py +++ b/autogen/agentchat/contrib/swarm_agent.py @@ -2,6 +2,7 @@ # # SPDX-License-Identifier: Apache-2.0 import copy +import inspect import json from dataclasses import dataclass from enum import Enum @@ -42,12 +43,18 @@ def __post_init__(self): @dataclass class ON_CONDITION: - agent: "SwarmAgent" + target: Union["SwarmAgent", Dict[str, Any]] = None condition: str = "" - # Ensure that agent is a SwarmAgent def __post_init__(self): - assert isinstance(self.agent, SwarmAgent), "Agent must be a SwarmAgent" + # Ensure valid types + if self.target is not None: + assert isinstance(self.target, SwarmAgent) or isinstance( + self.target, Dict + ), "'target' must be a SwarmAgent or a Dict" + + # Ensure they have a condition + assert isinstance(self.condition, str) and self.condition.strip(), "'condition' must be a non-empty string" def initiate_swarm_chat( @@ -96,18 +103,12 @@ def custom_afterwork_func(last_speaker: SwarmAgent, messages: List[Dict[str, Any if isinstance(messages, str): messages = [{"role": "user", "content": messages}] - swarm_agent_names = [agent.name for agent in agents] - tool_execution = SwarmAgent( name="Tool_Execution", system_message="Tool Execution", ) tool_execution._set_to_tool_execution(context_variables=context_variables) - # Update tool execution agent with all the functions from all the agents - for agent in agents: - tool_execution._function_map.update(agent._function_map) - INIT_AGENT_USED = False def swarm_transition(last_speaker: SwarmAgent, groupchat: GroupChat): @@ -173,6 +174,43 @@ def swarm_transition(last_speaker: SwarmAgent, groupchat: GroupChat): else: raise ValueError("Invalid After Work condition") + def create_nested_chats(agent: SwarmAgent, nested_chat_agents: List[SwarmAgent]): + """Create nested chat agents and register nested chats""" + for i, nested_chat_handoff in enumerate(agent._nested_chat_handoffs): + nested_chats: Dict[str, Any] = nested_chat_handoff["nested_chats"] + condition = nested_chat_handoff["condition"] + + # Create a nested chat agent specifically for this nested chat + nested_chat_agent = SwarmAgent(name=f"nested_chat_{agent.name}_{i + 1}") + + nested_chat_agent.register_nested_chats( + nested_chats["chat_queue"], + reply_func_from_nested_chats=nested_chats.get("reply_func_from_nested_chats") + or "summary_from_nested_chats", + config=nested_chats.get("config", None), + trigger=lambda sender: True, + position=0, + use_async=nested_chats.get("use_async", False), + ) + + # After the nested chat is complete, transfer back to the parent agent + nested_chat_agent.register_hand_off(AFTER_WORK(agent=agent)) + + nested_chat_agents.append(nested_chat_agent) + + # Nested chat is triggered through an agent transfer to this nested chat agent + agent.register_hand_off(ON_CONDITION(nested_chat_agent, condition)) + + nested_chat_agents = [] + for agent in agents: + create_nested_chats(agent, nested_chat_agents) + + # Update tool execution agent with all the functions from all the agents + for agent in agents + nested_chat_agents: + tool_execution._function_map.update(agent._function_map) + + swarm_agent_names = [agent.name for agent in agents + nested_chat_agents] + # If there's only one message and there's no identified swarm agent # Start with a user proxy agent, creating one if they haven't passed one in if len(messages) == 1 and "name" not in messages[0] and not user_agent: @@ -181,7 +219,10 @@ def swarm_transition(last_speaker: SwarmAgent, groupchat: GroupChat): temp_user_proxy = [] groupchat = GroupChat( - agents=[tool_execution] + agents + ([user_agent] if user_agent is not None else temp_user_proxy), + agents=[tool_execution] + + agents + + nested_chat_agents + + ([user_agent] if user_agent is not None else temp_user_proxy), messages=[], # Set to empty. We will resume the conversation with the messages max_round=max_rounds, speaker_selection_method=swarm_transition, @@ -294,10 +335,15 @@ def __init__( self.after_work = None - # use in the tool execution agent to transfer to the next agent + # Used only in the tool execution agent for context and transferring to the next agent + # Note: context variables are not stored for each agent self._context_variables = {} self._next_agent = None + # Store nested chats hand offs as we'll establish these in the initiate_swarm_chat + # List of Dictionaries containing the nested_chats and condition + self._nested_chat_handoffs = [] + def _set_to_tool_execution(self, context_variables: Optional[Dict[str, Any]] = None): """Set to a special instance of SwarmAgent that is responsible for executing tool calls from other swarm agents. This agent will be used internally and should not be visible to the user. @@ -342,16 +388,25 @@ def transfer_to_agent_name() -> SwarmAgent: self.after_work = transit elif isinstance(transit, ON_CONDITION): - # Create closure with current loop transit value - # to ensure the condition matches the one in the loop - def make_transfer_function(current_transit): - def transfer_to_agent() -> "SwarmAgent": - return current_transit.agent + if isinstance(transit.target, SwarmAgent): + # Transition to agent + + # Create closure with current loop transit value + # to ensure the condition matches the one in the loop + def make_transfer_function(current_transit: ON_CONDITION): + def transfer_to_agent() -> "SwarmAgent": + return current_transit.target + + return transfer_to_agent - return transfer_to_agent + transfer_func = make_transfer_function(transit) + self.add_single_function(transfer_func, f"transfer_to_{transit.target.name}", transit.condition) + + elif isinstance(transit.target, Dict): + # Transition to a nested chat + # We will store them here and establish them in the initiate_swarm_chat + self._nested_chat_handoffs.append({"nested_chats": transit.target, "condition": transit.condition}) - transfer_func = make_transfer_function(transit) - self.add_single_function(transfer_func, f"transfer_to_{transit.agent.name}", transit.condition) else: raise ValueError("Invalid hand off condition, must be either ON_CONDITION or AFTER_WORK") @@ -469,6 +524,127 @@ def add_functions(self, func_list: List[Callable]): for func in func_list: self.add_single_function(func) + @staticmethod + def process_nested_chat_carryover( + chat: Dict[str, Any], + recipient: ConversableAgent, + messages: List[Dict[str, Any]], + sender: ConversableAgent, + trim_n_messages: int = 0, + ) -> None: + """Process carryover messages for a nested chat (typically for the first chat of a swarm) + + The carryover_config key is a dictionary containing: + "summary_method": The method to use to summarise the messages, can be "all", "last_msg", "reflection_with_llm" or a Callable + "summary_args": Optional arguments for the summary method + + Supported carryover 'summary_methods' are: + "all" - all messages will be incorporated + "last_msg" - the last message will be incorporated + "reflection_with_llm" - an llm will summarise all the messages and the summary will be incorporated as a single message + Callable - a callable with the signature: my_method(agent: ConversableAgent, messages: List[Dict[str, Any]]) -> str + + Args: + chat: The chat dictionary containing the carryover configuration + recipient: The recipient agent + messages: The messages from the parent chat + sender: The sender agent + trim_n_messages: The number of latest messages to trim from the messages list + """ + + def concat_carryover(chat_message: str, carryover_message: Union[str, List[Dict[str, Any]]]) -> str: + """Concatenate the carryover message to the chat message.""" + prefix = f"{chat_message}\n" if chat_message else "" + + if isinstance(carryover_message, str): + content = carryover_message + elif isinstance(carryover_message, list): + content = "\n".join( + msg["content"] for msg in carryover_message if "content" in msg and msg["content"] is not None + ) + else: + raise ValueError("Carryover message must be a string or a list of dictionaries") + + return f"{prefix}Context:\n{content}" + + carryover_config = chat["carryover_config"] + + if "summary_method" not in carryover_config: + raise ValueError("Carryover configuration must contain a 'summary_method' key") + + carryover_summary_method = carryover_config["summary_method"] + carryover_summary_args = carryover_config.get("summary_args") or {} + + chat_message = chat.get("message", "") + + # deep copy and trim the latest messages + content_messages = copy.deepcopy(messages) + content_messages = content_messages[:-trim_n_messages] + + if carryover_summary_method == "all": + # Put a string concatenated value of all parent messages into the first message + # (e.g. message = \nContext: \n\n\n...) + carry_over_message = concat_carryover(chat_message, content_messages) + + elif carryover_summary_method == "last_msg": + # (e.g. message = \nContext: \n) + carry_over_message = concat_carryover(chat_message, content_messages[-1]["content"]) + + elif carryover_summary_method == "reflection_with_llm": + # (e.g. message = \nContext: \n) + + # Add the messages to the nested chat agent for reflection (we'll clear after reflection) + chat["recipient"]._oai_messages[sender] = content_messages + + carry_over_message_llm = ConversableAgent._reflection_with_llm_as_summary( + sender=sender, + recipient=chat["recipient"], # Chat recipient LLM config will be used for the reflection + summary_args=carryover_summary_args, + ) + + recipient._oai_messages[sender] = [] + + carry_over_message = concat_carryover(chat_message, carry_over_message_llm) + + elif isinstance(carryover_summary_method, Callable): + # (e.g. message = \nContext: \n) + carry_over_message_result = carryover_summary_method(recipient, content_messages, carryover_summary_args) + + carry_over_message = concat_carryover(chat_message, carry_over_message_result) + + chat["message"] = carry_over_message + + @staticmethod + def _summary_from_nested_chats( + chat_queue: List[Dict[str, Any]], recipient: Agent, messages: Union[str, Callable], sender: Agent, config: Any + ) -> Tuple[bool, Union[str, None]]: + """Overridden _summary_from_nested_chats method from ConversableAgent. + This function initiates one or a sequence of chats between the "recipient" and the agents in the chat_queue. + + It extracts and returns a summary from the nested chat based on the "summary_method" in each chat in chat_queue. + + Swarm Updates: + - the 'messages' parameter contains the parent chat's messages + - the first chat in the queue can contain a 'carryover_config' which is a dictionary that denotes how to carryover messages from the swarm chat into the first chat of the nested chats). Only applies to the first chat. + e.g.: carryover_summarize_chat_config = {"summary_method": "reflection_with_llm", "summary_args": None} + summary_method can be "last_msg", "all", "reflection_with_llm", Callable + The Callable signature: my_method(agent: ConversableAgent, messages: List[Dict[str, Any]]) -> str + The summary will be concatenated to the message of the first chat in the queue. + + Returns: + Tuple[bool, str]: A tuple where the first element indicates the completion of the chat, and the second element contains the summary of the last chat if any chats were initiated. + """ + + # Carryover configuration allowed on the first chat in the queue only, trim the last two messages specifically for swarm nested chat carryover as these are the messages for the transition to the nested chat agent + if len(chat_queue) > 0 and "carryover_config" in chat_queue[0]: + SwarmAgent.process_nested_chat_carryover(chat_queue[0], recipient, messages, sender, 2) + + chat_to_run = ConversableAgent._get_chats_to_run(chat_queue, recipient, messages, sender, config) + if not chat_to_run: + return True, None + res = sender.initiate_chats(chat_to_run) + return True, res[-1].summary + # Forward references for SwarmAgent in SwarmResult SwarmResult.update_forward_refs() diff --git a/test/agentchat/contrib/test_swarm.py b/test/agentchat/contrib/test_swarm.py index 828b6c837e..7f8bf43a7f 100644 --- a/test/agentchat/contrib/test_swarm.py +++ b/test/agentchat/contrib/test_swarm.py @@ -79,8 +79,8 @@ def test_on_condition(): # Test with a ConversableAgent test_conversable_agent = ConversableAgent("test_conversable_agent") - with pytest.raises(AssertionError, match="Agent must be a SwarmAgent"): - _ = ON_CONDITION(agent=test_conversable_agent, condition="test condition") + with pytest.raises(AssertionError, match="'target' must be a SwarmAgent or a Dict"): + _ = ON_CONDITION(target=test_conversable_agent, condition="test condition") def test_receiving_agent(): @@ -245,7 +245,7 @@ def test_on_condition_handoff(): agent1 = SwarmAgent("agent1", llm_config=testing_llm_config) agent2 = SwarmAgent("agent2", llm_config=testing_llm_config) - agent1.register_hand_off(hand_to=ON_CONDITION(agent2, "always take me to agent 2")) + agent1.register_hand_off(hand_to=ON_CONDITION(target=agent2, condition="always take me to agent 2")) # Fake generate_oai_reply def mock_generate_oai_reply(*args, **kwargs): @@ -428,8 +428,8 @@ def test_non_swarm_in_hand_off(): with pytest.raises(AssertionError, match="Invalid After Work value"): agent1.register_hand_off(hand_to=AFTER_WORK(bad_agent)) - with pytest.raises(AssertionError, match="Agent must be a SwarmAgent"): - agent1.register_hand_off(hand_to=ON_CONDITION(bad_agent, "Testing")) + with pytest.raises(AssertionError, match="'target' must be a SwarmAgent or a Dict"): + agent1.register_hand_off(hand_to=ON_CONDITION(target=bad_agent, condition="Testing")) with pytest.raises(ValueError, match="hand_to must be a list of ON_CONDITION or AFTER_WORK"): agent1.register_hand_off(0) diff --git a/website/docs/topics/swarm.ipynb b/website/docs/topics/swarm.ipynb index a445fb5db3..7e8263f0db 100644 --- a/website/docs/topics/swarm.ipynb +++ b/website/docs/topics/swarm.ipynb @@ -31,7 +31,7 @@ "- The docstring of the function will be used as the prompt. So make sure to write a clear description. \n", "- The function name will be used as the tool name.\n", "\n", - "### Registering Handoffs\n", + "### Registering Handoffs to agents\n", "While you can create a function to decide what next agent to call, we provide a quick way to register the handoff using `ON_CONDITION`. We will craft this transition function and add it to the LLM config directly.\n", "\n", "```python\n", @@ -55,6 +55,89 @@ "# You can also use agent_1.add_functions to add more functions after initialization\n", "```\n", "\n", + "### Registering Handoffs to a nested chat\n", + "In addition to transferring to an agent, you can also trigger a nested chat by doing a handoff and using `ON_CONDITION`. This is a useful way to perform sub-tasks without that work becoming part of the broader swarm's messages.\n", + "\n", + "Configuring the nested chat is similar to [establishing a nested chat for an agent](https://ag2ai.github.io/ag2/docs/tutorial/conversation-patterns#nested-chats).\n", + "\n", + "Nested chats are a set of sequential chats and these are defined like so:\n", + "```python\n", + "nested_chats = [\n", + " {\n", + " \"recipient\": my_first_agent,\n", + " \"summary_method\": \"reflection_with_llm\",\n", + " \"summary_prompt\": \"Summarize the conversation into bullet points.\",\n", + " },\n", + " {\n", + " \"recipient\": poetry_agent,\n", + " \"message\": \"Write a poem about the context.\",\n", + " \"max_turns\": 1,\n", + " \"summary_method\": \"last_msg\",\n", + " },\n", + "]\n", + "```\n", + "\n", + "New to nested chats within swarms is the ability to **carryover some context from the swarm chat into the nested chat**. This is done by adding a carryover configuration. If you're not using carryover, then no messages from the swarm chat will be brought into the nested chat.\n", + "\n", + "The carryover is applicable only to the first chat in the nested chats and works together with that nested chat's \"message\" value, if any.\n", + "\n", + "```python\n", + "my_carryover_config = {\n", + " \"summary_method\": \"reflection_with_llm\",\n", + " \"summary_args\": {\"summary_prompt\": \"Summarise the conversation into bullet points.\"}\n", + " }\n", + "```\n", + "\n", + "The `summary_method` can be (with messages referring to the swarm chat's messages): \n", + "\n", + "- `\"all\"` - messages will be converted to a new-line concatenated string, e.g. `[first nested chat message]\\nContext: \\n[swarm message 1]\\n[swarm message 2]\\n...`\n", + "- `\"last_msg\"` - the latest message will be added, e.g. `[first nested chat message]\\nContext: \\n[swarm's latest message]`\n", + "- `\"reflection_with_llm\"` - utilises an LLM to interpret the messages and its resulting response will be added, e.g. `[first nested chat message]\\nContext: \\n[llm response]`\n", + "- `Callable` - a function that returns the full message (this will not concatenate with the first nested chat's message, it will replace it entirely).\n", + "\n", + "The signature of the `summary_method` callable is: \n", + "`def my_method(agent: ConversableAgent, messages: List[Dict[str, Any]], summary_args: Dict) -> str:`\n", + "\n", + "Both the \"reflection_with_llm\" and Callable will be able to utilise the `summary_args` if they are included.\n", + "\n", + "With your configuration available, you can add it to the first chat in the nested chat:\n", + "```python\n", + "nested_chats = [\n", + " {\n", + " \"recipient\": my_first_agent,\n", + " \"summary_method\": \"reflection_with_llm\",\n", + " \"summary_prompt\": \"Summarize the conversation into bullet points.\",\n", + " \"carryover_config\": my_carryover_config,\n", + " },\n", + " {\n", + " \"recipient\": poetry_agent,\n", + " \"message\": \"Write a poem about the context.\",\n", + " \"max_turns\": 1,\n", + " \"summary_method\": \"last_msg\",\n", + " },\n", + "]\n", + "```\n", + "\n", + "Finally, we add the nested chat as a handoff in the same way as we do to an agent:\n", + "\n", + "```python\n", + "agent_1.handoff(\n", + " hand_to=[ON_CONDITION(\n", + " target={\n", + " \"chat_queue\":[nested_chats],\n", + " \"config\": Any,\n", + " \"reply_func_from_nested_chats\": None,\n", + " \"use_async\": False\n", + " },\n", + " condition=\"condition_1\")\n", + " ]\n", + " )\n", + "```\n", + "\n", + "See the documentation on [registering a nested chat](https://ag2ai.github.io/ag2/docs/reference/agentchat/conversable_agent#register_nested_chats) for further information on the parameters `reply_func_from_nested_chats`, `use_async`, and `config`.\n", + "\n", + "Once a nested chat is complete, the resulting output from the last chat in the nested chats will be returned as the agent that triggered the nested chat's response.\n", + "\n", "### AFTER_WORK\n", "\n", "When the last active agent's response doesn't suggest a tool call or handoff, the chat will terminate by default. However, you can register an `AFTER_WORK` handoff to define a fallback agent if you don't want the chat to end at this agent. At the swarm chat level, you also pass in an `AFTER_WORK` handoff to define the fallback mechanism for the entire chat.\n",