diff --git a/autogen/agentchat/conversable_agent.py b/autogen/agentchat/conversable_agent.py index e8b68fbc8ce..c842c993a47 100644 --- a/autogen/agentchat/conversable_agent.py +++ b/autogen/agentchat/conversable_agent.py @@ -4,6 +4,7 @@ import inspect import json import logging +import re from collections import defaultdict from typing import Any, Awaitable, Callable, Dict, List, Literal, Optional, Tuple, Type, TypeVar, Union @@ -80,7 +81,7 @@ def __init__( the number of auto reply reaches the max_consecutive_auto_reply. (3) When "NEVER", the agent will never prompt for human input. Under this mode, the conversation stops when the number of auto reply reaches the max_consecutive_auto_reply or when is_termination_msg is True. - function_map (dict[str, callable]): Mapping function names (passed to openai) to callable functions. + function_map (dict[str, callable]): Mapping function names (passed to openai) to callable functions, also used for tool calls. code_execution_config (dict or False): config for the code execution. To disable code execution, set to False. Otherwise, set to a dictionary with the following keys: - work_dir (Optional, str): The working directory for the code execution. @@ -133,13 +134,19 @@ def __init__( ) self._consecutive_auto_reply_counter = defaultdict(int) self._max_consecutive_auto_reply_dict = defaultdict(self.max_consecutive_auto_reply) - self._function_map = {} if function_map is None else function_map + self._function_map = ( + {} + if function_map is None + else {name: callable for name, callable in function_map.items() if self._assert_valid_name(name)} + ) self._default_auto_reply = default_auto_reply self._reply_func_list = [] self.reply_at_receive = defaultdict(bool) self.register_reply([Agent, None], ConversableAgent.generate_oai_reply) self.register_reply([Agent, None], ConversableAgent.a_generate_oai_reply) self.register_reply([Agent, None], ConversableAgent.generate_code_execution_reply) + self.register_reply([Agent, None], ConversableAgent.generate_tool_calls_reply) + self.register_reply([Agent, None], ConversableAgent.a_generate_tool_calls_reply) self.register_reply([Agent, None], ConversableAgent.generate_function_call_reply) self.register_reply([Agent, None], ConversableAgent.a_generate_function_call_reply) self.register_reply([Agent, None], ConversableAgent.check_termination_and_human_reply) @@ -280,13 +287,35 @@ def _message_to_dict(message: Union[Dict, str]) -> Dict: else: return dict(message) + @staticmethod + def _normalize_name(name): + """ + LLMs sometimes ask functions while ignoring their own format requirements, this function should be used to replace invalid characters with "_". + + Prefer _assert_valid_name for validating user configuration or input + """ + return re.sub(r"[^a-zA-Z0-9_-]", "_", name)[:64] + + @staticmethod + def _assert_valid_name(name): + """ + Ensure that configured names are valid, raises ValueError if not. + + For munging LLM responses use _normalize_name to ensure LLM specified names don't break the API. + """ + if not re.match(r"^[a-zA-Z0-9_-]+$", name): + raise ValueError(f"Invalid name: {name}. Only letters, numbers, '_' and '-' are allowed.") + if len(name) > 64: + raise ValueError(f"Invalid name: {name}. Name must be less than 64 characters.") + return name + def _append_oai_message(self, message: Union[Dict, str], role, conversation_id: Agent) -> bool: """Append a message to the ChatCompletion conversation. If the message received is a string, it will be put in the "content" field of the new dictionary. - If the message received is a dictionary but does not have any of the two fields "content" or "function_call", + If the message received is a dictionary but does not have any of the three fields "content", "function_call", or "tool_calls", this message is not a valid ChatCompletion message. - If only "function_call" is provided, "content" will be set to None if not provided, and the role of the message will be forced "assistant". + If only "function_call" or "tool_calls" is provided, "content" will be set to None if not provided, and the role of the message will be forced "assistant". Args: message (dict or str): message to be appended to the ChatCompletion conversation. @@ -298,17 +327,24 @@ def _append_oai_message(self, message: Union[Dict, str], role, conversation_id: """ message = self._message_to_dict(message) # create oai message to be appended to the oai conversation that can be passed to oai directly. - oai_message = {k: message[k] for k in ("content", "function_call", "name", "context") if k in message} + oai_message = { + k: message[k] + for k in ("content", "function_call", "tool_calls", "tool_responses", "tool_call_id", "name", "context") + if k in message and message[k] is not None + } if "content" not in oai_message: - if "function_call" in oai_message: + if "function_call" in oai_message or "tool_calls" in oai_message: oai_message["content"] = None # if only function_call is provided, content will be set to None. else: return False - oai_message["role"] = "function" if message.get("role") == "function" else role - if "function_call" in oai_message: + if message.get("role") in ["function", "tool"]: + oai_message["role"] = message.get("role") + else: + oai_message["role"] = role + + if oai_message.get("function_call", False) or oai_message.get("tool_calls", False): oai_message["role"] = "assistant" # only messages with role 'assistant' can have a function call. - oai_message["function_call"] = dict(oai_message["function_call"]) self._oai_messages[conversation_id].append(oai_message) return True @@ -415,8 +451,14 @@ def _print_received_message(self, message: Union[Dict, str], sender: Agent): print(colored(sender.name, "yellow"), "(to", f"{self.name}):\n", flush=True) message = self._message_to_dict(message) - if message.get("role") == "function": - func_print = f"***** Response from calling function \"{message['name']}\" *****" + if message.get("tool_responses"): # Handle tool multi-call responses + for tool_response in message["tool_responses"]: + self._print_received_message(tool_response, sender) + if message.get("role") == "tool": + return # If role is tool, then content is just a concatenation of all tool_responses + + if message.get("role") in ["function", "tool"]: + func_print = f"***** Response from calling {message['role']} \"{message['name']}\" *****" print(colored(func_print, "green"), flush=True) print(message["content"], flush=True) print(colored("*" * len(func_print), "green"), flush=True) @@ -430,7 +472,7 @@ def _print_received_message(self, message: Union[Dict, str], sender: Agent): self.llm_config and self.llm_config.get("allow_format_str_template", False), ) print(content_str(content), flush=True) - if "function_call" in message: + if "function_call" in message and message["function_call"]: function_call = dict(message["function_call"]) func_print = ( f"***** Suggested function Call: {function_call.get('name', '(No function name found)')} *****" @@ -443,10 +485,23 @@ def _print_received_message(self, message: Union[Dict, str], sender: Agent): sep="", ) print(colored("*" * len(func_print), "green"), flush=True) + if "tool_calls" in message and message["tool_calls"]: + for tool_call in message["tool_calls"]: + id = tool_call.get("id", "(No id found)") + function_call = dict(tool_call.get("function", {})) + func_print = f"***** Suggested tool Call ({id}): {function_call.get('name', '(No function name found)')} *****" + print(colored(func_print, "green"), flush=True) + print( + "Arguments: \n", + function_call.get("arguments", "(No arguments found)"), + flush=True, + sep="", + ) + print(colored("*" * len(func_print), "green"), flush=True) + print("\n", "-" * 80, flush=True, sep="") def _process_received_message(self, message: Union[Dict, str], sender: Agent, silent: bool): - message = self._message_to_dict(message) # When the agent receives a message, the role of the message is "user". (If 'role' exists and is 'function', it will remain unchanged.) valid = self._append_oai_message(message, "user", sender) if not valid: @@ -471,11 +526,12 @@ def receive( Args: message (dict or str): message from the sender. If the type is dict, it may contain the following reserved fields (either content or function_call need to be provided). 1. "content": content of the message, can be None. - 2. "function_call": a dictionary containing the function name and arguments. - 3. "role": role of the message, can be "assistant", "user", "function". + 2. "function_call": a dictionary containing the function name and arguments. (deprecated in favor of "tool_calls") + 3. "tool_calls": a list of dictionaries containing the function name and arguments. + 4. "role": role of the message, can be "assistant", "user", "function", "tool". This field is only needed to distinguish between "function" or "assistant"/"user". - 4. "name": In most cases, this field is not needed. When the role is "function", this field is needed to indicate the function name. - 5. "context" (dict): the context of the message, which will be passed to + 5. "name": In most cases, this field is not needed. When the role is "function", this field is needed to indicate the function name. + 6. "context" (dict): the context of the message, which will be passed to [OpenAIWrapper.create](../oai/client#create). sender: sender of an Agent instance. request_reply (bool or None): whether a reply is requested from the sender. @@ -507,11 +563,12 @@ async def a_receive( Args: message (dict or str): message from the sender. If the type is dict, it may contain the following reserved fields (either content or function_call need to be provided). 1. "content": content of the message, can be None. - 2. "function_call": a dictionary containing the function name and arguments. - 3. "role": role of the message, can be "assistant", "user", "function". + 2. "function_call": a dictionary containing the function name and arguments. (deprecated in favor of "tool_calls") + 3. "tool_calls": a list of dictionaries containing the function name and arguments. + 4. "role": role of the message, can be "assistant", "user", "function". This field is only needed to distinguish between "function" or "assistant"/"user". - 4. "name": In most cases, this field is not needed. When the role is "function", this field is needed to indicate the function name. - 5. "context" (dict): the context of the message, which will be passed to + 5. "name": In most cases, this field is not needed. When the role is "function", this field is needed to indicate the function name. + 6. "context" (dict): the context of the message, which will be passed to [OpenAIWrapper.create](../oai/client#create). sender: sender of an Agent instance. request_reply (bool or None): whether a reply is requested from the sender. @@ -631,15 +688,35 @@ def generate_oai_reply( if messages is None: messages = self._oai_messages[sender] + # unroll tool_responses + all_messages = [] + for message in messages: + tool_responses = message.get("tool_responses", []) + if tool_responses: + all_messages += tool_responses + # tool role on the parent message means the content is just concatentation of all of the tool_responses + if message.get("role") != "tool": + all_messages.append({key: message[key] for key in message if key != "tool_responses"}) + else: + all_messages.append(message) + # TODO: #1143 handle token limit exceeded error response = client.create( - context=messages[-1].pop("context", None), messages=self._oai_system_message + messages + context=messages[-1].pop("context", None), messages=self._oai_system_message + all_messages ) - # TODO: line 301, line 271 is converting messages to dict. Can be removed after ChatCompletionMessage_to_dict is merged. extracted_response = client.extract_text_or_completion_object(response)[0] + + # ensure function and tool calls will be accepted when sent back to the LLM if not isinstance(extracted_response, str): extracted_response = model_dump(extracted_response) + if isinstance(extracted_response, dict): + if extracted_response.get("function_call"): + extracted_response["function_call"]["name"] = self._normalize_name( + extracted_response["function_call"]["name"] + ) + for tool_call in extracted_response.get("tool_calls") or []: + tool_call["function"]["name"] = self._normalize_name(tool_call["function"]["name"]) return True, extracted_response async def a_generate_oai_reply( @@ -708,13 +785,23 @@ def generate_function_call_reply( sender: Optional[Agent] = None, config: Optional[Any] = None, ) -> Tuple[bool, Union[Dict, None]]: - """Generate a reply using function call.""" + """ + Generate a reply using function call. + + "function_call" replaced by "tool_calls" as of [OpenAI API v1.1.0](https://github.com/openai/openai-python/releases/tag/v1.1.0) + See https://platform.openai.com/docs/api-reference/chat/create#chat-create-functions + """ if config is None: config = self if messages is None: messages = self._oai_messages[sender] message = messages[-1] - if "function_call" in message: + if "function_call" in message and message["function_call"]: + func_call = message["function_call"] + func = self._function_map.get(func_call.get("name", None), None) + if asyncio.coroutines.iscoroutinefunction(func): + return False, None + _, func_return = self.execute_function(message["function_call"]) return True, func_return return False, None @@ -725,7 +812,12 @@ async def a_generate_function_call_reply( sender: Optional[Agent] = None, config: Optional[Any] = None, ) -> Tuple[bool, Union[Dict, None]]: - """Generate a reply using async function call.""" + """ + Generate a reply using async function call. + + "function_call" replaced by "tool_calls" as of [OpenAI API v1.1.0](https://github.com/openai/openai-python/releases/tag/v1.1.0) + See https://platform.openai.com/docs/api-reference/chat/create#chat-create-functions + """ if config is None: config = self if messages is None: @@ -741,6 +833,89 @@ async def a_generate_function_call_reply( return False, None + def _str_for_tool_response(self, tool_response): + func_name = tool_response.get("name", "") + func_id = tool_response.get("tool_call_id", "") + response = tool_response.get("content", "") + return f"Tool call: {func_name}\nId: {func_id}\n{response}" + + def generate_tool_calls_reply( + self, + messages: Optional[List[Dict]] = None, + sender: Optional[Agent] = None, + config: Optional[Any] = None, + ) -> Tuple[bool, Union[Dict, None]]: + """Generate a reply using tool call.""" + if config is None: + config = self + if messages is None: + messages = self._oai_messages[sender] + message = messages[-1] + if "tool_calls" in message and message["tool_calls"]: + tool_calls = message["tool_calls"] + tool_returns = [] + for tool_call in tool_calls: + id = tool_call["id"] + function_call = tool_call.get("function", {}) + func = self._function_map.get(function_call.get("name", None), None) + if asyncio.coroutines.iscoroutinefunction(func): + continue + _, func_return = self.execute_function(function_call) + tool_returns.append( + { + "tool_call_id": id, + "role": "tool", + "name": func_return.get("name", ""), + "content": func_return.get("content", ""), + } + ) + return True, { + "role": "tool", + "tool_responses": tool_returns, + "content": "\n\n".join([self._str_for_tool_response(tool_return) for tool_return in tool_returns]), + } + return False, None + + async def _a_execute_tool_call(self, tool_call): + id = tool_call["id"] + function_call = tool_call.get("function", {}) + _, func_return = await self.a_execute_function(function_call) + return { + "tool_call_id": id, + "role": "tool", + "name": func_return.get("name", ""), + "content": func_return.get("content", ""), + } + + async def a_generate_tool_calls_reply( + self, + messages: Optional[List[Dict]] = None, + sender: Optional[Agent] = None, + config: Optional[Any] = None, + ) -> Tuple[bool, Union[Dict, None]]: + """Generate a reply using async function call.""" + if config is None: + config = self + if messages is None: + messages = self._oai_messages[sender] + message = messages[-1] + async_tool_calls = [] + for tool_call in message.get("tool_calls", []): + func = self._function_map.get(tool_call.get("function", {}).get("name", None), None) + if func and asyncio.coroutines.iscoroutinefunction(func): + async_tool_calls.append(self._a_execute_tool_call(tool_call)) + if len(async_tool_calls) > 0: + tool_returns = await asyncio.gather(*async_tool_calls) + return True, { + "role": "tool", + "tool_responses": tool_returns, + "content": "\n\n".join( + [self._str_for_tool_response(tool_return["content"]) for tool_return in tool_returns] + ), + } + + return False, None + def check_termination_and_human_reply( self, messages: Optional[List[Dict]] = None, @@ -821,7 +996,28 @@ def check_termination_and_human_reply( if reply or self._max_consecutive_auto_reply_dict[sender] == 0: # reset the consecutive_auto_reply_counter self._consecutive_auto_reply_counter[sender] = 0 - return True, reply + # User provided a custom response, return function and tool failures indicating user interruption + tool_returns = [] + if message.get("function_call", False): + tool_returns.append( + { + "role": "function", + "name": message["function_call"].get("name", ""), + "content": "USER INTERRUPTED", + } + ) + + if message.get("tool_calls", False): + tool_returns.extend( + [ + {"role": "tool", "tool_call_id": tool_call.get("id", ""), "content": "USER INTERRUPTED"} + for tool_call in message["tool_calls"] + ] + ) + + response = {"role": "user", "content": reply, "tool_responses": tool_returns} + + return True, response # increment the consecutive_auto_reply_counter self._consecutive_auto_reply_counter[sender] += 1 @@ -906,9 +1102,29 @@ async def a_check_termination_and_human_reply( # send the human reply if reply or self._max_consecutive_auto_reply_dict[sender] == 0: + # User provided a custom response, return function and tool results indicating user interruption # reset the consecutive_auto_reply_counter self._consecutive_auto_reply_counter[sender] = 0 - return True, reply + tool_returns = [] + if message.get("function_call", False): + tool_returns.append( + { + "role": "function", + "name": message["function_call"].get("name", ""), + "content": "USER INTERRUPTED", + } + ) + + if message.get("tool_calls", False): + tool_returns.extend( + [ + {"role": "tool", "tool_call_id": tool_call.get("id", ""), "content": "USER INTERRUPTED"} + for tool_call in message["tool_calls"] + ] + ) + + response = {"role": "user", "content": reply, "tool_responses": tool_returns} + return True, response # increment the consecutive_auto_reply_counter self._consecutive_auto_reply_counter[sender] += 1 @@ -930,9 +1146,10 @@ def generate_reply( Use registered auto reply functions to generate replies. By default, the following functions are checked in order: 1. check_termination_and_human_reply - 2. generate_function_call_reply - 3. generate_code_execution_reply - 4. generate_oai_reply + 2. generate_function_call_reply (deprecated in favor of tool_calls) + 3. generate_tool_calls_reply + 4. generate_code_execution_reply + 5. generate_oai_reply Every function returns a tuple (final, reply). When a function returns final=False, the next function will be checked. So by default, termination and human reply will be checked first. @@ -982,8 +1199,9 @@ async def a_generate_reply( By default, the following functions are checked in order: 1. check_termination_and_human_reply 2. generate_function_call_reply - 3. generate_code_execution_reply - 4. generate_oai_reply + 3. generate_tool_calls_reply + 4. generate_code_execution_reply + 5. generate_oai_reply Every function returns a tuple (final, reply). When a function returns final=False, the next function will be checked. So by default, termination and human reply will be checked first. @@ -1173,15 +1391,18 @@ def _format_json_str(jstr): def execute_function(self, func_call, verbose: bool = False) -> Tuple[bool, Dict[str, str]]: """Execute a function call and return the result. - Override this function to modify the way to execute a function call. + Override this function to modify the way to execute function and tool calls. Args: - func_call: a dictionary extracted from openai message at key "function_call" with keys "name" and "arguments". + func_call: a dictionary extracted from openai message at "function_call" or "tool_calls" with keys "name" and "arguments". Returns: A tuple of (is_exec_success, result_dict). is_exec_success (boolean): whether the execution is successful. result_dict: a dictionary with keys "name", "role", and "content". Value of "role" is "function". + + "function_call" deprecated as of [OpenAI API v1.1.0](https://github.com/openai/openai-python/releases/tag/v1.1.0) + See https://platform.openai.com/docs/api-reference/chat/create#chat-create-function_call """ func_name = func_call.get("name", "") func = self._function_map.get(func_name, None) @@ -1225,15 +1446,18 @@ def execute_function(self, func_call, verbose: bool = False) -> Tuple[bool, Dict async def a_execute_function(self, func_call): """Execute an async function call and return the result. - Override this function to modify the way async functions are executed. + Override this function to modify the way async functions and tools are executed. Args: - func_call: a dictionary extracted from openai message at key "function_call" with keys "name" and "arguments". + func_call: a dictionary extracted from openai message at key "function_call" or "tool_calls" with keys "name" and "arguments". Returns: A tuple of (is_exec_success, result_dict). is_exec_success (boolean): whether the execution is successful. result_dict: a dictionary with keys "name", "role", and "content". Value of "role" is "function". + + "function_call" deprecated as of [OpenAI API v1.1.0](https://github.com/openai/openai-python/releases/tag/v1.1.0) + See https://platform.openai.com/docs/api-reference/chat/create#chat-create-function_call """ func_name = func_call.get("name", "") func = self._function_map.get(func_name, None) @@ -1289,6 +1513,8 @@ def register_function(self, function_map: Dict[str, Callable]): Args: function_map: a dictionary mapping function names to functions. """ + for name in function_map.keys(): + self._assert_valid_name(name) self._function_map.update(function_map) def update_function_signature(self, func_sig: Union[str, Dict], is_remove: None): @@ -1297,6 +1523,9 @@ def update_function_signature(self, func_sig: Union[str, Dict], is_remove: None) Args: func_sig (str or dict): description/name of the function to update/remove to the model. See: https://platform.openai.com/docs/api-reference/chat/create#chat/create-functions is_remove: whether removing the function from llm_config with name 'func_sig' + + Deprecated as of [OpenAI API v1.1.0](https://github.com/openai/openai-python/releases/tag/v1.1.0) + See https://platform.openai.com/docs/api-reference/chat/create#chat-create-function_call """ if not isinstance(self.llm_config, dict): @@ -1314,6 +1543,7 @@ def update_function_signature(self, func_sig: Union[str, Dict], is_remove: None) func for func in self.llm_config["functions"] if func["name"] != func_sig ] else: + self._assert_valid_name(func_sig["name"]) if "functions" in self.llm_config.keys(): self.llm_config["functions"] = [ func for func in self.llm_config["functions"] if func.get("name") != func_sig["name"] @@ -1326,9 +1556,48 @@ def update_function_signature(self, func_sig: Union[str, Dict], is_remove: None) self.client = OpenAIWrapper(**self.llm_config) - def can_execute_function(self, name: str) -> bool: + def update_tool_signature(self, tool_sig: Union[str, Dict], is_remove: None): + """update a tool_signature in the LLM configuration for tool_call. + + Args: + tool_sig (str or dict): description/name of the tool to update/remove to the model. See: https://platform.openai.com/docs/api-reference/chat/create#chat-create-tools + is_remove: whether removing the tool from llm_config with name 'tool_sig' + """ + + if not self.llm_config: + error_msg = "To update a tool signature, agent must have an llm_config" + logger.error(error_msg) + raise AssertionError(error_msg) + + if is_remove: + if "tools" not in self.llm_config.keys(): + error_msg = "The agent config doesn't have tool {name}.".format(name=tool_sig) + logger.error(error_msg) + raise AssertionError(error_msg) + else: + self.llm_config["tools"] = [ + tool for tool in self.llm_config["tools"] if tool["function"]["name"] != tool_sig + ] + else: + self._assert_valid_name(tool_sig["function"]["name"]) + if "tools" in self.llm_config.keys(): + self.llm_config["tools"] = [ + tool + for tool in self.llm_config["tools"] + if tool.get("function", {}).get("name") != tool_sig["function"]["name"] + ] + [tool_sig] + else: + self.llm_config["tools"] = [tool_sig] + + if len(self.llm_config["tools"]) == 0: + del self.llm_config["tools"] + + self.client = OpenAIWrapper(**self.llm_config) + + def can_execute_function(self, name: Union[List[str], str]) -> bool: """Whether the agent can execute the function.""" - return name in self._function_map + names = name if isinstance(name, list) else [name] + return all([n in self._function_map for n in names]) @property def function_map(self) -> Dict[str, Callable]: @@ -1433,7 +1702,7 @@ def _decorator(func: F) -> F: if self.llm_config is None: raise RuntimeError("LLM config must be setup before registering a function for LLM.") - self.update_function_signature(f, is_remove=False) + self.update_tool_signature(f, is_remove=False) return func diff --git a/autogen/agentchat/groupchat.py b/autogen/agentchat/groupchat.py index 1e6a636c8ec..cc87881c544 100644 --- a/autogen/agentchat/groupchat.py +++ b/autogen/agentchat/groupchat.py @@ -155,11 +155,21 @@ def _prepare_and_select_agents(self, last_speaker: Agent) -> Tuple[Optional[Agen "Or, use direct communication instead." ) - if self.func_call_filter and self.messages and "function_call" in self.messages[-1]: + if ( + self.func_call_filter + and self.messages + and ("function_call" in self.messages[-1] or "tool_calls" in self.messages[-1]) + ): + funcs = [] + if "function_call" in self.messages[-1]: + funcs += [self.messages[-1]["function_call"]["name"]] + if "tool_calls" in self.messages[-1]: + funcs += [ + tool["function"]["name"] for tool in self.messages[-1]["tool_calls"] if tool["type"] == "function" + ] + # find agents with the right function_map which contains the function name - agents = [ - agent for agent in self.agents if agent.can_execute_function(self.messages[-1]["function_call"]["name"]) - ] + agents = [agent for agent in self.agents if agent.can_execute_function(funcs)] if len(agents) == 1: # only one agent can execute the function return agents[0], agents @@ -170,7 +180,7 @@ def _prepare_and_select_agents(self, last_speaker: Agent) -> Tuple[Optional[Agen return agents[0], agents elif not agents: raise ValueError( - f"No agent can execute the function {self.messages[-1]['function_call']['name']}. " + f"No agent can execute the function {', '.join(funcs)}. " "Please check the function_map of the agents." ) # remove the last speaker from the list to avoid selecting the same speaker if allow_repeat_speaker is False @@ -193,7 +203,14 @@ def select_speaker(self, last_speaker: Agent, selector: ConversableAgent): return selected_agent # auto speaker selection selector.update_system_message(self.select_speaker_msg(agents)) - context = self.messages + [{"role": "system", "content": self.select_speaker_prompt(agents)}] + + # If last message is a tool call or function call, blank the call so the api doesn't throw + messages = self.messages.copy() + if messages[-1].get("function_call", False): + messages[-1] = dict(messages[-1], function_call=None) + if messages[-1].get("tool_calls", False): + messages[-1] = dict(messages[-1], tool_calls=None) + context = messages + [{"role": "system", "content": self.select_speaker_prompt(agents)}] final, name = selector.generate_oai_reply(context) if not final: @@ -275,6 +292,8 @@ def _mentioned_agents(self, message_content: Union[str, List], agents: List[Agen Dict: a counter for mentioned agents. """ # Cast message content to str + if isinstance(message_content, dict): + message_content = message_content["content"] message_content = content_str(message_content) mentions = dict() diff --git a/autogen/function_utils.py b/autogen/function_utils.py index 05493cc3df5..f289d9e4d2e 100644 --- a/autogen/function_utils.py +++ b/autogen/function_utils.py @@ -103,6 +103,13 @@ class Function(BaseModel): parameters: Annotated[Parameters, Field(description="Parameters of the function")] +class ToolFunction(BaseModel): + """A function under tool as defined by the OpenAI API.""" + + type: Literal["function"] = "function" + function: Annotated[Function, Field(description="Function under tool")] + + def get_parameter_json_schema( k: str, v: Union[Annotated[Type, str], Type], default_values: Dict[str, Any] ) -> JsonSchemaValue: @@ -260,10 +267,12 @@ def f(a: Annotated[str, "Parameter a"], b: int = 2, c: Annotated[float, "Paramet parameters = get_parameters(required, param_annotations, default_values=default_values) - function = Function( - description=description, - name=fname, - parameters=parameters, + function = ToolFunction( + function=Function( + description=description, + name=fname, + parameters=parameters, + ) ) return model_dump(function) diff --git a/autogen/oai/client.py b/autogen/oai/client.py index 443d62c9248..fdba6e108ae 100644 --- a/autogen/oai/client.py +++ b/autogen/oai/client.py @@ -287,9 +287,9 @@ def yes_or_no_filter(context, response): def _completions_create(self, client, params): completions = client.chat.completions if "messages" in params else client.completions - # If streaming is enabled, has messages, and does not have functions, then + # If streaming is enabled, has messages, and does not have functions or tools, then # iterate over the chunks of the response - if params.get("stream", False) and "messages" in params and "functions" not in params: + if params.get("stream", False) and "messages" in params and "functions" not in params and "tools" not in params: response_contents = [""] * params.get("n", 1) finish_reasons = [""] * params.get("n", 1) completion_tokens = 0 @@ -352,8 +352,8 @@ def _completions_create(self, client, params): response.choices.append(choice) else: - # If streaming is not enabled or using functions, send a regular chat completion request - # Functions are not supported, so ensure streaming is disabled + # If streaming is not enabled, using functions, or tools, send a regular chat completion request + # Functions and Tools are not supported, so ensure streaming is disabled params = params.copy() params["stream"] = False response = completions.create(**params) diff --git a/notebook/agentchat_function_call_currency_calculator.ipynb b/notebook/agentchat_function_call_currency_calculator.ipynb index c388db936ac..0d8ac52ea2c 100644 --- a/notebook/agentchat_function_call_currency_calculator.ipynb +++ b/notebook/agentchat_function_call_currency_calculator.ipynb @@ -185,20 +185,21 @@ { "data": { "text/plain": [ - "[{'description': 'Currency exchange calculator.',\n", - " 'name': 'currency_calculator',\n", - " 'parameters': {'type': 'object',\n", - " 'properties': {'base_amount': {'type': 'number',\n", - " 'description': 'Amount of currency in base_currency'},\n", - " 'base_currency': {'enum': ['USD', 'EUR'],\n", - " 'type': 'string',\n", - " 'default': 'USD',\n", - " 'description': 'Base currency'},\n", - " 'quote_currency': {'enum': ['USD', 'EUR'],\n", - " 'type': 'string',\n", - " 'default': 'EUR',\n", - " 'description': 'Quote currency'}},\n", - " 'required': ['base_amount']}}]" + "[{'type': 'function',\n", + " 'function': {'description': 'Currency exchange calculator.',\n", + " 'name': 'currency_calculator',\n", + " 'parameters': {'type': 'object',\n", + " 'properties': {'base_amount': {'type': 'number',\n", + " 'description': 'Amount of currency in base_currency'},\n", + " 'base_currency': {'enum': ['USD', 'EUR'],\n", + " 'type': 'string',\n", + " 'default': 'USD',\n", + " 'description': 'Base currency'},\n", + " 'quote_currency': {'enum': ['USD', 'EUR'],\n", + " 'type': 'string',\n", + " 'default': 'EUR',\n", + " 'description': 'Quote currency'}},\n", + " 'required': ['base_amount']}}}]" ] }, "execution_count": 4, @@ -207,7 +208,7 @@ } ], "source": [ - "chatbot.llm_config[\"functions\"]" + "chatbot.llm_config[\"tools\"]" ] }, { @@ -259,10 +260,14 @@ "--------------------------------------------------------------------------------\n", "\u001b[33mchatbot\u001b[0m (to user_proxy):\n", "\n", - "\u001b[32m***** Suggested function Call: currency_calculator *****\u001b[0m\n", + "\u001b[32m***** Suggested tool Call (call_2mZCDF9fe8WJh6SveIwdGGEy): currency_calculator *****\u001b[0m\n", "Arguments: \n", - "{\"base_amount\":123.45,\"base_currency\":\"USD\",\"quote_currency\":\"EUR\"}\n", - "\u001b[32m********************************************************\u001b[0m\n", + "{\n", + " \"base_amount\": 123.45,\n", + " \"base_currency\": \"USD\",\n", + " \"quote_currency\": \"EUR\"\n", + "}\n", + "\u001b[32m************************************************************************************\u001b[0m\n", "\n", "--------------------------------------------------------------------------------\n", "\u001b[35m\n", @@ -276,7 +281,7 @@ "--------------------------------------------------------------------------------\n", "\u001b[33mchatbot\u001b[0m (to user_proxy):\n", "\n", - "123.45 USD is equivalent to approximately 112.23 EUR.\n", + "123.45 USD is approximately 112.23 EUR.\n", "\n", "--------------------------------------------------------------------------------\n", "\u001b[33muser_proxy\u001b[0m (to chatbot):\n", @@ -370,27 +375,28 @@ { "data": { "text/plain": [ - "[{'description': 'Currency exchange calculator.',\n", - " 'name': 'currency_calculator',\n", - " 'parameters': {'type': 'object',\n", - " 'properties': {'base': {'properties': {'currency': {'description': 'Currency symbol',\n", - " 'enum': ['USD', 'EUR'],\n", - " 'title': 'Currency',\n", - " 'type': 'string'},\n", - " 'amount': {'default': 0,\n", - " 'description': 'Amount of currency',\n", - " 'minimum': 0.0,\n", - " 'title': 'Amount',\n", - " 'type': 'number'}},\n", - " 'required': ['currency'],\n", - " 'title': 'Currency',\n", - " 'type': 'object',\n", - " 'description': 'Base currency: amount and currency symbol'},\n", - " 'quote_currency': {'enum': ['USD', 'EUR'],\n", - " 'type': 'string',\n", - " 'default': 'USD',\n", - " 'description': 'Quote currency symbol'}},\n", - " 'required': ['base']}}]" + "[{'type': 'function',\n", + " 'function': {'description': 'Currency exchange calculator.',\n", + " 'name': 'currency_calculator',\n", + " 'parameters': {'type': 'object',\n", + " 'properties': {'base': {'properties': {'currency': {'description': 'Currency symbol',\n", + " 'enum': ['USD', 'EUR'],\n", + " 'title': 'Currency',\n", + " 'type': 'string'},\n", + " 'amount': {'default': 0,\n", + " 'description': 'Amount of currency',\n", + " 'minimum': 0.0,\n", + " 'title': 'Amount',\n", + " 'type': 'number'}},\n", + " 'required': ['currency'],\n", + " 'title': 'Currency',\n", + " 'type': 'object',\n", + " 'description': 'Base currency: amount and currency symbol'},\n", + " 'quote_currency': {'enum': ['USD', 'EUR'],\n", + " 'type': 'string',\n", + " 'default': 'USD',\n", + " 'description': 'Quote currency symbol'}},\n", + " 'required': ['base']}}}]" ] }, "execution_count": 8, @@ -399,7 +405,7 @@ } ], "source": [ - "chatbot.llm_config[\"functions\"]" + "chatbot.llm_config[\"tools\"]" ] }, { @@ -419,10 +425,16 @@ "--------------------------------------------------------------------------------\n", "\u001b[33mchatbot\u001b[0m (to user_proxy):\n", "\n", - "\u001b[32m***** Suggested function Call: currency_calculator *****\u001b[0m\n", + "\u001b[32m***** Suggested tool Call (call_MLtsPcVJXhdpvDPNNxfTB3OB): currency_calculator *****\u001b[0m\n", "Arguments: \n", - "{\"base\":{\"currency\":\"EUR\",\"amount\":112.23},\"quote_currency\":\"USD\"}\n", - "\u001b[32m********************************************************\u001b[0m\n", + "{\n", + " \"base\": {\n", + " \"currency\": \"EUR\",\n", + " \"amount\": 112.23\n", + " },\n", + " \"quote_currency\": \"USD\"\n", + "}\n", + "\u001b[32m************************************************************************************\u001b[0m\n", "\n", "--------------------------------------------------------------------------------\n", "\u001b[35m\n", @@ -436,7 +448,7 @@ "--------------------------------------------------------------------------------\n", "\u001b[33mchatbot\u001b[0m (to user_proxy):\n", "\n", - "112.23 Euros is equivalent to approximately 123.45 US Dollars.\n", + "112.23 Euros is approximately 123.45 US Dollars.\n", "\n", "--------------------------------------------------------------------------------\n", "\u001b[33muser_proxy\u001b[0m (to chatbot):\n", @@ -477,10 +489,16 @@ "--------------------------------------------------------------------------------\n", "\u001b[33mchatbot\u001b[0m (to user_proxy):\n", "\n", - "\u001b[32m***** Suggested function Call: currency_calculator *****\u001b[0m\n", + "\u001b[32m***** Suggested tool Call (call_WrBjnoLeXilBPuj9nTJLM5wh): currency_calculator *****\u001b[0m\n", "Arguments: \n", - "{\"base\":{\"currency\":\"USD\",\"amount\":123.45},\"quote_currency\":\"EUR\"}\n", - "\u001b[32m********************************************************\u001b[0m\n", + "{\n", + " \"base\": {\n", + " \"currency\": \"USD\",\n", + " \"amount\": 123.45\n", + " },\n", + " \"quote_currency\": \"EUR\"\n", + "}\n", + "\u001b[32m************************************************************************************\u001b[0m\n", "\n", "--------------------------------------------------------------------------------\n", "\u001b[35m\n", @@ -543,7 +561,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.13" + "version": "3.11.6" } }, "nbformat": 4, diff --git a/test/agentchat/test_conversable_agent.py b/test/agentchat/test_conversable_agent.py index 5e8ac73ebfb..0d647cbcc3d 100644 --- a/test/agentchat/test_conversable_agent.py +++ b/test/agentchat/test_conversable_agent.py @@ -489,9 +489,9 @@ def get_origin(d: Dict[str, Callable[..., Any]]) -> Dict[str, Callable[..., Any] def test_register_for_llm(): with pytest.MonkeyPatch.context() as mp: mp.setenv("OPENAI_API_KEY", "mock") - agent3 = ConversableAgent(name="agent3", llm_config={}) - agent2 = ConversableAgent(name="agent2", llm_config={}) - agent1 = ConversableAgent(name="agent1", llm_config={}) + agent3 = ConversableAgent(name="agent3", llm_config={"config_list": []}) + agent2 = ConversableAgent(name="agent2", llm_config={"config_list": []}) + agent1 = ConversableAgent(name="agent1", llm_config={"config_list": []}) @agent3.register_for_llm() @agent2.register_for_llm(name="python") @@ -501,27 +501,30 @@ def exec_python(cell: Annotated[str, "Valid Python cell to execute."]) -> str: expected1 = [ { - "description": "run cell in ipython and return the execution result.", - "name": "exec_python", - "parameters": { - "type": "object", - "properties": { - "cell": { - "type": "string", - "description": "Valid Python cell to execute.", - } + "type": "function", + "function": { + "description": "run cell in ipython and return the execution result.", + "name": "exec_python", + "parameters": { + "type": "object", + "properties": { + "cell": { + "type": "string", + "description": "Valid Python cell to execute.", + } + }, + "required": ["cell"], }, - "required": ["cell"], }, } ] expected2 = copy.deepcopy(expected1) - expected2[0]["name"] = "python" + expected2[0]["function"]["name"] = "python" expected3 = expected2 - assert agent1.llm_config["functions"] == expected1 - assert agent2.llm_config["functions"] == expected2 - assert agent3.llm_config["functions"] == expected3 + assert agent1.llm_config["tools"] == expected1 + assert agent2.llm_config["tools"] == expected2 + assert agent3.llm_config["tools"] == expected3 @agent3.register_for_llm() @agent2.register_for_llm() @@ -531,26 +534,29 @@ async def exec_sh(script: Annotated[str, "Valid shell script to execute."]) -> s expected1 = expected1 + [ { - "name": "sh", - "description": "run a shell script and return the execution result.", - "parameters": { - "type": "object", - "properties": { - "script": { - "type": "string", - "description": "Valid shell script to execute.", - } + "type": "function", + "function": { + "name": "sh", + "description": "run a shell script and return the execution result.", + "parameters": { + "type": "object", + "properties": { + "script": { + "type": "string", + "description": "Valid shell script to execute.", + } + }, + "required": ["script"], }, - "required": ["script"], }, } ] expected2 = expected2 + [expected1[1]] expected3 = expected3 + [expected1[1]] - assert agent1.llm_config["functions"] == expected1 - assert agent2.llm_config["functions"] == expected2 - assert agent3.llm_config["functions"] == expected3 + assert agent1.llm_config["tools"] == expected1 + assert agent2.llm_config["tools"] == expected2 + assert agent3.llm_config["tools"] == expected3 def test_register_for_llm_without_description(): @@ -586,7 +592,7 @@ def exec_python(cell: Annotated[str, "Valid Python cell to execute."]) -> str: def test_register_for_execution(): with pytest.MonkeyPatch.context() as mp: mp.setenv("OPENAI_API_KEY", "mock") - agent = ConversableAgent(name="agent", llm_config={}) + agent = ConversableAgent(name="agent", llm_config={"config_list": []}) user_proxy_1 = UserProxyAgent(name="user_proxy_1") user_proxy_2 = UserProxyAgent(name="user_proxy_2") diff --git a/test/agentchat/test_tool_calls.py b/test/agentchat/test_tool_calls.py new file mode 100644 index 00000000000..061431468a9 --- /dev/null +++ b/test/agentchat/test_tool_calls.py @@ -0,0 +1,235 @@ +try: + from openai import OpenAI +except ImportError: + OpenAI = None +import inspect +import pytest +import json +import autogen +from conftest import skip_openai +from autogen.math_utils import eval_math_responses +from test_assistant_agent import KEY_LOC, OAI_CONFIG_LIST +import sys +from autogen.oai.client import TOOL_ENABLED + +try: + from openai import OpenAI +except ImportError: + skip = True +else: + skip = False or skip_openai + + +@pytest.mark.skipif(skip_openai or not TOOL_ENABLED, reason="openai>=1.1.0 not installed or requested to skip") +def test_eval_math_responses(): + config_list = autogen.config_list_from_models( + KEY_LOC, exclude="aoai", model_list=["gpt-4-0613", "gpt-3.5-turbo-0613", "gpt-3.5-turbo-16k"] + ) + tools = [ + { + "type": "function", + "function": { + "name": "eval_math_responses", + "description": "Select a response for a math problem using voting, and check if the response is correct if the solution is provided", + "parameters": { + "type": "object", + "properties": { + "responses": { + "type": "array", + "items": {"type": "string"}, + "description": "The responses in a list", + }, + "solution": { + "type": "string", + "description": "The canonical solution", + }, + }, + "required": ["responses"], + }, + }, + }, + ] + client = autogen.OpenAIWrapper(config_list=config_list) + response = client.create( + messages=[ + { + "role": "user", + "content": 'evaluate the math responses ["1", "5/2", "5/2"] against the true answer \\frac{5}{2}', + }, + ], + tools=tools, + ) + print(response) + responses = client.extract_text_or_completion_object(response) + print(responses[0]) + tool_calls = responses[0].tool_calls + function_call = tool_calls[0].function + name, arguments = function_call.name, json.loads(function_call.arguments) + assert name == "eval_math_responses" + print(arguments["responses"]) + # if isinstance(arguments["responses"], str): + # arguments["responses"] = json.loads(arguments["responses"]) + arguments["responses"] = [f"\\boxed{{{x}}}" for x in arguments["responses"]] + print(arguments["responses"]) + arguments["solution"] = f"\\boxed{{{arguments['solution']}}}" + print(eval_math_responses(**arguments)) + + +@pytest.mark.skipif( + skip_openai or not TOOL_ENABLED or not sys.version.startswith("3.10"), + reason="do not run if openai is <1.1.0 or py!=3.10 or requested to skip", +) +def test_update_tool(): + config_list_gpt4 = autogen.config_list_from_json( + OAI_CONFIG_LIST, + filter_dict={ + "model": ["gpt-4", "gpt-4-0314", "gpt4", "gpt-4-32k", "gpt-4-32k-0314", "gpt-4-32k-v0314"], + }, + file_location=KEY_LOC, + ) + llm_config = { + "config_list": config_list_gpt4, + "seed": 42, + "tools": [], + } + + user_proxy = autogen.UserProxyAgent( + name="user_proxy", + human_input_mode="NEVER", + is_termination_msg=lambda x: True if "TERMINATE" in x.get("content") else False, + ) + assistant = autogen.AssistantAgent(name="test", llm_config=llm_config) + + # Define a new function *after* the assistant has been created + assistant.update_tool_signature( + { + "type": "function", + "function": { + "name": "greet_user", + "description": "Greets the user.", + "parameters": { + "type": "object", + "properties": {}, + "required": [], + }, + }, + }, + is_remove=False, + ) + user_proxy.initiate_chat( + assistant, + message="What functions do you know about in the context of this conversation? End your response with 'TERMINATE'.", + ) + messages1 = assistant.chat_messages[user_proxy][-1]["content"] + print(messages1) + + assistant.update_tool_signature("greet_user", is_remove=True) + user_proxy.initiate_chat( + assistant, + message="What functions do you know about in the context of this conversation? End your response with 'TERMINATE'.", + ) + messages2 = assistant.chat_messages[user_proxy][-1]["content"] + print(messages2) + # The model should know about the function in the context of the conversation + assert "greet_user" in messages1 + assert "greet_user" not in messages2 + + +@pytest.mark.skipif(not TOOL_ENABLED, reason="openai>=1.1.0 not installed") +def test_multi_tool_call(): + class FakeAgent(autogen.Agent): + def __init__(self, name): + super().__init__(name) + self.received = [] + + def receive( + self, + message, + sender, + request_reply=None, + silent=False, + ): + message = message if isinstance(message, list) else [message] + self.received.extend(message) + + user_proxy = autogen.UserProxyAgent( + name="user_proxy", + human_input_mode="NEVER", + is_termination_msg=lambda x: True if "TERMINATE" in x.get("content") else False, + ) + user_proxy.register_function({"echo": lambda str: str}) + + fake_agent = FakeAgent("fake_agent") + + user_proxy.receive( + message={ + "content": "test multi tool call", + "tool_calls": [ + { + "id": "tool_1", + "type": "function", + "function": {"name": "echo", "arguments": json.JSONEncoder().encode({"str": "hello world"})}, + }, + { + "id": "tool_2", + "type": "function", + "function": { + "name": "echo", + "arguments": json.JSONEncoder().encode({"str": "goodbye and thanks for all the fish"}), + }, + }, + { + "id": "tool_3", + "type": "function", + "function": { + "name": "multi_tool_call_echo", # normalized "multi_tool_call.echo" + "arguments": json.JSONEncoder().encode({"str": "goodbye and thanks for all the fish"}), + }, + }, + ], + }, + sender=fake_agent, + request_reply=True, + ) + + assert fake_agent.received == [ + { + "role": "tool", + "tool_responses": [ + {"tool_call_id": "tool_1", "role": "tool", "name": "echo", "content": "hello world"}, + { + "tool_call_id": "tool_2", + "role": "tool", + "name": "echo", + "content": "goodbye and thanks for all the fish", + }, + { + "tool_call_id": "tool_3", + "role": "tool", + "name": "multi_tool_call_echo", + "content": "Error: Function multi_tool_call_echo not found.", + }, + ], + "content": inspect.cleandoc( + """ + Tool call: echo + Id: tool_1 + hello world + + Tool call: echo + Id: tool_2 + goodbye and thanks for all the fish + + Tool call: multi_tool_call_echo + Id: tool_3 + Error: Function multi_tool_call_echo not found. + """ + ), + } + ] + + +if __name__ == "__main__": + test_update_tool() + test_eval_math_responses() + test_multi_tool_call() diff --git a/test/test_function_utils.py b/test/test_function_utils.py index 53e0d86cf50..422aaf9bbed 100644 --- a/test/test_function_utils.py +++ b/test/test_function_utils.py @@ -210,54 +210,60 @@ def f(a: Annotated[str, "Parameter a"], b, c: Annotated[float, "Parameter c"] = def test_get_function_schema() -> None: expected_v2 = { - "description": "function g", - "name": "fancy name for g", - "parameters": { - "type": "object", - "properties": { - "a": {"type": "string", "description": "Parameter a"}, - "b": {"type": "integer", "description": "b", "default": 2}, - "c": {"type": "number", "description": "Parameter c", "default": 0.1}, - "d": { - "additionalProperties": { - "maxItems": 2, - "minItems": 2, - "prefixItems": [ - {"anyOf": [{"type": "integer"}, {"type": "null"}]}, - {"items": {"type": "number"}, "type": "array"}, - ], - "type": "array", + "type": "function", + "function": { + "description": "function g", + "name": "fancy name for g", + "parameters": { + "type": "object", + "properties": { + "a": {"type": "string", "description": "Parameter a"}, + "b": {"type": "integer", "description": "b", "default": 2}, + "c": {"type": "number", "description": "Parameter c", "default": 0.1}, + "d": { + "additionalProperties": { + "maxItems": 2, + "minItems": 2, + "prefixItems": [ + {"anyOf": [{"type": "integer"}, {"type": "null"}]}, + {"items": {"type": "number"}, "type": "array"}, + ], + "type": "array", + }, + "type": "object", + "description": "d", }, - "type": "object", - "description": "d", }, + "required": ["a", "d"], }, - "required": ["a", "d"], }, } # the difference is that the v1 version does not handle Union types (Optional is Union[T, None]) expected_v1 = { - "description": "function g", - "name": "fancy name for g", - "parameters": { - "type": "object", - "properties": { - "a": {"type": "string", "description": "Parameter a"}, - "b": {"type": "integer", "description": "b", "default": 2}, - "c": {"type": "number", "description": "Parameter c", "default": 0.1}, - "d": { - "type": "object", - "additionalProperties": { - "type": "array", - "minItems": 2, - "maxItems": 2, - "items": [{"type": "integer"}, {"type": "array", "items": {"type": "number"}}], + "type": "function", + "function": { + "description": "function g", + "name": "fancy name for g", + "parameters": { + "type": "object", + "properties": { + "a": {"type": "string", "description": "Parameter a"}, + "b": {"type": "integer", "description": "b", "default": 2}, + "c": {"type": "number", "description": "Parameter c", "default": 0.1}, + "d": { + "type": "object", + "additionalProperties": { + "type": "array", + "minItems": 2, + "maxItems": 2, + "items": [{"type": "integer"}, {"type": "array", "items": {"type": "number"}}], + }, + "description": "d", }, - "description": "d", }, + "required": ["a", "d"], }, - "required": ["a", "d"], }, } @@ -291,39 +297,42 @@ def currency_calculator( pass expected = { - "description": "Currency exchange calculator.", - "name": "currency_calculator", - "parameters": { - "type": "object", - "properties": { - "base": { - "properties": { - "currency": { - "description": "Currency code", - "enum": ["USD", "EUR"], - "title": "Currency", - "type": "string", - }, - "amount": { - "default": 100.0, - "description": "Amount of money in the currency", - "title": "Amount", - "type": "number", + "type": "function", + "function": { + "description": "Currency exchange calculator.", + "name": "currency_calculator", + "parameters": { + "type": "object", + "properties": { + "base": { + "properties": { + "currency": { + "description": "Currency code", + "enum": ["USD", "EUR"], + "title": "Currency", + "type": "string", + }, + "amount": { + "default": 100.0, + "description": "Amount of money in the currency", + "title": "Amount", + "type": "number", + }, }, + "required": ["currency"], + "title": "Currency", + "type": "object", + "description": "Base currency: amount and currency symbol", + }, + "quote_currency": { + "enum": ["USD", "EUR"], + "type": "string", + "default": "EUR", + "description": "Quote currency symbol (default: 'EUR')", }, - "required": ["currency"], - "title": "Currency", - "type": "object", - "description": "Base currency: amount and currency symbol", - }, - "quote_currency": { - "enum": ["USD", "EUR"], - "type": "string", - "default": "EUR", - "description": "Quote currency symbol (default: 'EUR')", }, + "required": ["base"], }, - "required": ["base"], }, } diff --git a/website/docs/Use-Cases/agent_chat.md b/website/docs/Use-Cases/agent_chat.md index 2bb30653462..2ac7057bddd 100644 --- a/website/docs/Use-Cases/agent_chat.md +++ b/website/docs/Use-Cases/agent_chat.md @@ -23,7 +23,7 @@ We have designed a generic [`ConversableAgent`](../reference/agentchat/conversab - The [`AssistantAgent`](../reference/agentchat/assistant_agent.md#assistantagent-objects) is designed to act as an AI assistant, using LLMs by default but not requiring human input or code execution. It could write Python code (in a Python coding block) for a user to execute when a message (typically a description of a task that needs to be solved) is received. Under the hood, the Python code is written by LLM (e.g., GPT-4). It can also receive the execution results and suggest corrections or bug fixes. Its behavior can be altered by passing a new system message. The LLM [inference](#enhanced-inference) configuration can be configured via [`llm_config`]. -- The [`UserProxyAgent`](../reference/agentchat/user_proxy_agent.md#userproxyagent-objects) is conceptually a proxy agent for humans, soliciting human input as the agent's reply at each interaction turn by default and also having the capability to execute code and call functions. The [`UserProxyAgent`](../reference/agentchat/user_proxy_agent.md#userproxyagent-objects) triggers code execution automatically when it detects an executable code block in the received message and no human user input is provided. Code execution can be disabled by setting the `code_execution_config` parameter to False. LLM-based response is disabled by default. It can be enabled by setting `llm_config` to a dict corresponding to the [inference](/docs/Use-Cases/enhanced_inference) configuration. When `llm_config` is set as a dictionary, [`UserProxyAgent`](../reference/agentchat/user_proxy_agent.md#userproxyagent-objects) can generate replies using an LLM when code execution is not performed. +- The [`UserProxyAgent`](../reference/agentchat/user_proxy_agent.md#userproxyagent-objects) is conceptually a proxy agent for humans, soliciting human input as the agent's reply at each interaction turn by default and also having the capability to execute code and call functions or tools. The [`UserProxyAgent`](../reference/agentchat/user_proxy_agent.md#userproxyagent-objects) triggers code execution automatically when it detects an executable code block in the received message and no human user input is provided. Code execution can be disabled by setting the `code_execution_config` parameter to False. LLM-based response is disabled by default. It can be enabled by setting `llm_config` to a dict corresponding to the [inference](/docs/Use-Cases/enhanced_inference) configuration. When `llm_config` is set as a dictionary, [`UserProxyAgent`](../reference/agentchat/user_proxy_agent.md#userproxyagent-objects) can generate replies using an LLM when code execution is not performed. The auto-reply capability of [`ConversableAgent`](../reference/agentchat/conversable_agent.md#conversableagent-objects) allows for more autonomous multi-agent communication while retaining the possibility of human intervention. One can also easily extend it by registering reply functions with the [`register_reply()`](../reference/agentchat/conversable_agent.md#register_reply) method. @@ -39,16 +39,16 @@ assistant = AssistantAgent(name="assistant") # create a UserProxyAgent instance named "user_proxy" user_proxy = UserProxyAgent(name="user_proxy") ``` -#### Function calling +#### Tool calling -Function calling enables agents to interact with external tools and APIs more efficiently. +Tool calling enables agents to interact with external tools and APIs more efficiently. This feature allows the AI model to intelligently choose to output a JSON object containing -arguments to call specific functions based on the user's input. A function to be called is +arguments to call specific tools based on the user's input. A tool to be called is specified with a JSON schema describing its parameters and their types. Writing such JSON schema is complex and error-prone and that is why AutoGen framework provides two high level function decorators for automatically generating such schema using type hints on standard Python datatypes or Pydantic models: -1. [`ConversableAgent.register_for_llm`](../reference/agentchat/conversable_agent#register_for_llm) is used to register the function in the `llm_config` of a ConversableAgent. The ConversableAgent agent can propose execution of a registrated function, but the actual execution will be performed by a UserProxy agent. +1. [`ConversableAgent.register_for_llm`](../reference/agentchat/conversable_agent#register_for_llm) is used to register the function as a Tool in the `llm_config` of a ConversableAgent. The ConversableAgent agent can propose execution of a registrated Tool, but the actual execution will be performed by a UserProxy agent. 2. [`ConversableAgent.register_for_execution`](../reference/agentchat/conversable_agent#register_for_execution) is used to register the function in the `function_map` of a UserProxy agent. @@ -81,9 +81,10 @@ def currency_calculator( Notice the use of [Annotated](https://docs.python.org/3/library/typing.html?highlight=annotated#typing.Annotated) to specify the type and the description of each parameter. The return value of the function must be either string or serializable to string using the [`json.dumps()`](https://docs.python.org/3/library/json.html#json.dumps) or [`Pydantic` model dump to JSON](https://docs.pydantic.dev/latest/concepts/serialization/#modelmodel_dump_json) (both version 1.x and 2.x are supported). -You can check the JSON schema generated by the decorator `chatbot.llm_config["functions"]`: +You can check the JSON schema generated by the decorator `chatbot.llm_config["tools"]`: ```python -[{'description': 'Currency exchange calculator.', +[{'type': 'function', 'function': + {'description': 'Currency exchange calculator.', 'name': 'currency_calculator', 'parameters': {'type': 'object', 'properties': {'base_amount': {'type': 'number', @@ -96,7 +97,7 @@ You can check the JSON schema generated by the decorator `chatbot.llm_config["fu 'type': 'string', 'default': 'EUR', 'description': 'Quote currency'}}, - 'required': ['base_amount']}}] + 'required': ['base_amount']}}}] ``` Agents can now use the function as follows: ``` @@ -107,7 +108,7 @@ How much is 123.45 USD in EUR? -------------------------------------------------------------------------------- chatbot (to user_proxy): -***** Suggested function Call: currency_calculator ***** +***** Suggested tool Call: currency_calculator ***** Arguments: {"base_amount":123.45,"base_currency":"USD","quote_currency":"EUR"} ******************************************************** @@ -163,7 +164,8 @@ def currency_calculator( The generated JSON schema has additional properties such as minimum value encoded: ```python -[{'description': 'Currency exchange calculator.', +[{'type': 'function', 'function': + {'description': 'Currency exchange calculator.', 'name': 'currency_calculator', 'parameters': {'type': 'object', 'properties': {'base': {'properties': {'currency': {'description': 'Currency symbol', @@ -183,7 +185,7 @@ The generated JSON schema has additional properties such as minimum value encode 'type': 'string', 'default': 'USD', 'description': 'Quote currency symbol'}}, - 'required': ['base']}}] + 'required': ['base']}}}] ``` For more in-depth examples, please check the following: