From 21ee796ec61e19e0ab80c95fc145f6c99746aebe Mon Sep 17 00:00:00 2001 From: NirajC3-Microsoft Date: Tue, 11 Aug 2026 15:09:49 +0530 Subject: [PATCH 1/4] chore: Add convesation Id for voice chat and normal chat from text input --- chat-app/backend/app/config.py | 5 +++++ chat-app/backend/app/routers/chat.py | 17 +++++++++++++---- chat-app/backend/app/routers/voice_live.py | 17 +++++++++++------ .../backend/app/utils/foundry_agent_utils.py | 18 +++++++++++++++++- .../src/components/EnhancedChatPanel.tsx | 3 ++- scenario-app/backend/app/config.py | 6 +++++- scenario-app/backend/app/routers/chat.py | 16 +++++++++++++--- scenario-app/backend/app/routers/voice_live.py | 17 +++++++++++------ .../backend/app/utils/foundry_agent_utils.py | 18 +++++++++++++++++- .../src/components/EnhancedChatPanel.tsx | 3 ++- 10 files changed, 96 insertions(+), 24 deletions(-) diff --git a/chat-app/backend/app/config.py b/chat-app/backend/app/config.py index 9f8f4079..f267f425 100644 --- a/chat-app/backend/app/config.py +++ b/chat-app/backend/app/config.py @@ -2,6 +2,7 @@ from pathlib import Path from typing import List, Optional +from cachetools import TTLCache from pydantic_settings import BaseSettings _current_dir = Path(__file__).parent @@ -103,6 +104,10 @@ class Config: settings = Settings() +# Shared cache mapping session_id -> Azure AI conversation_id (conv_xxx) +# Used by both text chat (chat.py) and voice (foundry_agent_utils.py) +conversation_cache: TTLCache = TTLCache(maxsize=1000, ttl=3600.0) + def get_settings() -> Settings: return settings diff --git a/chat-app/backend/app/routers/chat.py b/chat-app/backend/app/routers/chat.py index dc937afb..b997f620 100644 --- a/chat-app/backend/app/routers/chat.py +++ b/chat-app/backend/app/routers/chat.py @@ -10,7 +10,7 @@ try: # Try relative imports first (for Docker) from ..auth import get_current_user_optional - from ..config import settings + from ..config import settings, conversation_cache from ..cosmos_service import get_cosmos_service from ..models import ( APIResponse, @@ -36,9 +36,8 @@ ) from app.cosmos_service import get_cosmos_service - from app.config import settings + from app.config import settings, conversation_cache from app.auth import get_current_user_optional - from agent_framework.azure import AzureAIProjectAgentProvider from azure.ai.projects.aio import AIProjectClient @@ -395,6 +394,16 @@ async def send_message_legacy( catalog_tool = catalog_tool_name() policy_tool = policy_tool_name() + # Get or create Azure AI conversation for this session + conv_id = conversation_cache.get(session_id) + if not conv_id: + openai_client = project_client.get_openai_client() + conv = await openai_client.conversations.create() + conv_id = conv.id + conversation_cache[session_id] = conv_id + await openai_client.close() + logger.info("Created Azure AI conversation %s for session %s", conv_id, session_id) + for attempt in range(max_retries): try: retrieved_agent = await provider.get_agent( @@ -405,7 +414,7 @@ async def send_message_legacy( ], ) question = message.content - result = await retrieved_agent.run(question) + result = await retrieved_agent.run(question, options={"conversation_id": conv_id}) track_event_if_configured("Agent_Response_Received", {"session_id": session_id, "user_id": user_id}) break # Success, exit retry loop diff --git a/chat-app/backend/app/routers/voice_live.py b/chat-app/backend/app/routers/voice_live.py index 34647c5b..cf67cb8f 100644 --- a/chat-app/backend/app/routers/voice_live.py +++ b/chat-app/backend/app/routers/voice_live.py @@ -66,7 +66,7 @@ def _build_foundry_agent_tool() -> FunctionTool: ) -async def _call_foundry_agent(question: str) -> str: +async def _call_foundry_agent(question: str, conversation_id: str = "") -> str: """Delegate to foundry_agent_utils.""" client_id = str(settings.azure_client_id) if settings.azure_client_id else None return await call_foundry_agent( @@ -76,6 +76,7 @@ async def _call_foundry_agent(question: str) -> str: product_agent_name=settings.foundry_product_agent, policy_agent_name=settings.foundry_policy_agent, azure_client_id=client_id, + conversation_id=conversation_id or None, ) @@ -96,8 +97,10 @@ def __init__( credential: Any, send_message, config: VoiceSessionConfig, + session_id: str = "", ): self.client_id = client_id + self.session_id = session_id or client_id self.endpoint = endpoint self.credential = credential self.send = send_message @@ -347,7 +350,7 @@ async def _handle_event(self, event, connection) -> None: question = args.get("question", "") if name == "ask_customer_service": # Run Foundry agent with keep-alive pings to prevent WS timeout - agent_task = asyncio.create_task(_call_foundry_agent(question)) + agent_task = asyncio.create_task(_call_foundry_agent(question, conversation_id=self.session_id)) while not agent_task.done(): await asyncio.sleep(2) if not agent_task.done(): @@ -616,12 +619,13 @@ async def text_to_speech(request: Request): @router.websocket("/ws/{client_id}") async def websocket_endpoint(websocket: WebSocket, client_id: str): await websocket.accept() + session_id = websocket.query_params.get("session_id", client_id) try: while True: data = await websocket.receive_text() message = json.loads(data) - await _handle_message(client_id, message, websocket) + await _handle_message(client_id, message, websocket, session_id=session_id) except WebSocketDisconnect: logger.info("Voice client disconnected: %s", client_id) except Exception as exc: @@ -630,12 +634,12 @@ async def websocket_endpoint(websocket: WebSocket, client_id: str): await _cleanup_client(client_id) -async def _handle_message(client_id: str, message: dict, websocket: WebSocket): +async def _handle_message(client_id: str, message: dict, websocket: WebSocket, session_id: str = ""): msg_type = message.get("type") if msg_type == "start_session": config = {k: v for k, v in message.items() if k != "type"} - await _start_session(client_id, config, websocket) + await _start_session(client_id, config, websocket, session_id=session_id) elif msg_type == "stop_session": await _stop_session(client_id, websocket) @@ -651,7 +655,7 @@ async def _handle_message(client_id: str, message: dict, websocket: WebSocket): await handler.interrupt() -async def _start_session(client_id: str, config: dict, websocket: WebSocket): +async def _start_session(client_id: str, config: dict, websocket: WebSocket, session_id: str = ""): endpoint = resolve_endpoint(settings.azure_voicelive_endpoint, settings.azure_openai_endpoint) if not endpoint: await websocket.send_text( @@ -696,6 +700,7 @@ async def send_to_client(msg: dict): credential=credential, send_message=send_to_client, config=session_config, + session_id=session_id, ) previous_handler = _handlers.get(client_id) diff --git a/chat-app/backend/app/utils/foundry_agent_utils.py b/chat-app/backend/app/utils/foundry_agent_utils.py index 565bd081..e3b76730 100644 --- a/chat-app/backend/app/utils/foundry_agent_utils.py +++ b/chat-app/backend/app/utils/foundry_agent_utils.py @@ -3,6 +3,11 @@ logger = logging.getLogger(__name__) +try: + from ..config import conversation_cache +except ImportError: + from app.config import conversation_cache + async def call_foundry_agent( question: str, @@ -11,6 +16,7 @@ async def call_foundry_agent( product_agent_name: str, policy_agent_name: str, azure_client_id: Optional[str] = None, + conversation_id: Optional[str] = None, ) -> str: try: from agent_framework.azure import AzureAIProjectAgentProvider @@ -50,7 +56,17 @@ async def call_foundry_agent( ], ) - result = await retrieved_agent.run(question) + # Get or create Azure AI conversation for tracing + conv_id = conversation_cache.get(conversation_id) if conversation_id else None + if not conv_id: + openai_client = project_client.get_openai_client() + conv = await openai_client.conversations.create() + conv_id = conv.id + if conversation_id: + conversation_cache[conversation_id] = conv_id + await openai_client.close() + + result = await retrieved_agent.run(question, options={"conversation_id": conv_id}) if result and hasattr(result, "text"): return result.text diff --git a/chat-app/frontend/src/components/EnhancedChatPanel.tsx b/chat-app/frontend/src/components/EnhancedChatPanel.tsx index 8b18218d..0010104e 100644 --- a/chat-app/frontend/src/components/EnhancedChatPanel.tsx +++ b/chat-app/frontend/src/components/EnhancedChatPanel.tsx @@ -482,7 +482,8 @@ export const EnhancedChatPanel = ({ const apiBase = getApiBaseUrl(); const apiUrl = new URL(apiBase); const wsProtocol = apiUrl.protocol === 'https:' ? 'wss:' : 'ws:'; - const wsUrl = `${wsProtocol}//${apiUrl.host}/api/voice/ws/${clientIdRef.current}`; + const storedSessionId = localStorage.getItem('current_chat_session_id') || ''; + const wsUrl = `${wsProtocol}//${apiUrl.host}/api/voice/ws/${clientIdRef.current}${storedSessionId ? `?session_id=${storedSessionId}` : ''}`; const ws = new WebSocket(wsUrl); wsRef.current = ws; diff --git a/scenario-app/backend/app/config.py b/scenario-app/backend/app/config.py index aa2e4348..b0f16bce 100644 --- a/scenario-app/backend/app/config.py +++ b/scenario-app/backend/app/config.py @@ -2,6 +2,7 @@ from pathlib import Path from typing import List, Optional +from cachetools import TTLCache from dotenv import load_dotenv from pydantic_settings import BaseSettings @@ -78,9 +79,12 @@ class Config: extra = "ignore" # Allow extra environment variables -# Global settings instance settings = Settings() +# Shared cache mapping session_id -> Azure AI conversation_id (conv_xxx) +# Used by both text chat (chat.py) and voice (foundry_agent_utils.py) +conversation_cache: TTLCache = TTLCache(maxsize=1000, ttl=3600.0) + # Check if we have Azure Cosmos DB configuration def has_cosmos_db_config() -> bool: diff --git a/scenario-app/backend/app/routers/chat.py b/scenario-app/backend/app/routers/chat.py index b259083b..77a10eb3 100644 --- a/scenario-app/backend/app/routers/chat.py +++ b/scenario-app/backend/app/routers/chat.py @@ -10,7 +10,7 @@ try: # Try relative imports first (for Docker) from ..auth import get_current_user_optional - from ..config import settings + from ..config import settings, conversation_cache from ..cosmos_service import get_cosmos_service from ..models import ( APIResponse, @@ -36,7 +36,7 @@ ) from app.cosmos_service import get_cosmos_service - from app.config import settings + from app.config import settings, conversation_cache from app.auth import get_current_user_optional from agent_framework.azure import AzureAIProjectAgentProvider @@ -387,6 +387,16 @@ async def send_message_legacy( product_agent = await provider.get_agent(name=product_agent_name) policy_agent = await provider.get_agent(name=policy_agent_name) + # Get or create Azure AI conversation for this session + conv_id = conversation_cache.get(session_id) + if not conv_id: + openai_client = project_client.get_openai_client() + conv = await openai_client.conversations.create() + conv_id = conv.id + conversation_cache[session_id] = conv_id + await openai_client.close() + logger.info("Created Azure AI conversation %s for session %s", conv_id, session_id) + for attempt in range(max_retries): try: # Retrieve chat_agent with the required tools @@ -398,7 +408,7 @@ async def send_message_legacy( ], ) question = message.content - result = await retrieved_agent.run(question) + result = await retrieved_agent.run(question, options={"conversation_id": conv_id}) track_event_if_configured("Agent_Response_Received", {"session_id": session_id, "user_id": user_id}) break # Success, exit retry loop diff --git a/scenario-app/backend/app/routers/voice_live.py b/scenario-app/backend/app/routers/voice_live.py index 59be95c3..07f69388 100644 --- a/scenario-app/backend/app/routers/voice_live.py +++ b/scenario-app/backend/app/routers/voice_live.py @@ -86,7 +86,7 @@ ) -async def _call_foundry_agent(question: str) -> str: +async def _call_foundry_agent(question: str, conversation_id: str = "") -> str: """Delegate to foundry_agent_utils.""" client_id = str(settings.azure_client_id) if settings.azure_client_id else None return await call_foundry_agent( @@ -96,6 +96,7 @@ async def _call_foundry_agent(question: str) -> str: product_agent_name=settings.foundry_product_agent, policy_agent_name=settings.foundry_policy_agent, azure_client_id=client_id, + conversation_id=conversation_id or None, ) @@ -116,8 +117,10 @@ def __init__( credential: Any, send_message, config: VoiceSessionConfig, + session_id: str = "", ): self.client_id = client_id + self.session_id = session_id or client_id self.endpoint = endpoint self.credential = credential self.send = send_message @@ -367,7 +370,7 @@ async def _handle_event(self, event, connection) -> None: question = args.get("question", "") if name == "ask_customer_service": # Run Foundry agent with keep-alive pings to prevent WS timeout - agent_task = asyncio.create_task(_call_foundry_agent(question)) + agent_task = asyncio.create_task(_call_foundry_agent(question, conversation_id=self.session_id)) while not agent_task.done(): await asyncio.sleep(2) if not agent_task.done(): @@ -632,12 +635,13 @@ async def text_to_speech(request: Request): @router.websocket("/ws/{client_id}") async def websocket_endpoint(websocket: WebSocket, client_id: str): await websocket.accept() + session_id = websocket.query_params.get("session_id", client_id) try: while True: data = await websocket.receive_text() message = json.loads(data) - await _handle_message(client_id, message, websocket) + await _handle_message(client_id, message, websocket, session_id=session_id) except WebSocketDisconnect: logger.info("Voice client disconnected: %s", client_id) except Exception as exc: @@ -646,12 +650,12 @@ async def websocket_endpoint(websocket: WebSocket, client_id: str): await _cleanup_client(client_id) -async def _handle_message(client_id: str, message: dict, websocket: WebSocket): +async def _handle_message(client_id: str, message: dict, websocket: WebSocket, session_id: str = ""): msg_type = message.get("type") if msg_type == "start_session": config = {k: v for k, v in message.items() if k != "type"} - await _start_session(client_id, config, websocket) + await _start_session(client_id, config, websocket, session_id=session_id) elif msg_type == "stop_session": await _stop_session(client_id, websocket) @@ -667,7 +671,7 @@ async def _handle_message(client_id: str, message: dict, websocket: WebSocket): await handler.interrupt() -async def _start_session(client_id: str, config: dict, websocket: WebSocket): +async def _start_session(client_id: str, config: dict, websocket: WebSocket, session_id: str = ""): endpoint = resolve_endpoint(settings.azure_voicelive_endpoint, settings.azure_openai_endpoint) if not endpoint: await websocket.send_text( @@ -712,6 +716,7 @@ async def send_to_client(msg: dict): credential=credential, send_message=send_to_client, config=session_config, + session_id=session_id, ) previous_handler = _handlers.get(client_id) diff --git a/scenario-app/backend/app/utils/foundry_agent_utils.py b/scenario-app/backend/app/utils/foundry_agent_utils.py index 54b6feab..51db0ebd 100644 --- a/scenario-app/backend/app/utils/foundry_agent_utils.py +++ b/scenario-app/backend/app/utils/foundry_agent_utils.py @@ -6,6 +6,11 @@ logger = logging.getLogger(__name__) +try: + from ..config import conversation_cache +except ImportError: + from app.config import conversation_cache + async def call_foundry_agent( question: str, @@ -14,6 +19,7 @@ async def call_foundry_agent( product_agent_name: str, policy_agent_name: str, azure_client_id: Optional[str] = None, + conversation_id: Optional[str] = None, ) -> str: """ Call the Foundry multi-agent pipeline (chat -> product/policy agents -> Azure AI Search). @@ -55,7 +61,17 @@ async def call_foundry_agent( ], ) - result = await retrieved_agent.run(question) + # Get or create Azure AI conversation for tracing + conv_id = conversation_cache.get(conversation_id) if conversation_id else None + if not conv_id: + openai_client = project_client.get_openai_client() + conv = await openai_client.conversations.create() + conv_id = conv.id + if conversation_id: + conversation_cache[conversation_id] = conv_id + await openai_client.close() + + result = await retrieved_agent.run(question, options={"conversation_id": conv_id}) if result and hasattr(result, "text"): return result.text diff --git a/scenario-app/frontend/src/components/EnhancedChatPanel.tsx b/scenario-app/frontend/src/components/EnhancedChatPanel.tsx index b75e9846..64614643 100644 --- a/scenario-app/frontend/src/components/EnhancedChatPanel.tsx +++ b/scenario-app/frontend/src/components/EnhancedChatPanel.tsx @@ -468,7 +468,8 @@ export const EnhancedChatPanel = ({ const apiBase = getApiBaseUrl(); const apiUrl = new URL(apiBase); const wsProtocol = apiUrl.protocol === 'https:' ? 'wss:' : 'ws:'; - const wsUrl = `${wsProtocol}//${apiUrl.host}/api/voice/ws/${clientIdRef.current}`; + const storedSessionId = localStorage.getItem('current_chat_session_id') || ''; + const wsUrl = `${wsProtocol}//${apiUrl.host}/api/voice/ws/${clientIdRef.current}${storedSessionId ? `?session_id=${storedSessionId}` : ''}`; const ws = new WebSocket(wsUrl); wsRef.current = ws; From ca18269c50e5f0cafdce8b4b341fd403f1dca3cc Mon Sep 17 00:00:00 2001 From: NirajC3-Microsoft Date: Thu, 13 Aug 2026 12:05:12 +0530 Subject: [PATCH 2/4] Resolve copilot comments --- chat-app/backend/app/routers/chat.py | 10 ++++++---- chat-app/backend/app/utils/foundry_agent_utils.py | 12 +++++++----- .../frontend/src/components/EnhancedChatPanel.tsx | 4 ++-- scenario-app/backend/app/routers/chat.py | 10 ++++++---- .../backend/app/utils/foundry_agent_utils.py | 12 +++++++----- .../frontend/src/components/EnhancedChatPanel.tsx | 4 ++-- 6 files changed, 30 insertions(+), 22 deletions(-) diff --git a/chat-app/backend/app/routers/chat.py b/chat-app/backend/app/routers/chat.py index b997f620..94200d4d 100644 --- a/chat-app/backend/app/routers/chat.py +++ b/chat-app/backend/app/routers/chat.py @@ -398,10 +398,12 @@ async def send_message_legacy( conv_id = conversation_cache.get(session_id) if not conv_id: openai_client = project_client.get_openai_client() - conv = await openai_client.conversations.create() - conv_id = conv.id - conversation_cache[session_id] = conv_id - await openai_client.close() + try: + conv = await openai_client.conversations.create() + conv_id = conv.id + conversation_cache[session_id] = conv_id + finally: + await openai_client.close() logger.info("Created Azure AI conversation %s for session %s", conv_id, session_id) for attempt in range(max_retries): diff --git a/chat-app/backend/app/utils/foundry_agent_utils.py b/chat-app/backend/app/utils/foundry_agent_utils.py index e3b76730..0908d8ca 100644 --- a/chat-app/backend/app/utils/foundry_agent_utils.py +++ b/chat-app/backend/app/utils/foundry_agent_utils.py @@ -60,11 +60,13 @@ async def call_foundry_agent( conv_id = conversation_cache.get(conversation_id) if conversation_id else None if not conv_id: openai_client = project_client.get_openai_client() - conv = await openai_client.conversations.create() - conv_id = conv.id - if conversation_id: - conversation_cache[conversation_id] = conv_id - await openai_client.close() + try: + conv = await openai_client.conversations.create() + conv_id = conv.id + if conversation_id: + conversation_cache[conversation_id] = conv_id + finally: + await openai_client.close() result = await retrieved_agent.run(question, options={"conversation_id": conv_id}) diff --git a/chat-app/frontend/src/components/EnhancedChatPanel.tsx b/chat-app/frontend/src/components/EnhancedChatPanel.tsx index 0010104e..09f6a43e 100644 --- a/chat-app/frontend/src/components/EnhancedChatPanel.tsx +++ b/chat-app/frontend/src/components/EnhancedChatPanel.tsx @@ -482,8 +482,8 @@ export const EnhancedChatPanel = ({ const apiBase = getApiBaseUrl(); const apiUrl = new URL(apiBase); const wsProtocol = apiUrl.protocol === 'https:' ? 'wss:' : 'ws:'; - const storedSessionId = localStorage.getItem('current_chat_session_id') || ''; - const wsUrl = `${wsProtocol}//${apiUrl.host}/api/voice/ws/${clientIdRef.current}${storedSessionId ? `?session_id=${storedSessionId}` : ''}`; + const storedSessionId = localStorage.getItem('current_chat_session_id'); + const wsUrl = `${wsProtocol}//${apiUrl.host}/api/voice/ws/${clientIdRef.current}${storedSessionId ? `?session_id=${encodeURIComponent(storedSessionId)}` : ''}`; const ws = new WebSocket(wsUrl); wsRef.current = ws; diff --git a/scenario-app/backend/app/routers/chat.py b/scenario-app/backend/app/routers/chat.py index 77a10eb3..aa0b3541 100644 --- a/scenario-app/backend/app/routers/chat.py +++ b/scenario-app/backend/app/routers/chat.py @@ -391,10 +391,12 @@ async def send_message_legacy( conv_id = conversation_cache.get(session_id) if not conv_id: openai_client = project_client.get_openai_client() - conv = await openai_client.conversations.create() - conv_id = conv.id - conversation_cache[session_id] = conv_id - await openai_client.close() + try: + conv = await openai_client.conversations.create() + conv_id = conv.id + conversation_cache[session_id] = conv_id + finally: + await openai_client.close() logger.info("Created Azure AI conversation %s for session %s", conv_id, session_id) for attempt in range(max_retries): diff --git a/scenario-app/backend/app/utils/foundry_agent_utils.py b/scenario-app/backend/app/utils/foundry_agent_utils.py index 51db0ebd..b5678721 100644 --- a/scenario-app/backend/app/utils/foundry_agent_utils.py +++ b/scenario-app/backend/app/utils/foundry_agent_utils.py @@ -65,11 +65,13 @@ async def call_foundry_agent( conv_id = conversation_cache.get(conversation_id) if conversation_id else None if not conv_id: openai_client = project_client.get_openai_client() - conv = await openai_client.conversations.create() - conv_id = conv.id - if conversation_id: - conversation_cache[conversation_id] = conv_id - await openai_client.close() + try: + conv = await openai_client.conversations.create() + conv_id = conv.id + if conversation_id: + conversation_cache[conversation_id] = conv_id + finally: + await openai_client.close() result = await retrieved_agent.run(question, options={"conversation_id": conv_id}) diff --git a/scenario-app/frontend/src/components/EnhancedChatPanel.tsx b/scenario-app/frontend/src/components/EnhancedChatPanel.tsx index 64614643..d0404915 100644 --- a/scenario-app/frontend/src/components/EnhancedChatPanel.tsx +++ b/scenario-app/frontend/src/components/EnhancedChatPanel.tsx @@ -468,8 +468,8 @@ export const EnhancedChatPanel = ({ const apiBase = getApiBaseUrl(); const apiUrl = new URL(apiBase); const wsProtocol = apiUrl.protocol === 'https:' ? 'wss:' : 'ws:'; - const storedSessionId = localStorage.getItem('current_chat_session_id') || ''; - const wsUrl = `${wsProtocol}//${apiUrl.host}/api/voice/ws/${clientIdRef.current}${storedSessionId ? `?session_id=${storedSessionId}` : ''}`; + const storedSessionId = localStorage.getItem('current_chat_session_id'); + const wsUrl = `${wsProtocol}//${apiUrl.host}/api/voice/ws/${clientIdRef.current}${storedSessionId ? `?session_id=${encodeURIComponent(storedSessionId)}` : ''}`; const ws = new WebSocket(wsUrl); wsRef.current = ws; From 389593f2e31c45c618334d135258fd3340f5ab4c Mon Sep 17 00:00:00 2001 From: NirajC3-Microsoft Date: Fri, 14 Aug 2026 14:24:16 +0530 Subject: [PATCH 3/4] Code changes to delete Orphan conversation id --- chat-app/backend/app/config.py | 79 ++++++++++++++++++- chat-app/backend/app/routers/chat.py | 6 ++ .../backend/app/utils/foundry_agent_utils.py | 4 + scenario-app/backend/app/config.py | 79 ++++++++++++++++++- scenario-app/backend/app/routers/chat.py | 6 ++ .../backend/app/utils/foundry_agent_utils.py | 4 + 6 files changed, 176 insertions(+), 2 deletions(-) diff --git a/chat-app/backend/app/config.py b/chat-app/backend/app/config.py index f267f425..688083d2 100644 --- a/chat-app/backend/app/config.py +++ b/chat-app/backend/app/config.py @@ -1,3 +1,5 @@ +import asyncio +import logging import os from pathlib import Path from typing import List, Optional @@ -104,9 +106,84 @@ class Config: settings = Settings() +_config_logger = logging.getLogger(__name__) + + +class ExpCache(TTLCache): + """Extended TTLCache that deletes Azure AI Foundry conversations when items expire or are evicted.""" + + def __init__(self, maxsize: int, ttl: float): + super().__init__(maxsize=maxsize, ttl=ttl) + self._foundry_endpoint: str = "" + self._azure_client_id: Optional[str] = None + + def configure(self, foundry_endpoint: str, azure_client_id: Optional[str] = None) -> None: + self._foundry_endpoint = foundry_endpoint + self._azure_client_id = azure_client_id + + def expire(self, time=None): + """Remove expired items and delete associated Foundry conversations.""" + items = super().expire(time) + for key, conv_id in items: + try: + asyncio.create_task(self._delete_conversation_async(conv_id)) + _config_logger.info("Scheduled conversation deletion: %s", conv_id) + except RuntimeError: + pass # No running event loop + except Exception as e: + _config_logger.error("Failed to schedule deletion for key %s: %s", key, e) + return items + + def popitem(self): + """Remove LRU item and delete associated Foundry conversation.""" + key, conv_id = super().popitem() + try: + asyncio.create_task(self._delete_conversation_async(conv_id)) + _config_logger.info("Scheduled conversation deletion (LRU evict): %s", conv_id) + except RuntimeError: + pass # No running event loop + except Exception as e: + _config_logger.error("Failed to schedule deletion for key %s (LRU evict): %s", key, e) + return key, conv_id + + async def _delete_conversation_async(self, conv_id: str) -> None: + """Asynchronously delete a Foundry conversation with proper resource cleanup.""" + credential = None + try: + if not conv_id or not self._foundry_endpoint: + return + # Response IDs (resp_xxx) are managed by the API — skip deletion + if conv_id.startswith("resp_"): + _config_logger.info("Skipping deletion for response ID: %s", conv_id) + return + + from azure.ai.projects.aio import AIProjectClient + + try: + from .utils.azure_credential_utils import get_azure_credential_async + except ImportError: + from app.utils.azure_credential_utils import get_azure_credential_async + + credential = await get_azure_credential_async(client_id=self._azure_client_id) + async with AIProjectClient( + endpoint=self._foundry_endpoint, credential=credential + ) as project_client: + openai_client = project_client.get_openai_client() + try: + await openai_client.conversations.delete(conversation_id=conv_id) + _config_logger.info("Conversation deleted successfully: %s", conv_id) + finally: + await openai_client.close() + except Exception as e: + _config_logger.error("Failed to delete conversation %s: %s", conv_id, e) + finally: + if credential is not None: + await credential.close() + + # Shared cache mapping session_id -> Azure AI conversation_id (conv_xxx) # Used by both text chat (chat.py) and voice (foundry_agent_utils.py) -conversation_cache: TTLCache = TTLCache(maxsize=1000, ttl=3600.0) +conversation_cache: ExpCache = ExpCache(maxsize=1000, ttl=3600.0) def get_settings() -> Settings: diff --git a/chat-app/backend/app/routers/chat.py b/chat-app/backend/app/routers/chat.py index 94200d4d..0d0c0dba 100644 --- a/chat-app/backend/app/routers/chat.py +++ b/chat-app/backend/app/routers/chat.py @@ -204,6 +204,8 @@ async def delete_chat_session(session_id: str, user_id: Optional[str] = None): track_event_if_configured("Error_Chat_Session_Not_Found", {"session_id": session_id, "user_id": user_id}) raise HTTPException(status_code=404, detail="Chat session not found") + # Remove cached Foundry conversation mapping (triggers async cleanup) + conversation_cache.pop(session_id, None) track_event_if_configured("Chat_Session_Deleted", {"session_id": session_id, "user_id": user_id}) return APIResponse(message="Chat session deleted successfully") except HTTPException: @@ -394,6 +396,10 @@ async def send_message_legacy( catalog_tool = catalog_tool_name() policy_tool = policy_tool_name() + # Configure cache for Foundry cleanup on first use + if not conversation_cache._foundry_endpoint: + conversation_cache.configure(ai_project_endpoint, client_id) + # Get or create Azure AI conversation for this session conv_id = conversation_cache.get(session_id) if not conv_id: diff --git a/chat-app/backend/app/utils/foundry_agent_utils.py b/chat-app/backend/app/utils/foundry_agent_utils.py index 0908d8ca..e437f564 100644 --- a/chat-app/backend/app/utils/foundry_agent_utils.py +++ b/chat-app/backend/app/utils/foundry_agent_utils.py @@ -37,6 +37,10 @@ async def call_foundry_agent( credential = await get_azure_credential_async(client_id=azure_client_id) + # Configure cache for Foundry cleanup on first use + if hasattr(conversation_cache, 'configure') and not conversation_cache._foundry_endpoint: + conversation_cache.configure(foundry_endpoint, azure_client_id) + async with ( credential, AIProjectClient(endpoint=foundry_endpoint, credential=credential) as project_client, diff --git a/scenario-app/backend/app/config.py b/scenario-app/backend/app/config.py index b0f16bce..5872d747 100644 --- a/scenario-app/backend/app/config.py +++ b/scenario-app/backend/app/config.py @@ -1,3 +1,5 @@ +import asyncio +import logging import os from pathlib import Path from typing import List, Optional @@ -81,9 +83,84 @@ class Config: settings = Settings() +_config_logger = logging.getLogger(__name__) + + +class ExpCache(TTLCache): + """Extended TTLCache that deletes Azure AI Foundry conversations when items expire or are evicted.""" + + def __init__(self, maxsize: int, ttl: float): + super().__init__(maxsize=maxsize, ttl=ttl) + self._foundry_endpoint: str = "" + self._azure_client_id: Optional[str] = None + + def configure(self, foundry_endpoint: str, azure_client_id: Optional[str] = None) -> None: + self._foundry_endpoint = foundry_endpoint + self._azure_client_id = azure_client_id + + def expire(self, time=None): + """Remove expired items and delete associated Foundry conversations.""" + items = super().expire(time) + for key, conv_id in items: + try: + asyncio.create_task(self._delete_conversation_async(conv_id)) + _config_logger.info("Scheduled conversation deletion: %s", conv_id) + except RuntimeError: + pass # No running event loop + except Exception as e: + _config_logger.error("Failed to schedule deletion for key %s: %s", key, e) + return items + + def popitem(self): + """Remove LRU item and delete associated Foundry conversation.""" + key, conv_id = super().popitem() + try: + asyncio.create_task(self._delete_conversation_async(conv_id)) + _config_logger.info("Scheduled conversation deletion (LRU evict): %s", conv_id) + except RuntimeError: + pass # No running event loop + except Exception as e: + _config_logger.error("Failed to schedule deletion for key %s (LRU evict): %s", key, e) + return key, conv_id + + async def _delete_conversation_async(self, conv_id: str) -> None: + """Asynchronously delete a Foundry conversation with proper resource cleanup.""" + credential = None + try: + if not conv_id or not self._foundry_endpoint: + return + # Response IDs (resp_xxx) are managed by the API — skip deletion + if conv_id.startswith("resp_"): + _config_logger.info("Skipping deletion for response ID: %s", conv_id) + return + + from azure.ai.projects.aio import AIProjectClient + + try: + from .utils.azure_credential_utils import get_azure_credential_async + except ImportError: + from app.utils.azure_credential_utils import get_azure_credential_async + + credential = await get_azure_credential_async(client_id=self._azure_client_id) + async with AIProjectClient( + endpoint=self._foundry_endpoint, credential=credential + ) as project_client: + openai_client = project_client.get_openai_client() + try: + await openai_client.conversations.delete(conversation_id=conv_id) + _config_logger.info("Conversation deleted successfully: %s", conv_id) + finally: + await openai_client.close() + except Exception as e: + _config_logger.error("Failed to delete conversation %s: %s", conv_id, e) + finally: + if credential is not None: + await credential.close() + + # Shared cache mapping session_id -> Azure AI conversation_id (conv_xxx) # Used by both text chat (chat.py) and voice (foundry_agent_utils.py) -conversation_cache: TTLCache = TTLCache(maxsize=1000, ttl=3600.0) +conversation_cache: ExpCache = ExpCache(maxsize=1000, ttl=3600.0) # Check if we have Azure Cosmos DB configuration diff --git a/scenario-app/backend/app/routers/chat.py b/scenario-app/backend/app/routers/chat.py index aa0b3541..6277e6bf 100644 --- a/scenario-app/backend/app/routers/chat.py +++ b/scenario-app/backend/app/routers/chat.py @@ -189,6 +189,8 @@ async def delete_chat_session(session_id: str, user_id: Optional[str] = None): track_event_if_configured("Error_Chat_Session_Not_Found", {"session_id": session_id, "user_id": user_id}) raise HTTPException(status_code=404, detail="Chat session not found") + # Remove cached Foundry conversation mapping (triggers async cleanup) + conversation_cache.pop(session_id, None) track_event_if_configured("Chat_Session_Deleted", {"session_id": session_id, "user_id": user_id}) return APIResponse(message="Chat session deleted successfully") except HTTPException: @@ -387,6 +389,10 @@ async def send_message_legacy( product_agent = await provider.get_agent(name=product_agent_name) policy_agent = await provider.get_agent(name=policy_agent_name) + # Configure cache for Foundry cleanup on first use + if not conversation_cache._foundry_endpoint: + conversation_cache.configure(ai_project_endpoint, client_id) + # Get or create Azure AI conversation for this session conv_id = conversation_cache.get(session_id) if not conv_id: diff --git a/scenario-app/backend/app/utils/foundry_agent_utils.py b/scenario-app/backend/app/utils/foundry_agent_utils.py index b5678721..2802a448 100644 --- a/scenario-app/backend/app/utils/foundry_agent_utils.py +++ b/scenario-app/backend/app/utils/foundry_agent_utils.py @@ -42,6 +42,10 @@ async def call_foundry_agent( credential = await get_azure_credential_async(client_id=azure_client_id) + # Configure cache for Foundry cleanup on first use + if hasattr(conversation_cache, 'configure') and not conversation_cache._foundry_endpoint: + conversation_cache.configure(foundry_endpoint, azure_client_id) + async with ( credential, AIProjectClient(endpoint=foundry_endpoint, credential=credential) as project_client, From e3c7efeae203854a0f3bd85199066d2299dcf0b4 Mon Sep 17 00:00:00 2001 From: NirajC3-Microsoft Date: Fri, 14 Aug 2026 15:31:09 +0530 Subject: [PATCH 4/4] Resolve Copilot Comment --- chat-app/backend/app/config.py | 13 +++++++++++ chat-app/backend/app/routers/chat.py | 23 +++++++++++-------- .../backend/app/utils/foundry_agent_utils.py | 23 +++++++++++-------- scenario-app/backend/app/config.py | 13 +++++++++++ scenario-app/backend/app/routers/chat.py | 23 +++++++++++-------- .../backend/app/utils/foundry_agent_utils.py | 23 +++++++++++-------- 6 files changed, 82 insertions(+), 36 deletions(-) diff --git a/chat-app/backend/app/config.py b/chat-app/backend/app/config.py index 688083d2..e7140396 100644 --- a/chat-app/backend/app/config.py +++ b/chat-app/backend/app/config.py @@ -146,6 +146,19 @@ def popitem(self): _config_logger.error("Failed to schedule deletion for key %s (LRU evict): %s", key, e) return key, conv_id + def pop(self, key, *args): + """Remove item by key and delete associated Foundry conversation.""" + conv_id = super().pop(key, *args) + if conv_id and isinstance(conv_id, str): + try: + asyncio.create_task(self._delete_conversation_async(conv_id)) + _config_logger.info("Scheduled conversation deletion (explicit pop): %s", conv_id) + except RuntimeError: + pass # No running event loop + except Exception as e: + _config_logger.error("Failed to schedule deletion for key %s (pop): %s", key, e) + return conv_id + async def _delete_conversation_async(self, conv_id: str) -> None: """Asynchronously delete a Foundry conversation with proper resource cleanup.""" credential = None diff --git a/chat-app/backend/app/routers/chat.py b/chat-app/backend/app/routers/chat.py index 0d0c0dba..b0376731 100644 --- a/chat-app/backend/app/routers/chat.py +++ b/chat-app/backend/app/routers/chat.py @@ -400,17 +400,21 @@ async def send_message_legacy( if not conversation_cache._foundry_endpoint: conversation_cache.configure(ai_project_endpoint, client_id) - # Get or create Azure AI conversation for this session + # Get or create Azure AI conversation for this session (best-effort) conv_id = conversation_cache.get(session_id) if not conv_id: - openai_client = project_client.get_openai_client() try: - conv = await openai_client.conversations.create() - conv_id = conv.id - conversation_cache[session_id] = conv_id - finally: - await openai_client.close() - logger.info("Created Azure AI conversation %s for session %s", conv_id, session_id) + openai_client = project_client.get_openai_client() + try: + conv = await openai_client.conversations.create() + conv_id = conv.id + conversation_cache[session_id] = conv_id + finally: + await openai_client.close() + logger.info("Created Azure AI conversation %s for session %s", conv_id, session_id) + except Exception as e: + logger.warning("Failed to create Azure AI conversation for session %s, proceeding without: %s", session_id, e) + conv_id = None for attempt in range(max_retries): try: @@ -422,7 +426,8 @@ async def send_message_legacy( ], ) question = message.content - result = await retrieved_agent.run(question, options={"conversation_id": conv_id}) + run_options = {"conversation_id": conv_id} if conv_id else {} + result = await retrieved_agent.run(question, options=run_options) track_event_if_configured("Agent_Response_Received", {"session_id": session_id, "user_id": user_id}) break # Success, exit retry loop diff --git a/chat-app/backend/app/utils/foundry_agent_utils.py b/chat-app/backend/app/utils/foundry_agent_utils.py index e437f564..f73f5b67 100644 --- a/chat-app/backend/app/utils/foundry_agent_utils.py +++ b/chat-app/backend/app/utils/foundry_agent_utils.py @@ -60,19 +60,24 @@ async def call_foundry_agent( ], ) - # Get or create Azure AI conversation for tracing + # Get or create Azure AI conversation for tracing (best-effort) conv_id = conversation_cache.get(conversation_id) if conversation_id else None if not conv_id: - openai_client = project_client.get_openai_client() try: - conv = await openai_client.conversations.create() - conv_id = conv.id - if conversation_id: - conversation_cache[conversation_id] = conv_id - finally: - await openai_client.close() + openai_client = project_client.get_openai_client() + try: + conv = await openai_client.conversations.create() + conv_id = conv.id + if conversation_id: + conversation_cache[conversation_id] = conv_id + finally: + await openai_client.close() + except Exception as e: + logger.warning("Failed to create Azure AI conversation, proceeding without: %s", e) + conv_id = None - result = await retrieved_agent.run(question, options={"conversation_id": conv_id}) + run_options = {"conversation_id": conv_id} if conv_id else {} + result = await retrieved_agent.run(question, options=run_options) if result and hasattr(result, "text"): return result.text diff --git a/scenario-app/backend/app/config.py b/scenario-app/backend/app/config.py index 5872d747..3c3d61f1 100644 --- a/scenario-app/backend/app/config.py +++ b/scenario-app/backend/app/config.py @@ -123,6 +123,19 @@ def popitem(self): _config_logger.error("Failed to schedule deletion for key %s (LRU evict): %s", key, e) return key, conv_id + def pop(self, key, *args): + """Remove item by key and delete associated Foundry conversation.""" + conv_id = super().pop(key, *args) + if conv_id and isinstance(conv_id, str): + try: + asyncio.create_task(self._delete_conversation_async(conv_id)) + _config_logger.info("Scheduled conversation deletion (explicit pop): %s", conv_id) + except RuntimeError: + pass # No running event loop + except Exception as e: + _config_logger.error("Failed to schedule deletion for key %s (pop): %s", key, e) + return conv_id + async def _delete_conversation_async(self, conv_id: str) -> None: """Asynchronously delete a Foundry conversation with proper resource cleanup.""" credential = None diff --git a/scenario-app/backend/app/routers/chat.py b/scenario-app/backend/app/routers/chat.py index 6277e6bf..ade202ea 100644 --- a/scenario-app/backend/app/routers/chat.py +++ b/scenario-app/backend/app/routers/chat.py @@ -393,17 +393,21 @@ async def send_message_legacy( if not conversation_cache._foundry_endpoint: conversation_cache.configure(ai_project_endpoint, client_id) - # Get or create Azure AI conversation for this session + # Get or create Azure AI conversation for this session (best-effort) conv_id = conversation_cache.get(session_id) if not conv_id: - openai_client = project_client.get_openai_client() try: - conv = await openai_client.conversations.create() - conv_id = conv.id - conversation_cache[session_id] = conv_id - finally: - await openai_client.close() - logger.info("Created Azure AI conversation %s for session %s", conv_id, session_id) + openai_client = project_client.get_openai_client() + try: + conv = await openai_client.conversations.create() + conv_id = conv.id + conversation_cache[session_id] = conv_id + finally: + await openai_client.close() + logger.info("Created Azure AI conversation %s for session %s", conv_id, session_id) + except Exception as e: + logger.warning("Failed to create Azure AI conversation for session %s, proceeding without: %s", session_id, e) + conv_id = None for attempt in range(max_retries): try: @@ -416,7 +420,8 @@ async def send_message_legacy( ], ) question = message.content - result = await retrieved_agent.run(question, options={"conversation_id": conv_id}) + run_options = {"conversation_id": conv_id} if conv_id else {} + result = await retrieved_agent.run(question, options=run_options) track_event_if_configured("Agent_Response_Received", {"session_id": session_id, "user_id": user_id}) break # Success, exit retry loop diff --git a/scenario-app/backend/app/utils/foundry_agent_utils.py b/scenario-app/backend/app/utils/foundry_agent_utils.py index 2802a448..dc87b76b 100644 --- a/scenario-app/backend/app/utils/foundry_agent_utils.py +++ b/scenario-app/backend/app/utils/foundry_agent_utils.py @@ -65,19 +65,24 @@ async def call_foundry_agent( ], ) - # Get or create Azure AI conversation for tracing + # Get or create Azure AI conversation for tracing (best-effort) conv_id = conversation_cache.get(conversation_id) if conversation_id else None if not conv_id: - openai_client = project_client.get_openai_client() try: - conv = await openai_client.conversations.create() - conv_id = conv.id - if conversation_id: - conversation_cache[conversation_id] = conv_id - finally: - await openai_client.close() + openai_client = project_client.get_openai_client() + try: + conv = await openai_client.conversations.create() + conv_id = conv.id + if conversation_id: + conversation_cache[conversation_id] = conv_id + finally: + await openai_client.close() + except Exception as e: + logger.warning("Failed to create Azure AI conversation, proceeding without: %s", e) + conv_id = None - result = await retrieved_agent.run(question, options={"conversation_id": conv_id}) + run_options = {"conversation_id": conv_id} if conv_id else {} + result = await retrieved_agent.run(question, options=run_options) if result and hasattr(result, "text"): return result.text