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

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