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.

test_openai_assistant_agent.py 15 kB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390
  1. import io
  2. import os
  3. from contextlib import asynccontextmanager
  4. from enum import Enum
  5. from pathlib import Path
  6. from typing import Any, AsyncGenerator, Dict, List, Literal, Optional, Union
  7. from unittest.mock import AsyncMock, MagicMock
  8. import aiofiles
  9. import pytest
  10. from autogen_agentchat.messages import ChatMessage, TextMessage
  11. from autogen_core import CancellationToken
  12. from autogen_core.tools._base import BaseTool, Tool
  13. from autogen_ext.agents.openai import OpenAIAssistantAgent
  14. from azure.identity import DefaultAzureCredential, get_bearer_token_provider
  15. from openai import AsyncAzureOpenAI
  16. from pydantic import BaseModel
  17. class QuestionType(str, Enum):
  18. MULTIPLE_CHOICE = "MULTIPLE_CHOICE"
  19. FREE_RESPONSE = "FREE_RESPONSE"
  20. class Question(BaseModel):
  21. question_text: str
  22. question_type: QuestionType
  23. choices: Optional[List[str]] = None
  24. class DisplayQuizArgs(BaseModel):
  25. title: str
  26. questions: List[Question]
  27. class QuizResponses(BaseModel):
  28. responses: List[str]
  29. class DisplayQuizTool(BaseTool[DisplayQuizArgs, QuizResponses]):
  30. def __init__(self) -> None:
  31. super().__init__(
  32. args_type=DisplayQuizArgs,
  33. return_type=QuizResponses,
  34. name="display_quiz",
  35. description=(
  36. "Displays a quiz to the student and returns the student's responses. "
  37. "A single quiz can have multiple questions."
  38. ),
  39. )
  40. async def run(self, args: DisplayQuizArgs, cancellation_token: CancellationToken) -> QuizResponses:
  41. responses: List[str] = []
  42. for q in args.questions:
  43. if q.question_type == QuestionType.MULTIPLE_CHOICE:
  44. response = q.choices[0] if q.choices else ""
  45. elif q.question_type == QuestionType.FREE_RESPONSE:
  46. response = "Sample free response"
  47. else:
  48. response = ""
  49. responses.append(response)
  50. return QuizResponses(responses=responses)
  51. class FakeText:
  52. def __init__(self, value: str):
  53. self.value = value
  54. class FakeTextContent:
  55. def __init__(self, text: str):
  56. self.type = "text"
  57. self.text = FakeText(text)
  58. class FakeMessage:
  59. def __init__(self, id: str, text: str):
  60. self.id = id
  61. # The agent expects content to be a list of objects with a "type" attribute.
  62. self.content = [FakeTextContent(text)]
  63. class FakeCursorPage:
  64. def __init__(self, data: List[ChatMessage | FakeMessage]) -> None:
  65. self.data = data
  66. def has_next_page(self) -> bool:
  67. return False
  68. def create_mock_openai_client() -> AsyncAzureOpenAI:
  69. # Create the base client as an AsyncMock.
  70. client = AsyncMock(spec=AsyncAzureOpenAI)
  71. # Create a "beta" attribute with the required nested structure.
  72. beta = MagicMock()
  73. client.beta = beta
  74. # Setup beta.assistants with dummy create/retrieve/update/delete.
  75. beta.assistants = MagicMock()
  76. beta.assistants.create = AsyncMock(return_value=MagicMock(id="assistant-mock"))
  77. beta.assistants.retrieve = AsyncMock(return_value=MagicMock(id="assistant-mock"))
  78. beta.assistants.update = AsyncMock(return_value=MagicMock(id="assistant-mock"))
  79. beta.assistants.delete = AsyncMock(return_value=None)
  80. # Setup beta.threads with create and retrieve.
  81. beta.threads = MagicMock()
  82. beta.threads.create = AsyncMock(return_value=MagicMock(id="thread-mock", tool_resources=None))
  83. beta.threads.retrieve = AsyncMock(return_value=MagicMock(id="thread-mock", tool_resources=None))
  84. # Setup beta.threads.messages with create, list, and delete.
  85. beta.threads.messages = MagicMock()
  86. beta.threads.messages.create = AsyncMock(return_value=MagicMock(id="msg-mock", content="mock content"))
  87. # Default fake messages – these may be overridden in individual tests.
  88. name_message = FakeMessage("msg-mock", "Your name is John, you are a software engineer.")
  89. def mock_list(thread_id: str, **kwargs: Dict[str, Any]) -> FakeCursorPage:
  90. # Default behavior returns the "name" message.
  91. if thread_id == "thread-mock":
  92. return FakeCursorPage([name_message])
  93. return FakeCursorPage([FakeMessage("msg-mock", "Default response")])
  94. beta.threads.messages.list = AsyncMock(side_effect=mock_list)
  95. beta.threads.messages.delete = AsyncMock(return_value=MagicMock(deleted=True))
  96. # Setup beta.threads.runs with create, retrieve, and submit_tool_outputs.
  97. beta.threads.runs = MagicMock()
  98. beta.threads.runs.create = AsyncMock(return_value=MagicMock(id="run-mock", status="completed"))
  99. beta.threads.runs.retrieve = AsyncMock(return_value=MagicMock(id="run-mock", status="completed"))
  100. beta.threads.runs.submit_tool_outputs = AsyncMock(return_value=MagicMock(id="run-mock", status="completed"))
  101. # Setup beta.vector_stores with create, delete, and file_batches.
  102. beta.vector_stores = MagicMock()
  103. beta.vector_stores.create = AsyncMock(return_value=MagicMock(id="vector-mock"))
  104. beta.vector_stores.delete = AsyncMock(return_value=None)
  105. beta.vector_stores.file_batches = MagicMock()
  106. beta.vector_stores.file_batches.create_and_poll = AsyncMock(return_value=None)
  107. # Setup client.files with create and delete.
  108. client.files = MagicMock()
  109. client.files.create = AsyncMock(return_value=MagicMock(id="file-mock"))
  110. client.files.delete = AsyncMock(return_value=None)
  111. return client
  112. # Fixture for the mock client.
  113. @pytest.fixture
  114. def mock_openai_client() -> AsyncAzureOpenAI:
  115. return create_mock_openai_client()
  116. @pytest.fixture
  117. def client() -> AsyncAzureOpenAI:
  118. azure_endpoint = os.getenv("AZURE_OPENAI_ENDPOINT")
  119. api_version = os.getenv("AZURE_OPENAI_API_VERSION", "2024-08-01-preview")
  120. api_key = os.getenv("AZURE_OPENAI_API_KEY")
  121. # Return mock client if credentials not available
  122. if not azure_endpoint or not api_key:
  123. return create_mock_openai_client()
  124. # Try Azure CLI credentials if API key not provided
  125. if not api_key:
  126. try:
  127. token_provider = get_bearer_token_provider(
  128. DefaultAzureCredential(), "https://cognitiveservices.azure.com/.default"
  129. )
  130. return AsyncAzureOpenAI(
  131. azure_endpoint=azure_endpoint, api_version=api_version, azure_ad_token_provider=token_provider
  132. )
  133. except Exception:
  134. return create_mock_openai_client()
  135. # Fall back to API key auth if provided
  136. return AsyncAzureOpenAI(azure_endpoint=azure_endpoint, api_version=api_version, api_key=api_key)
  137. @pytest.fixture
  138. def agent(client: AsyncAzureOpenAI) -> OpenAIAssistantAgent:
  139. tools: List[Union[Literal["code_interpreter", "file_search"], Tool]] = [
  140. "code_interpreter",
  141. "file_search",
  142. DisplayQuizTool(),
  143. ]
  144. return OpenAIAssistantAgent(
  145. name="assistant",
  146. instructions="Help the user with their task.",
  147. model="gpt-4o-mini",
  148. description="OpenAI Assistant Agent",
  149. client=client,
  150. tools=tools,
  151. )
  152. @pytest.fixture
  153. def cancellation_token() -> CancellationToken:
  154. return CancellationToken()
  155. # A fake aiofiles.open to bypass filesystem access.
  156. @asynccontextmanager
  157. async def fake_aiofiles_open(*args: Any, **kwargs: Dict[str, Any]) -> AsyncGenerator[io.BytesIO, None]:
  158. yield io.BytesIO(b"dummy file content")
  159. @pytest.mark.asyncio
  160. async def test_file_retrieval(
  161. agent: OpenAIAssistantAgent, cancellation_token: CancellationToken, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
  162. ) -> None:
  163. # Arrange: Define a fake async file opener that returns a file-like object with an async read() method.
  164. class FakeAiofilesFile:
  165. async def read(self) -> bytes:
  166. return b"dummy file content"
  167. @asynccontextmanager
  168. async def fake_async_aiofiles_open(*args: Any, **kwargs: Dict[str, Any]) -> AsyncGenerator[FakeAiofilesFile, None]:
  169. yield FakeAiofilesFile()
  170. monkeypatch.setattr(aiofiles, "open", fake_async_aiofiles_open)
  171. # We also override the messages.list to return a fake file search result.
  172. fake_file_message = FakeMessage(
  173. "msg-mock", "The first sentence of the jungle book is 'Mowgli was raised by wolves.'"
  174. )
  175. agent._client.beta.threads.messages.list = AsyncMock(return_value=FakeCursorPage([fake_file_message])) # type: ignore
  176. # Create a temporary file.
  177. file_path = tmp_path / "jungle_book.txt"
  178. file_path.write_text("dummy content")
  179. await agent.on_upload_for_file_search(str(file_path), cancellation_token)
  180. message = TextMessage(source="user", content="What is the first sentence of the jungle scout book?")
  181. response = await agent.on_messages([message], cancellation_token)
  182. assert response.chat_message.content is not None
  183. assert isinstance(response.chat_message.content, str)
  184. assert len(response.chat_message.content) > 0
  185. await agent.delete_uploaded_files(cancellation_token)
  186. await agent.delete_vector_store(cancellation_token)
  187. await agent.delete_assistant(cancellation_token)
  188. @pytest.mark.asyncio
  189. async def test_code_interpreter(
  190. agent: OpenAIAssistantAgent, cancellation_token: CancellationToken, monkeypatch: pytest.MonkeyPatch
  191. ) -> None:
  192. # Arrange: For code interpreter, have the messages.list return a result with "x = 1".
  193. agent._client.beta.threads.messages.list = AsyncMock( # type: ignore
  194. return_value=FakeCursorPage([FakeMessage("msg-mock", "x = 1")])
  195. )
  196. message = TextMessage(source="user", content="I need to solve the equation `3x + 11 = 14`. Can you help me?")
  197. response = await agent.on_messages([message], cancellation_token)
  198. assert response.chat_message.content is not None
  199. assert isinstance(response.chat_message.content, str)
  200. assert len(response.chat_message.content) > 0
  201. assert "x = 1" in response.chat_message.content.lower()
  202. await agent.delete_assistant(cancellation_token)
  203. @pytest.mark.asyncio
  204. async def test_quiz_creation(
  205. agent: OpenAIAssistantAgent, cancellation_token: CancellationToken, monkeypatch: pytest.MonkeyPatch
  206. ) -> None:
  207. monkeypatch.setattr(DisplayQuizTool, "run_json", DisplayQuizTool.run)
  208. # Create a fake tool call for display_quiz.
  209. fake_tool_call = MagicMock()
  210. fake_tool_call.type = "function"
  211. fake_tool_call.id = "tool-call-1"
  212. fake_tool_call.function = MagicMock()
  213. fake_tool_call.function.name = "display_quiz"
  214. fake_tool_call.function.arguments = (
  215. '{"title": "Quiz Title", "questions": [{"question_text": "What is 2+2?", '
  216. '"question_type": "MULTIPLE_CHOICE", "choices": ["3", "4", "5"]}]}'
  217. )
  218. # Create a run that requires action (tool call).
  219. run_requires_action = MagicMock()
  220. run_requires_action.id = "run-mock"
  221. run_requires_action.status = "requires_action"
  222. run_requires_action.required_action = MagicMock()
  223. run_requires_action.required_action.submit_tool_outputs = MagicMock()
  224. run_requires_action.required_action.submit_tool_outputs.tool_calls = [fake_tool_call]
  225. # Create a completed run for the subsequent retrieval.
  226. run_completed = MagicMock()
  227. run_completed.id = "run-mock"
  228. run_completed.status = "completed"
  229. run_completed.required_action = None
  230. # Set up the beta.threads.runs.retrieve mock to return these in sequence.
  231. agent._client.beta.threads.runs.retrieve.side_effect = [run_requires_action, run_completed] # type: ignore
  232. # Also, set the messages.list call (after run completion) to return a quiz message.
  233. quiz_tool_message = FakeMessage("msg-mock", "Quiz created: Q1) 2+2=? Answer: b) 4; Q2) Free: Sample free response")
  234. agent._client.beta.threads.messages.list = AsyncMock(return_value=FakeCursorPage([quiz_tool_message])) # type: ignore
  235. # Create a user message to trigger the tool invocation.
  236. message = TextMessage(
  237. source="user",
  238. content="Create a short quiz about basic math with one multiple choice question and one free response question.",
  239. )
  240. response = await agent.on_messages([message], cancellation_token)
  241. # Check that the final response has non-empty inner messages (i.e. tool call events).
  242. assert response.chat_message.content is not None
  243. assert isinstance(response.chat_message.content, str)
  244. assert len(response.chat_message.content) > 0
  245. assert isinstance(response.inner_messages, list)
  246. # Ensure that at least one inner message has non-empty content.
  247. assert any(hasattr(tool_msg, "content") and tool_msg.content for tool_msg in response.inner_messages)
  248. await agent.delete_assistant(cancellation_token)
  249. @pytest.mark.asyncio
  250. async def test_on_reset_behavior(client: AsyncAzureOpenAI, cancellation_token: CancellationToken) -> None:
  251. # Arrange: Use the default behavior for reset.
  252. thread = await client.beta.threads.create()
  253. await client.beta.threads.messages.create(
  254. thread_id=thread.id,
  255. content="Hi, my name is John and I'm a software engineer. Use this information to help me.",
  256. role="user",
  257. )
  258. agent = OpenAIAssistantAgent(
  259. name="assistant",
  260. instructions="Help the user with their task.",
  261. model="gpt-4o-mini",
  262. description="OpenAI Assistant Agent",
  263. client=client,
  264. thread_id=thread.id,
  265. )
  266. message1 = TextMessage(source="user", content="What is my name?")
  267. response1 = await agent.on_messages([message1], cancellation_token)
  268. assert isinstance(response1.chat_message.content, str)
  269. assert "john" in response1.chat_message.content.lower()
  270. await agent.on_reset(cancellation_token)
  271. message2 = TextMessage(source="user", content="What is my name?")
  272. response2 = await agent.on_messages([message2], cancellation_token)
  273. assert isinstance(response2.chat_message.content, str)
  274. assert "john" in response2.chat_message.content.lower()
  275. await agent.delete_assistant(cancellation_token)
  276. @pytest.mark.asyncio
  277. async def test_save_and_load_state(mock_openai_client: AsyncAzureOpenAI) -> None:
  278. agent = OpenAIAssistantAgent(
  279. name="assistant",
  280. description="Dummy assistant for state testing",
  281. client=mock_openai_client,
  282. model="dummy-model",
  283. instructions="dummy instructions",
  284. tools=[],
  285. )
  286. agent._assistant_id = "assistant-123" # type: ignore
  287. agent._init_thread_id = "thread-456" # type: ignore
  288. agent._initial_message_ids = {"msg1", "msg2"} # type: ignore
  289. agent._vector_store_id = "vector-789" # type: ignore
  290. agent._uploaded_file_ids = ["file-abc", "file-def"] # type: ignore
  291. saved_state = await agent.save_state()
  292. new_agent = OpenAIAssistantAgent(
  293. name="assistant",
  294. description="Dummy assistant for state testing",
  295. client=mock_openai_client,
  296. model="dummy-model",
  297. instructions="dummy instructions",
  298. tools=[],
  299. )
  300. await new_agent.load_state(saved_state)
  301. assert new_agent._assistant_id == "assistant-123" # type: ignore
  302. assert new_agent._init_thread_id == "thread-456" # type: ignore
  303. assert new_agent._initial_message_ids == {"msg1", "msg2"} # type: ignore
  304. assert new_agent._vector_store_id == "vector-789" # type: ignore
  305. assert new_agent._uploaded_file_ids == ["file-abc", "file-def"] # type: ignore