Skip to content

Commit bbb744f

Browse files
committed
Added handling for conversation_id (microsoft#2098)
1 parent f8f90ff commit bbb744f

5 files changed

Lines changed: 318 additions & 2 deletions

File tree

python/packages/azure-ai/agent_framework_azure_ai/_client.py

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,11 @@
2626
)
2727
from azure.core.credentials_async import AsyncTokenCredential
2828
from azure.core.exceptions import ResourceNotFoundError
29-
from pydantic import ValidationError
29+
from openai.types.responses.parsed_response import (
30+
ParsedResponse,
31+
)
32+
from openai.types.responses.response import Response as OpenAIResponse
33+
from pydantic import BaseModel, ValidationError
3034

3135
from ._shared import AzureAISettings
3236

@@ -279,6 +283,19 @@ async def prepare_options(
279283

280284
run_options["extra_body"] = {"agent": agent_reference}
281285

286+
conversation_id = chat_options.conversation_id or self.conversation_id
287+
288+
# Handle different conversation ID formats
289+
if conversation_id:
290+
if conversation_id.startswith("resp_"):
291+
# For response IDs, set previous_response_id and remove conversation property
292+
run_options.pop("conversation", None)
293+
run_options["previous_response_id"] = conversation_id
294+
elif conversation_id.startswith("conv_"):
295+
# For conversation IDs, set conversation and remove previous_response_id property
296+
run_options.pop("previous_response_id", None)
297+
run_options["conversation"] = conversation_id
298+
282299
# Remove properties that are not supported on request level
283300
# but were configured on agent level
284301
exclude = ["model", "tools", "response_format"]
@@ -325,3 +342,15 @@ def get_mcp_tool(self, tool: HostedMCPTool) -> MutableMapping[str, Any]:
325342
mcp["require_approval"] = {"never": {"tool_names": list(never_require_approvals)}}
326343

327344
return mcp
345+
346+
def get_conversation_id(self, response: OpenAIResponse | ParsedResponse[BaseModel], store: bool) -> str | None:
347+
"""Get the conversation ID from the response if store is True."""
348+
if store:
349+
# If conversation ID exists, it means that we operate with conversation
350+
# so we use conversation ID as input and output.
351+
if response.conversation and response.conversation.id:
352+
return response.conversation.id
353+
# If conversation ID doesn't exist, we operate with responses
354+
# so we use response ID as input and output.
355+
return response.id
356+
return None

python/packages/azure-ai/tests/test_azure_ai_client.py

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
from azure.ai.projects.models import (
1515
ResponseTextFormatConfigurationJsonSchema,
1616
)
17+
from openai.types.responses.parsed_response import ParsedResponse
18+
from openai.types.responses.response import Response as OpenAIResponse
1719
from pydantic import BaseModel, ConfigDict, ValidationError
1820

1921
from agent_framework_azure_ai import AzureAIClient, AzureAISettings
@@ -537,6 +539,192 @@ async def test_azure_ai_client_prepare_options_excludes_response_format(
537539
assert run_options["extra_body"]["agent"]["name"] == "test-agent"
538540

539541

542+
async def test_azure_ai_client_prepare_options_with_resp_conversation_id(
543+
mock_project_client: MagicMock,
544+
) -> None:
545+
"""Test prepare_options with conversation ID starting with 'resp_'."""
546+
client = create_test_azure_ai_client(mock_project_client, agent_name="test-agent", agent_version="1.0")
547+
548+
messages = [ChatMessage(role=Role.USER, contents=[TextContent(text="Hello")])]
549+
chat_options = ChatOptions(conversation_id="resp_12345")
550+
551+
with (
552+
patch.object(
553+
client.__class__.__bases__[0],
554+
"prepare_options",
555+
return_value={"model": "test-model", "previous_response_id": "old_value", "conversation": "old_conv"},
556+
),
557+
patch.object(
558+
client,
559+
"_get_agent_reference_or_create",
560+
return_value={"name": "test-agent", "version": "1.0", "type": "agent_reference"},
561+
),
562+
):
563+
run_options = await client.prepare_options(messages, chat_options)
564+
565+
# Should set previous_response_id and remove conversation property
566+
assert run_options["previous_response_id"] == "resp_12345"
567+
assert "conversation" not in run_options
568+
569+
570+
async def test_azure_ai_client_prepare_options_with_conv_conversation_id(
571+
mock_project_client: MagicMock,
572+
) -> None:
573+
"""Test prepare_options with conversation ID starting with 'conv_'."""
574+
client = create_test_azure_ai_client(mock_project_client, agent_name="test-agent", agent_version="1.0")
575+
576+
messages = [ChatMessage(role=Role.USER, contents=[TextContent(text="Hello")])]
577+
chat_options = ChatOptions(conversation_id="conv_67890")
578+
579+
with (
580+
patch.object(
581+
client.__class__.__bases__[0],
582+
"prepare_options",
583+
return_value={"model": "test-model", "previous_response_id": "old_value", "conversation": "old_conv"},
584+
),
585+
patch.object(
586+
client,
587+
"_get_agent_reference_or_create",
588+
return_value={"name": "test-agent", "version": "1.0", "type": "agent_reference"},
589+
),
590+
):
591+
run_options = await client.prepare_options(messages, chat_options)
592+
593+
# Should set conversation and remove previous_response_id property
594+
assert run_options["conversation"] == "conv_67890"
595+
assert "previous_response_id" not in run_options
596+
597+
598+
async def test_azure_ai_client_prepare_options_with_client_conversation_id(
599+
mock_project_client: MagicMock,
600+
) -> None:
601+
"""Test prepare_options using client's default conversation ID when chat options don't have one."""
602+
client = create_test_azure_ai_client(
603+
mock_project_client, agent_name="test-agent", agent_version="1.0", conversation_id="resp_client_default"
604+
)
605+
606+
messages = [ChatMessage(role=Role.USER, contents=[TextContent(text="Hello")])]
607+
chat_options = ChatOptions() # No conversation_id specified
608+
609+
with (
610+
patch.object(
611+
client.__class__.__bases__[0],
612+
"prepare_options",
613+
return_value={"model": "test-model", "previous_response_id": "old_value", "conversation": "old_conv"},
614+
),
615+
patch.object(
616+
client,
617+
"_get_agent_reference_or_create",
618+
return_value={"name": "test-agent", "version": "1.0", "type": "agent_reference"},
619+
),
620+
):
621+
run_options = await client.prepare_options(messages, chat_options)
622+
623+
# Should use client's default conversation_id and set previous_response_id
624+
assert run_options["previous_response_id"] == "resp_client_default"
625+
assert "conversation" not in run_options
626+
627+
628+
def test_get_conversation_id_with_store_true_and_conversation_id() -> None:
629+
"""Test get_conversation_id returns conversation ID when store is True and conversation exists."""
630+
client = create_test_azure_ai_client(MagicMock())
631+
632+
# Mock OpenAI response with conversation
633+
mock_response = MagicMock(spec=OpenAIResponse)
634+
mock_response.id = "resp_12345"
635+
mock_conversation = MagicMock()
636+
mock_conversation.id = "conv_67890"
637+
mock_response.conversation = mock_conversation
638+
639+
result = client.get_conversation_id(mock_response, store=True)
640+
641+
assert result == "conv_67890"
642+
643+
644+
def test_get_conversation_id_with_store_true_and_no_conversation() -> None:
645+
"""Test get_conversation_id returns response ID when store is True and no conversation exists."""
646+
client = create_test_azure_ai_client(MagicMock())
647+
648+
# Mock OpenAI response without conversation
649+
mock_response = MagicMock(spec=OpenAIResponse)
650+
mock_response.id = "resp_12345"
651+
mock_response.conversation = None
652+
653+
result = client.get_conversation_id(mock_response, store=True)
654+
655+
assert result == "resp_12345"
656+
657+
658+
def test_get_conversation_id_with_store_true_and_empty_conversation_id() -> None:
659+
"""Test get_conversation_id returns response ID when store is True and conversation ID is empty."""
660+
client = create_test_azure_ai_client(MagicMock())
661+
662+
# Mock OpenAI response with conversation but empty ID
663+
mock_response = MagicMock(spec=OpenAIResponse)
664+
mock_response.id = "resp_12345"
665+
mock_conversation = MagicMock()
666+
mock_conversation.id = ""
667+
mock_response.conversation = mock_conversation
668+
669+
result = client.get_conversation_id(mock_response, store=True)
670+
671+
assert result == "resp_12345"
672+
673+
674+
def test_get_conversation_id_with_store_false() -> None:
675+
"""Test get_conversation_id returns None when store is False."""
676+
client = create_test_azure_ai_client(MagicMock())
677+
678+
# Mock OpenAI response with conversation
679+
mock_response = MagicMock(spec=OpenAIResponse)
680+
mock_response.id = "resp_12345"
681+
mock_conversation = MagicMock()
682+
mock_conversation.id = "conv_67890"
683+
mock_response.conversation = mock_conversation
684+
685+
result = client.get_conversation_id(mock_response, store=False)
686+
687+
assert result is None
688+
689+
690+
def test_get_conversation_id_with_parsed_response_and_store_true() -> None:
691+
"""Test get_conversation_id works with ParsedResponse when store is True."""
692+
client = create_test_azure_ai_client(MagicMock())
693+
694+
# Create a simple BaseModel for testing
695+
class TestModel(BaseModel):
696+
content: str = "test"
697+
698+
# Mock ParsedResponse with conversation
699+
mock_response = MagicMock(spec=ParsedResponse[BaseModel])
700+
mock_response.id = "resp_parsed_12345"
701+
mock_conversation = MagicMock()
702+
mock_conversation.id = "conv_parsed_67890"
703+
mock_response.conversation = mock_conversation
704+
705+
result = client.get_conversation_id(mock_response, store=True)
706+
707+
assert result == "conv_parsed_67890"
708+
709+
710+
def test_get_conversation_id_with_parsed_response_no_conversation() -> None:
711+
"""Test get_conversation_id returns response ID with ParsedResponse when no conversation exists."""
712+
client = create_test_azure_ai_client(MagicMock())
713+
714+
# Create a simple BaseModel for testing
715+
class TestModel(BaseModel):
716+
content: str = "test"
717+
718+
# Mock ParsedResponse without conversation
719+
mock_response = MagicMock(spec=ParsedResponse[BaseModel])
720+
mock_response.id = "resp_parsed_12345"
721+
mock_response.conversation = None
722+
723+
result = client.get_conversation_id(mock_response, store=True)
724+
725+
assert result == "resp_parsed_12345"
726+
727+
540728
@pytest.fixture
541729
def mock_project_client() -> MagicMock:
542730
"""Fixture that provides a mock AIProjectClient."""

python/samples/getting_started/agents/azure_ai/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ This folder contains examples demonstrating different ways to create and use age
1010
| [`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. |
1111
| [`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. |
1212
| [`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. |
13+
| [`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. |
1314
| [`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. |
1415
| [`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. |
1516
| [`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. |

python/samples/getting_started/agents/azure_ai/azure_ai_basic.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ async def streaming_example() -> None:
6060
tools=get_weather,
6161
) as agent,
6262
):
63-
query = "What's the weather like in Portland?"
63+
query = "What's the weather like in Tokyo?"
6464
print(f"User: {query}")
6565
print("Agent: ", end="", flush=True)
6666
async for chunk in agent.run_stream(query):
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
# Copyright (c) Microsoft. All rights reserved.
2+
import asyncio
3+
import os
4+
from random import randint
5+
from typing import Annotated
6+
7+
from agent_framework.azure import AzureAIClient
8+
from azure.ai.projects.aio import AIProjectClient
9+
from azure.identity.aio import AzureCliCredential
10+
from pydantic import Field
11+
12+
"""
13+
Azure AI Agent Existing Conversation Example
14+
15+
This sample demonstrates usage of AzureAIClient with existing conversation created on service side.
16+
"""
17+
18+
19+
def get_weather(
20+
location: Annotated[str, Field(description="The location to get the weather for.")],
21+
) -> str:
22+
"""Get the weather for a given location."""
23+
conditions = ["sunny", "cloudy", "rainy", "stormy"]
24+
return f"The weather in {location} is {conditions[randint(0, 3)]} with a high of {randint(10, 30)}°C."
25+
26+
27+
async def example_with_client() -> None:
28+
"""Example shows how to specify existing conversation ID when initializing Azure AI Client."""
29+
print("=== Azure AI Agent With Existing Conversation and Client ===")
30+
async with (
31+
AzureCliCredential() as credential,
32+
AIProjectClient(endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], credential=credential) as project_client,
33+
):
34+
# Create a conversation using OpenAI client
35+
openai_client = await project_client.get_openai_client()
36+
conversation = await openai_client.conversations.create()
37+
conversation_id = conversation.id
38+
print(f"Conversation ID: {conversation_id}")
39+
40+
async with AzureAIClient(
41+
project_client=project_client,
42+
# Specify conversation ID on client level
43+
conversation_id=conversation_id,
44+
).create_agent(
45+
name="BasicAgent",
46+
instructions="You are a helpful agent.",
47+
tools=get_weather,
48+
) as agent:
49+
query = "What's the weather like in Seattle?"
50+
print(f"User: {query}")
51+
result = await agent.run(query)
52+
print(f"Agent: {result.text}\n")
53+
54+
query = "What was my last question?"
55+
print(f"User: {query}")
56+
result = await agent.run(query)
57+
print(f"Agent: {result.text}\n")
58+
59+
60+
async def example_with_thread() -> None:
61+
"""This example shows how to specify existing conversation ID with AgentThread."""
62+
print("=== Azure AI Agent With Existing Conversation and Thread ===")
63+
async with (
64+
AzureCliCredential() as credential,
65+
AIProjectClient(endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], credential=credential) as project_client,
66+
AzureAIClient(project_client=project_client).create_agent(
67+
name="BasicAgent",
68+
instructions="You are a helpful agent.",
69+
tools=get_weather,
70+
) as agent,
71+
):
72+
# Create a conversation using OpenAI client
73+
openai_client = await project_client.get_openai_client()
74+
conversation = await openai_client.conversations.create()
75+
conversation_id = conversation.id
76+
print(f"Conversation ID: {conversation_id}")
77+
78+
# Create a thread with the existing ID
79+
thread = agent.get_new_thread(service_thread_id=conversation_id)
80+
81+
query = "What's the weather like in Seattle?"
82+
print(f"User: {query}")
83+
result = await agent.run(query, thread=thread)
84+
print(f"Agent: {result.text}\n")
85+
86+
query = "What was my last question?"
87+
print(f"User: {query}")
88+
result = await agent.run(query, thread=thread)
89+
print(f"Agent: {result.text}\n")
90+
91+
92+
async def main() -> None:
93+
await example_with_client()
94+
await example_with_thread()
95+
96+
97+
if __name__ == "__main__":
98+
asyncio.run(main())

0 commit comments

Comments
 (0)