You can not select more than 25 topics Topics must start with a chinese character,a letter or number, can include dashes ('-') and can be up to 35 characters long.

_chat_completion_agent.py 11 kB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262
  1. import asyncio
  2. import json
  3. from typing import Any, Coroutine, Dict, List, Mapping, Sequence, Tuple
  4. from agnext.components import (
  5. DefaultTopicId,
  6. FunctionCall,
  7. RoutedAgent,
  8. message_handler,
  9. )
  10. from agnext.components.memory import ChatMemory
  11. from agnext.components.models import (
  12. ChatCompletionClient,
  13. FunctionExecutionResult,
  14. FunctionExecutionResultMessage,
  15. SystemMessage,
  16. )
  17. from agnext.components.tools import Tool
  18. from agnext.core import AgentId, CancellationToken, MessageContext
  19. from ..types import (
  20. FunctionCallMessage,
  21. Message,
  22. MultiModalMessage,
  23. PublishNow,
  24. Reset,
  25. RespondNow,
  26. ResponseFormat,
  27. TextMessage,
  28. ToolApprovalRequest,
  29. ToolApprovalResponse,
  30. )
  31. from ..utils import convert_messages_to_llm_messages
  32. class ChatCompletionAgent(RoutedAgent):
  33. """An agent implementation that uses the ChatCompletion API to gnenerate
  34. responses and execute tools.
  35. Args:
  36. description (str): The description of the agent.
  37. system_messages (List[SystemMessage]): The system messages to use for
  38. the ChatCompletion API.
  39. memory (ChatMemory[Message]): The memory to store and retrieve messages.
  40. model_client (ChatCompletionClient): The client to use for the
  41. ChatCompletion API.
  42. tools (Sequence[Tool], optional): The tools used by the agent. Defaults
  43. to []. If no tools are provided, the agent cannot handle tool calls.
  44. If tools are provided, and the response from the model is a list of
  45. tool calls, the agent will call itselfs with the tool calls until it
  46. gets a response that is not a list of tool calls, and then use that
  47. response as the final response.
  48. tool_approver (Agent | None, optional): The agent that approves tool
  49. calls. Defaults to None. If no tool approver is provided, the agent
  50. will execute the tools without approval. If a tool approver is
  51. provided, the agent will send a request to the tool approver before
  52. executing the tools.
  53. """
  54. def __init__(
  55. self,
  56. description: str,
  57. system_messages: List[SystemMessage],
  58. memory: ChatMemory[Message],
  59. model_client: ChatCompletionClient,
  60. tools: Sequence[Tool] = [],
  61. tool_approver: AgentId | None = None,
  62. ) -> None:
  63. super().__init__(description)
  64. self._description = description
  65. self._system_messages = system_messages
  66. self._client = model_client
  67. self._memory = memory
  68. self._tools = tools
  69. self._tool_approver = tool_approver
  70. @message_handler()
  71. async def on_text_message(self, message: TextMessage, ctx: MessageContext) -> None:
  72. """Handle a text message. This method adds the message to the memory and
  73. does not generate any message."""
  74. # Add a user message.
  75. await self._memory.add_message(message)
  76. @message_handler()
  77. async def on_multi_modal_message(self, message: MultiModalMessage, ctx: MessageContext) -> None:
  78. """Handle a multimodal message. This method adds the message to the memory
  79. and does not generate any message."""
  80. # Add a user message.
  81. await self._memory.add_message(message)
  82. @message_handler()
  83. async def on_reset(self, message: Reset, ctx: MessageContext) -> None:
  84. """Handle a reset message. This method clears the memory."""
  85. # Reset the chat messages.
  86. await self._memory.clear()
  87. @message_handler()
  88. async def on_respond_now(self, message: RespondNow, ctx: MessageContext) -> TextMessage | FunctionCallMessage:
  89. """Handle a respond now message. This method generates a response and
  90. returns it to the sender."""
  91. # Generate a response.
  92. response = await self._generate_response(message.response_format, ctx)
  93. # Return the response.
  94. return response
  95. @message_handler()
  96. async def on_publish_now(self, message: PublishNow, ctx: MessageContext) -> None:
  97. """Handle a publish now message. This method generates a response and
  98. publishes it."""
  99. # Generate a response.
  100. response = await self._generate_response(message.response_format, ctx)
  101. # Publish the response.
  102. await self.publish_message(response, topic_id=DefaultTopicId())
  103. @message_handler()
  104. async def on_tool_call_message(
  105. self, message: FunctionCallMessage, ctx: MessageContext
  106. ) -> FunctionExecutionResultMessage:
  107. """Handle a tool call message. This method executes the tools and
  108. returns the results."""
  109. if len(self._tools) == 0:
  110. raise ValueError("No tools available")
  111. # Add a tool call message.
  112. await self._memory.add_message(message)
  113. # Execute the tool calls.
  114. results: List[FunctionExecutionResult] = []
  115. execution_futures: List[Coroutine[Any, Any, Tuple[str, str]]] = []
  116. for function_call in message.content:
  117. # Parse the arguments.
  118. try:
  119. arguments = json.loads(function_call.arguments)
  120. except json.JSONDecodeError:
  121. results.append(
  122. FunctionExecutionResult(
  123. content=f"Error: Could not parse arguments for function {function_call.name}.",
  124. call_id=function_call.id,
  125. )
  126. )
  127. continue
  128. # Execute the function.
  129. future = self._execute_function(
  130. function_call.name,
  131. arguments,
  132. function_call.id,
  133. cancellation_token=ctx.cancellation_token,
  134. )
  135. # Append the async result.
  136. execution_futures.append(future)
  137. if execution_futures:
  138. # Wait for all async results.
  139. execution_results = await asyncio.gather(*execution_futures)
  140. # Add the results.
  141. for execution_result, call_id in execution_results:
  142. results.append(FunctionExecutionResult(content=execution_result, call_id=call_id))
  143. # Create a tool call result message.
  144. tool_call_result_msg = FunctionExecutionResultMessage(content=results)
  145. # Add tool call result message.
  146. await self._memory.add_message(tool_call_result_msg)
  147. # Return the results.
  148. return tool_call_result_msg
  149. async def _generate_response(
  150. self,
  151. response_format: ResponseFormat,
  152. ctx: MessageContext,
  153. ) -> TextMessage | FunctionCallMessage:
  154. # Get a response from the model.
  155. hisorical_messages = await self._memory.get_messages()
  156. response = await self._client.create(
  157. self._system_messages + convert_messages_to_llm_messages(hisorical_messages, self.metadata["type"]),
  158. tools=self._tools,
  159. json_output=response_format == ResponseFormat.json_object,
  160. )
  161. # If the agent has function executor, and the response is a list of
  162. # tool calls, iterate with itself until we get a response that is not a
  163. # list of tool calls.
  164. while (
  165. len(self._tools) > 0
  166. and isinstance(response.content, list)
  167. and all(isinstance(x, FunctionCall) for x in response.content)
  168. ):
  169. # Send a function call message to itself.
  170. response = await self.send_message(
  171. message=FunctionCallMessage(content=response.content, source=self.metadata["type"]),
  172. recipient=self.id,
  173. cancellation_token=ctx.cancellation_token,
  174. )
  175. # Make an assistant message from the response.
  176. hisorical_messages = await self._memory.get_messages()
  177. response = await self._client.create(
  178. self._system_messages + convert_messages_to_llm_messages(hisorical_messages, self.metadata["type"]),
  179. tools=self._tools,
  180. json_output=response_format == ResponseFormat.json_object,
  181. )
  182. final_response: Message
  183. if isinstance(response.content, str):
  184. # If the response is a string, return a text message.
  185. final_response = TextMessage(content=response.content, source=self.metadata["type"])
  186. elif isinstance(response.content, list) and all(isinstance(x, FunctionCall) for x in response.content):
  187. # If the response is a list of function calls, return a function call message.
  188. final_response = FunctionCallMessage(content=response.content, source=self.metadata["type"])
  189. else:
  190. raise ValueError(f"Unexpected response: {response.content}")
  191. # Add the response to the chat messages.
  192. await self._memory.add_message(final_response)
  193. return final_response
  194. async def _execute_function(
  195. self,
  196. name: str,
  197. args: Dict[str, Any],
  198. call_id: str,
  199. cancellation_token: CancellationToken,
  200. ) -> Tuple[str, str]:
  201. # Find tool
  202. tool = next((t for t in self._tools if t.name == name), None)
  203. if tool is None:
  204. return (f"Error: tool {name} not found.", call_id)
  205. # Check if the tool needs approval
  206. if self._tool_approver is not None:
  207. # Send a tool approval request.
  208. approval_request = ToolApprovalRequest(
  209. tool_call=FunctionCall(id=call_id, arguments=json.dumps(args), name=name)
  210. )
  211. approval_response = await self.send_message(
  212. message=approval_request,
  213. recipient=self._tool_approver,
  214. cancellation_token=cancellation_token,
  215. )
  216. if not isinstance(approval_response, ToolApprovalResponse):
  217. raise ValueError(f"Expecting {ToolApprovalResponse.__name__}, received: {type(approval_response)}")
  218. if not approval_response.approved:
  219. return (f"Error: tool {name} approved, reason: {approval_response.reason}", call_id)
  220. try:
  221. result = await tool.run_json(args, cancellation_token)
  222. result_as_str = tool.return_value_as_string(result)
  223. except Exception as e:
  224. result_as_str = f"Error: {str(e)}"
  225. return (result_as_str, call_id)
  226. def save_state(self) -> Mapping[str, Any]:
  227. return {
  228. "memory": self._memory.save_state(),
  229. "system_messages": self._system_messages,
  230. }
  231. def load_state(self, state: Mapping[str, Any]) -> None:
  232. self._memory.load_state(state["memory"])
  233. self._system_messages = state["system_messages"]