From cd0dc10de6c598917fb9a2dad8859eb34ff65a28 Mon Sep 17 00:00:00 2001 From: Dmytro Struk <13853051+dmytrostruk@users.noreply.github.com> Date: Tue, 11 Nov 2025 12:23:01 -0800 Subject: [PATCH] Added handling for conversation_id --- .../agent_framework_azure_ai/_client.py | 31 ++- .../azure-ai/tests/test_azure_ai_client.py | 188 ++++++++++++++++++ .../getting_started/agents/azure_ai/README.md | 1 + .../agents/azure_ai/azure_ai_basic.py | 2 +- .../azure_ai_with_existing_conversation.py | 98 +++++++++ 5 files changed, 318 insertions(+), 2 deletions(-) create mode 100644 python/samples/getting_started/agents/azure_ai/azure_ai_with_existing_conversation.py diff --git a/python/packages/azure-ai/agent_framework_azure_ai/_client.py b/python/packages/azure-ai/agent_framework_azure_ai/_client.py index 0713a39bec..cbe45c56d6 100644 --- a/python/packages/azure-ai/agent_framework_azure_ai/_client.py +++ b/python/packages/azure-ai/agent_framework_azure_ai/_client.py @@ -26,7 +26,11 @@ ) from azure.core.credentials_async import AsyncTokenCredential from azure.core.exceptions import ResourceNotFoundError -from pydantic import ValidationError +from openai.types.responses.parsed_response import ( + ParsedResponse, +) +from openai.types.responses.response import Response as OpenAIResponse +from pydantic import BaseModel, ValidationError from ._shared import AzureAISettings @@ -279,6 +283,19 @@ async def prepare_options( run_options["extra_body"] = {"agent": agent_reference} + conversation_id = chat_options.conversation_id or self.conversation_id + + # Handle different conversation ID formats + if conversation_id: + if conversation_id.startswith("resp_"): + # For response IDs, set previous_response_id and remove conversation property + run_options.pop("conversation", None) + run_options["previous_response_id"] = conversation_id + elif conversation_id.startswith("conv_"): + # For conversation IDs, set conversation and remove previous_response_id property + run_options.pop("previous_response_id", None) + run_options["conversation"] = conversation_id + # Remove properties that are not supported on request level # but were configured on agent level exclude = ["model", "tools", "response_format"] @@ -325,3 +342,15 @@ def get_mcp_tool(self, tool: HostedMCPTool) -> MutableMapping[str, Any]: mcp["require_approval"] = {"never": {"tool_names": list(never_require_approvals)}} return mcp + + def get_conversation_id(self, response: OpenAIResponse | ParsedResponse[BaseModel], store: bool) -> str | None: + """Get the conversation ID from the response if store is True.""" + if store: + # If conversation ID exists, it means that we operate with conversation + # so we use conversation ID as input and output. + if response.conversation and response.conversation.id: + return response.conversation.id + # If conversation ID doesn't exist, we operate with responses + # so we use response ID as input and output. + return response.id + return None diff --git a/python/packages/azure-ai/tests/test_azure_ai_client.py b/python/packages/azure-ai/tests/test_azure_ai_client.py index a6a0ba9085..d3f469b753 100644 --- a/python/packages/azure-ai/tests/test_azure_ai_client.py +++ b/python/packages/azure-ai/tests/test_azure_ai_client.py @@ -14,6 +14,8 @@ from azure.ai.projects.models import ( ResponseTextFormatConfigurationJsonSchema, ) +from openai.types.responses.parsed_response import ParsedResponse +from openai.types.responses.response import Response as OpenAIResponse from pydantic import BaseModel, ConfigDict, ValidationError from agent_framework_azure_ai import AzureAIClient, AzureAISettings @@ -537,6 +539,192 @@ async def test_azure_ai_client_prepare_options_excludes_response_format( assert run_options["extra_body"]["agent"]["name"] == "test-agent" +async def test_azure_ai_client_prepare_options_with_resp_conversation_id( + mock_project_client: MagicMock, +) -> None: + """Test prepare_options with conversation ID starting with 'resp_'.""" + client = create_test_azure_ai_client(mock_project_client, agent_name="test-agent", agent_version="1.0") + + messages = [ChatMessage(role=Role.USER, contents=[TextContent(text="Hello")])] + chat_options = ChatOptions(conversation_id="resp_12345") + + with ( + patch.object( + client.__class__.__bases__[0], + "prepare_options", + return_value={"model": "test-model", "previous_response_id": "old_value", "conversation": "old_conv"}, + ), + patch.object( + client, + "_get_agent_reference_or_create", + return_value={"name": "test-agent", "version": "1.0", "type": "agent_reference"}, + ), + ): + run_options = await client.prepare_options(messages, chat_options) + + # Should set previous_response_id and remove conversation property + assert run_options["previous_response_id"] == "resp_12345" + assert "conversation" not in run_options + + +async def test_azure_ai_client_prepare_options_with_conv_conversation_id( + mock_project_client: MagicMock, +) -> None: + """Test prepare_options with conversation ID starting with 'conv_'.""" + client = create_test_azure_ai_client(mock_project_client, agent_name="test-agent", agent_version="1.0") + + messages = [ChatMessage(role=Role.USER, contents=[TextContent(text="Hello")])] + chat_options = ChatOptions(conversation_id="conv_67890") + + with ( + patch.object( + client.__class__.__bases__[0], + "prepare_options", + return_value={"model": "test-model", "previous_response_id": "old_value", "conversation": "old_conv"}, + ), + patch.object( + client, + "_get_agent_reference_or_create", + return_value={"name": "test-agent", "version": "1.0", "type": "agent_reference"}, + ), + ): + run_options = await client.prepare_options(messages, chat_options) + + # Should set conversation and remove previous_response_id property + assert run_options["conversation"] == "conv_67890" + assert "previous_response_id" not in run_options + + +async def test_azure_ai_client_prepare_options_with_client_conversation_id( + mock_project_client: MagicMock, +) -> None: + """Test prepare_options using client's default conversation ID when chat options don't have one.""" + client = create_test_azure_ai_client( + mock_project_client, agent_name="test-agent", agent_version="1.0", conversation_id="resp_client_default" + ) + + messages = [ChatMessage(role=Role.USER, contents=[TextContent(text="Hello")])] + chat_options = ChatOptions() # No conversation_id specified + + with ( + patch.object( + client.__class__.__bases__[0], + "prepare_options", + return_value={"model": "test-model", "previous_response_id": "old_value", "conversation": "old_conv"}, + ), + patch.object( + client, + "_get_agent_reference_or_create", + return_value={"name": "test-agent", "version": "1.0", "type": "agent_reference"}, + ), + ): + run_options = await client.prepare_options(messages, chat_options) + + # Should use client's default conversation_id and set previous_response_id + assert run_options["previous_response_id"] == "resp_client_default" + assert "conversation" not in run_options + + +def test_get_conversation_id_with_store_true_and_conversation_id() -> None: + """Test get_conversation_id returns conversation ID when store is True and conversation exists.""" + client = create_test_azure_ai_client(MagicMock()) + + # Mock OpenAI response with conversation + mock_response = MagicMock(spec=OpenAIResponse) + mock_response.id = "resp_12345" + mock_conversation = MagicMock() + mock_conversation.id = "conv_67890" + mock_response.conversation = mock_conversation + + result = client.get_conversation_id(mock_response, store=True) + + assert result == "conv_67890" + + +def test_get_conversation_id_with_store_true_and_no_conversation() -> None: + """Test get_conversation_id returns response ID when store is True and no conversation exists.""" + client = create_test_azure_ai_client(MagicMock()) + + # Mock OpenAI response without conversation + mock_response = MagicMock(spec=OpenAIResponse) + mock_response.id = "resp_12345" + mock_response.conversation = None + + result = client.get_conversation_id(mock_response, store=True) + + assert result == "resp_12345" + + +def test_get_conversation_id_with_store_true_and_empty_conversation_id() -> None: + """Test get_conversation_id returns response ID when store is True and conversation ID is empty.""" + client = create_test_azure_ai_client(MagicMock()) + + # Mock OpenAI response with conversation but empty ID + mock_response = MagicMock(spec=OpenAIResponse) + mock_response.id = "resp_12345" + mock_conversation = MagicMock() + mock_conversation.id = "" + mock_response.conversation = mock_conversation + + result = client.get_conversation_id(mock_response, store=True) + + assert result == "resp_12345" + + +def test_get_conversation_id_with_store_false() -> None: + """Test get_conversation_id returns None when store is False.""" + client = create_test_azure_ai_client(MagicMock()) + + # Mock OpenAI response with conversation + mock_response = MagicMock(spec=OpenAIResponse) + mock_response.id = "resp_12345" + mock_conversation = MagicMock() + mock_conversation.id = "conv_67890" + mock_response.conversation = mock_conversation + + result = client.get_conversation_id(mock_response, store=False) + + assert result is None + + +def test_get_conversation_id_with_parsed_response_and_store_true() -> None: + """Test get_conversation_id works with ParsedResponse when store is True.""" + client = create_test_azure_ai_client(MagicMock()) + + # Create a simple BaseModel for testing + class TestModel(BaseModel): + content: str = "test" + + # Mock ParsedResponse with conversation + mock_response = MagicMock(spec=ParsedResponse[BaseModel]) + mock_response.id = "resp_parsed_12345" + mock_conversation = MagicMock() + mock_conversation.id = "conv_parsed_67890" + mock_response.conversation = mock_conversation + + result = client.get_conversation_id(mock_response, store=True) + + assert result == "conv_parsed_67890" + + +def test_get_conversation_id_with_parsed_response_no_conversation() -> None: + """Test get_conversation_id returns response ID with ParsedResponse when no conversation exists.""" + client = create_test_azure_ai_client(MagicMock()) + + # Create a simple BaseModel for testing + class TestModel(BaseModel): + content: str = "test" + + # Mock ParsedResponse without conversation + mock_response = MagicMock(spec=ParsedResponse[BaseModel]) + mock_response.id = "resp_parsed_12345" + mock_response.conversation = None + + result = client.get_conversation_id(mock_response, store=True) + + assert result == "resp_parsed_12345" + + @pytest.fixture def mock_project_client() -> MagicMock: """Fixture that provides a mock AIProjectClient.""" diff --git a/python/samples/getting_started/agents/azure_ai/README.md b/python/samples/getting_started/agents/azure_ai/README.md index 0334ca4342..7016cb1e5e 100644 --- a/python/samples/getting_started/agents/azure_ai/README.md +++ b/python/samples/getting_started/agents/azure_ai/README.md @@ -10,6 +10,7 @@ This folder contains examples demonstrating different ways to create and use age | [`azure_ai_use_latest_version.py`](azure_ai_use_latest_version.py) | Demonstrates how to reuse the latest version of an existing agent instead of creating a new agent version on each instantiation using the `use_latest_version=True` parameter. | | [`azure_ai_with_code_interpreter.py`](azure_ai_with_code_interpreter.py) | Shows how to use the `HostedCodeInterpreterTool` with Azure AI agents to write and execute Python code for mathematical problem solving and data analysis. | | [`azure_ai_with_existing_agent.py`](azure_ai_with_existing_agent.py) | Shows how to work with a pre-existing agent by providing the agent name and version to the Azure AI client. Demonstrates agent reuse patterns for production scenarios. | +| [`azure_ai_with_existing_conversation.py`](azure_ai_with_existing_conversation.py) | Demonstrates how to use an existing conversation created on the service side with Azure AI agents. Shows two approaches: specifying conversation ID at the client level and using AgentThread with an existing conversation ID. | | [`azure_ai_with_explicit_settings.py`](azure_ai_with_explicit_settings.py) | Shows how to create an agent with explicitly configured `AzureAIClient` settings, including project endpoint, model deployment, and credentials rather than relying on environment variable defaults. | | [`azure_ai_with_file_search.py`](azure_ai_with_file_search.py) | Shows how to use the `HostedFileSearchTool` with Azure AI agents to upload files, create vector stores, and enable agents to search through uploaded documents to answer user questions. | | [`azure_ai_with_hosted_mcp.py`](azure_ai_with_hosted_mcp.py) | Shows how to integrate hosted Model Context Protocol (MCP) tools with Azure AI Agent. | diff --git a/python/samples/getting_started/agents/azure_ai/azure_ai_basic.py b/python/samples/getting_started/agents/azure_ai/azure_ai_basic.py index 92251905b4..87a121d015 100644 --- a/python/samples/getting_started/agents/azure_ai/azure_ai_basic.py +++ b/python/samples/getting_started/agents/azure_ai/azure_ai_basic.py @@ -60,7 +60,7 @@ async def streaming_example() -> None: tools=get_weather, ) as agent, ): - query = "What's the weather like in Portland?" + query = "What's the weather like in Tokyo?" print(f"User: {query}") print("Agent: ", end="", flush=True) async for chunk in agent.run_stream(query): diff --git a/python/samples/getting_started/agents/azure_ai/azure_ai_with_existing_conversation.py b/python/samples/getting_started/agents/azure_ai/azure_ai_with_existing_conversation.py new file mode 100644 index 0000000000..a268b0db0e --- /dev/null +++ b/python/samples/getting_started/agents/azure_ai/azure_ai_with_existing_conversation.py @@ -0,0 +1,98 @@ +# Copyright (c) Microsoft. All rights reserved. +import asyncio +import os +from random import randint +from typing import Annotated + +from agent_framework.azure import AzureAIClient +from azure.ai.projects.aio import AIProjectClient +from azure.identity.aio import AzureCliCredential +from pydantic import Field + +""" +Azure AI Agent Existing Conversation Example + +This sample demonstrates usage of AzureAIClient with existing conversation created on service side. +""" + + +def get_weather( + location: Annotated[str, Field(description="The location to get the weather for.")], +) -> str: + """Get the weather for a given location.""" + conditions = ["sunny", "cloudy", "rainy", "stormy"] + return f"The weather in {location} is {conditions[randint(0, 3)]} with a high of {randint(10, 30)}°C." + + +async def example_with_client() -> None: + """Example shows how to specify existing conversation ID when initializing Azure AI Client.""" + print("=== Azure AI Agent With Existing Conversation and Client ===") + async with ( + AzureCliCredential() as credential, + AIProjectClient(endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], credential=credential) as project_client, + ): + # Create a conversation using OpenAI client + openai_client = await project_client.get_openai_client() + conversation = await openai_client.conversations.create() + conversation_id = conversation.id + print(f"Conversation ID: {conversation_id}") + + async with AzureAIClient( + project_client=project_client, + # Specify conversation ID on client level + conversation_id=conversation_id, + ).create_agent( + name="BasicAgent", + instructions="You are a helpful agent.", + tools=get_weather, + ) as agent: + query = "What's the weather like in Seattle?" + print(f"User: {query}") + result = await agent.run(query) + print(f"Agent: {result.text}\n") + + query = "What was my last question?" + print(f"User: {query}") + result = await agent.run(query) + print(f"Agent: {result.text}\n") + + +async def example_with_thread() -> None: + """This example shows how to specify existing conversation ID with AgentThread.""" + print("=== Azure AI Agent With Existing Conversation and Thread ===") + async with ( + AzureCliCredential() as credential, + AIProjectClient(endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], credential=credential) as project_client, + AzureAIClient(project_client=project_client).create_agent( + name="BasicAgent", + instructions="You are a helpful agent.", + tools=get_weather, + ) as agent, + ): + # Create a conversation using OpenAI client + openai_client = await project_client.get_openai_client() + conversation = await openai_client.conversations.create() + conversation_id = conversation.id + print(f"Conversation ID: {conversation_id}") + + # Create a thread with the existing ID + thread = agent.get_new_thread(service_thread_id=conversation_id) + + query = "What's the weather like in Seattle?" + print(f"User: {query}") + result = await agent.run(query, thread=thread) + print(f"Agent: {result.text}\n") + + query = "What was my last question?" + print(f"User: {query}") + result = await agent.run(query, thread=thread) + print(f"Agent: {result.text}\n") + + +async def main() -> None: + await example_with_client() + await example_with_thread() + + +if __name__ == "__main__": + asyncio.run(main())