Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 95 additions & 0 deletions chat-app/backend/app/config.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import asyncio
import logging
import os
from pathlib import Path
from typing import List, Optional

from cachetools import TTLCache
from pydantic_settings import BaseSettings

_current_dir = Path(__file__).parent
Expand Down Expand Up @@ -103,6 +106,98 @@ 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

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
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: ExpCache = ExpCache(maxsize=1000, ttl=3600.0)


def get_settings() -> Settings:
return settings
Expand Down
30 changes: 26 additions & 4 deletions chat-app/backend/app/routers/chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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

Expand Down Expand Up @@ -205,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:
Expand Down Expand Up @@ -395,6 +396,26 @@ 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 (best-effort)
conv_id = conversation_cache.get(session_id)
if not conv_id:
try:
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:
retrieved_agent = await provider.get_agent(
Expand All @@ -405,7 +426,8 @@ async def send_message_legacy(
],
)
question = message.content
result = await retrieved_agent.run(question)
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

Expand Down
17 changes: 11 additions & 6 deletions chat-app/backend/app/routers/voice_live.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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,
)


Expand All @@ -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
Expand Down Expand Up @@ -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():
Expand Down Expand Up @@ -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)
Comment thread
NirajC3-Microsoft marked this conversation as resolved.

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:
Expand All @@ -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)
Expand All @@ -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(
Expand Down Expand Up @@ -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)
Expand Down
29 changes: 28 additions & 1 deletion chat-app/backend/app/utils/foundry_agent_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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:
Comment thread
NirajC3-Microsoft marked this conversation as resolved.
try:
from agent_framework.azure import AzureAIProjectAgentProvider
Expand All @@ -31,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,
Expand All @@ -50,7 +60,24 @@ async def call_foundry_agent(
],
)

result = await retrieved_agent.run(question)
# 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:
try:
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

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
Expand Down
3 changes: 2 additions & 1 deletion chat-app/frontend/src/components/EnhancedChatPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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=${encodeURIComponent(storedSessionId)}` : ''}`;

const ws = new WebSocket(wsUrl);
wsRef.current = ws;
Expand Down
Loading
Loading