2222from typing import AsyncIterator , Optional , cast
2323
2424from agent_framework import (
25- ChatMessage ,
26- HandoffBuilder ,
27- HandoffAgentUserRequest ,
28- RequestInfoEvent ,
29- WorkflowOutputEvent ,
30- WorkflowStatusEvent ,
25+ Agent ,
26+ Message ,
27+ WorkflowEventType ,
3128)
32- from agent_framework .azure import AzureOpenAIChatClient
29+ from agent_framework .orchestrations import HandoffBuilder , HandoffAgentUserRequest
30+ from agent_framework .openai import OpenAIChatCompletionClient
3331from azure .identity import DefaultAzureCredential
3432
3533# Foundry imports - only used when USE_FOUNDRY=true
4846# Token endpoint for Azure Cognitive Services (used for Azure OpenAI)
4947TOKEN_ENDPOINT = "https://cognitiveservices.azure.com/.default"
5048
49+ # Event type constants for type-safe dispatch (avoids string typos)
50+ EVENT_STATUS : WorkflowEventType = "status"
51+ EVENT_REQUEST_INFO : WorkflowEventType = "request_info"
52+ EVENT_OUTPUT : WorkflowEventType = "output"
53+
5154
5255# Harmful content patterns to detect in USER INPUT before processing
5356# This provides proactive content safety by blocking harmful requests at the input layer
@@ -120,9 +123,9 @@ def _check_input_for_harmful_content(message: str) -> tuple[bool, str]:
120123 r"You are a Text Content Agent" ,
121124 r"You are an Image Content Agent" ,
122125 r"You are a Compliance Agent" ,
123- # Handoff instructions
124- r"hand off to \w+_agent " ,
125- r"hand back to \w+_agent " ,
126+ # Handoff instructions (match both underscore and hyphen agent names)
127+ r"hand off to [\w\-]+[_\-]agent " ,
128+ r"hand back to [\w\-]+[_\-]agent " ,
126129 r"may hand off to" ,
127130 r"After (?:generating|completing|validation|parsing)" ,
128131 # Internal workflow markers
@@ -139,8 +142,8 @@ def _check_input_for_harmful_content(message: str) -> tuple[bool, str]:
139142 # RAI internal instructions
140143 r"NEVER generate images that contain:" ,
141144 r"Responsible AI - Image Generation Rules" ,
142- # Agent framework references
143- r"compliance_agent|triage_agent|planning_agent|research_agent|text_content_agent|image_content_agent " ,
145+ # Agent framework references (match both underscore and hyphen separators)
146+ r"compliance[_\-]agent|triage[_\-]agent|planning[_\-]agent|research[_\-]agent|text[_\-]content[_\-]agent|image[_\-]content[_\-]agent " ,
144147]
145148
146149_SYSTEM_PROMPT_PATTERNS_COMPILED = [re .compile (pattern , re .IGNORECASE | re .DOTALL ) for pattern in SYSTEM_PROMPT_PATTERNS ]
@@ -485,7 +488,7 @@ class ContentGenerationOrchestrator:
485488 Microsoft Agent Framework's HandoffBuilder.
486489
487490 Supports two modes:
488- 1. Azure OpenAI Direct (default): Uses AzureOpenAIChatClient with ad_token_provider
491+ 1. Azure OpenAI Direct (default): Uses OpenAIChatCompletionClient with DefaultAzureCredential
489492 2. Azure AI Foundry: Uses AIProjectClient with project endpoint (set USE_FOUNDRY=true)
490493
491494 Agents:
@@ -498,7 +501,7 @@ class ContentGenerationOrchestrator:
498501 """
499502
500503 def __init__ (self ):
501- self ._chat_client = None # Always AzureOpenAIChatClient
504+ self ._chat_client = None # OpenAIChatCompletionClient instance
502505 self ._project_client = None # AIProjectClient for Foundry mode (used for image generation)
503506 self ._agents : dict = {}
504507 self ._rai_agent = None
@@ -536,45 +539,34 @@ def _get_chat_client(self):
536539 # Store the project client for image generation
537540 self ._project_client = project_client
538541
539- # For chat completions, use the direct Azure OpenAI endpoint
540542 # The Foundry project uses Azure OpenAI under the hood, and we need the direct endpoint
541543 # to properly authenticate with Cognitive Services token
542544 azure_endpoint = app_settings .azure_openai .endpoint
543545 if not azure_endpoint :
544546 raise ValueError ("AZURE_OPENAI_ENDPOINT is required for Foundry mode chat completions" )
545547
546- def get_token () -> str :
547- """Token provider callable - invoked for each request to ensure fresh tokens."""
548- token = self ._credential .get_token (TOKEN_ENDPOINT )
549- return token .token
550-
551548 model_deployment = app_settings .ai_foundry .model_deployment or app_settings .azure_openai .gpt_model
552549 api_version = app_settings .azure_openai .api_version
553550
554551 logger .info (f"Foundry mode using Azure OpenAI endpoint: { azure_endpoint } , deployment: { model_deployment } " )
555- self ._chat_client = AzureOpenAIChatClient (
556- endpoint = azure_endpoint ,
557- deployment_name = model_deployment ,
552+ self ._chat_client = OpenAIChatCompletionClient (
553+ azure_endpoint = azure_endpoint ,
554+ model = model_deployment ,
558555 api_version = api_version ,
559- ad_token_provider = get_token ,
556+ credential = self . _credential ,
560557 )
561558 else :
562559 # Azure OpenAI Direct mode
563560 endpoint = app_settings .azure_openai .endpoint
564561 if not endpoint :
565562 raise ValueError ("AZURE_OPENAI_ENDPOINT is not configured" )
566563
567- def get_token () -> str :
568- """Token provider callable - invoked for each request to ensure fresh tokens."""
569- token = self ._credential .get_token (TOKEN_ENDPOINT )
570- return token .token
571-
572- logger .info ("Using Azure OpenAI Direct mode with ad_token_provider" )
573- self ._chat_client = AzureOpenAIChatClient (
574- endpoint = endpoint ,
575- deployment_name = app_settings .azure_openai .gpt_model ,
564+ logger .info ("Using Azure OpenAI Direct mode with DefaultAzureCredential" )
565+ self ._chat_client = OpenAIChatCompletionClient (
566+ azure_endpoint = endpoint ,
567+ model = app_settings .azure_openai .gpt_model ,
576568 api_version = app_settings .azure_openai .api_version ,
577- ad_token_provider = get_token ,
569+ credential = self . _credential ,
578570 )
579571 return self ._chat_client
580572
@@ -589,40 +581,60 @@ def initialize(self) -> None:
589581 # Get the chat client
590582 chat_client = self ._get_chat_client ()
591583
592- # Agent names - use underscores (AzureOpenAIChatClient works with both modes now)
584+ # Agent names - always use underscores so that instruction strings
585+ # (TRIAGE_INSTRUCTIONS, *_CONTENT_INSTRUCTIONS, etc.) and the
586+ # SYSTEM_PROMPT_PATTERNS leakage-detection regexes stay in sync.
587+ # Foundry workflows accept underscore names; no hyphen conversion needed.
593588 name_sep = "_"
594589
595590 # Create all agents
596- triage_agent = chat_client .create_agent (
591+ # NOTE: Handoff workflow participants must set
592+ # require_per_service_call_history_persistence=True so local conversation
593+ # history stays consistent with the service across handoff tool-call
594+ # short-circuits (required by agent_framework.orchestrations.HandoffBuilder).
595+ triage_agent = Agent (
596+ client = chat_client ,
597597 name = f"triage{ name_sep } agent" ,
598598 instructions = TRIAGE_INSTRUCTIONS ,
599+ require_per_service_call_history_persistence = True ,
599600 )
600601
601- planning_agent = chat_client .create_agent (
602+ planning_agent = Agent (
603+ client = chat_client ,
602604 name = f"planning{ name_sep } agent" ,
603605 instructions = PLANNING_INSTRUCTIONS ,
606+ require_per_service_call_history_persistence = True ,
604607 )
605608
606- research_agent = chat_client .create_agent (
609+ research_agent = Agent (
610+ client = chat_client ,
607611 name = f"research{ name_sep } agent" ,
608612 instructions = RESEARCH_INSTRUCTIONS ,
613+ require_per_service_call_history_persistence = True ,
609614 )
610615
611- text_content_agent = chat_client .create_agent (
616+ text_content_agent = Agent (
617+ client = chat_client ,
612618 name = f"text{ name_sep } content{ name_sep } agent" ,
613619 instructions = TEXT_CONTENT_INSTRUCTIONS ,
620+ require_per_service_call_history_persistence = True ,
614621 )
615622
616- image_content_agent = chat_client .create_agent (
623+ image_content_agent = Agent (
624+ client = chat_client ,
617625 name = f"image{ name_sep } content{ name_sep } agent" ,
618626 instructions = IMAGE_CONTENT_INSTRUCTIONS ,
627+ require_per_service_call_history_persistence = True ,
619628 )
620629
621- compliance_agent = chat_client .create_agent (
630+ compliance_agent = Agent (
631+ client = chat_client ,
622632 name = f"compliance{ name_sep } agent" ,
623633 instructions = COMPLIANCE_INSTRUCTIONS ,
634+ require_per_service_call_history_persistence = True ,
624635 )
625- self ._rai_agent = chat_client .create_agent (
636+ self ._rai_agent = Agent (
637+ client = chat_client ,
626638 name = f"rai{ name_sep } agent" ,
627639 instructions = RAI_INSTRUCTIONS ,
628640 )
@@ -636,7 +648,7 @@ def initialize(self) -> None:
636648 "compliance" : compliance_agent ,
637649 }
638650
639- # Workflow name - Foundry requires hyphens
651+ # Workflow name
640652 workflow_name = f"content{ name_sep } generation{ name_sep } workflow"
641653
642654 # Build the handoff workflow
@@ -736,35 +748,35 @@ async def process_message(
736748 events .append (event )
737749
738750 # Handle different event types from the workflow
739- if isinstance (event , WorkflowStatusEvent ):
751+ if event .type == EVENT_STATUS :
752+ status_name = event .state .name if event .state else str (event .data )
740753 yield {
741754 "type" : "status" ,
742- "content" : event . state . name ,
755+ "content" : status_name ,
743756 "is_final" : False ,
744757 "metadata" : {"conversation_id" : conversation_id }
745758 }
746759
747- elif isinstance ( event , RequestInfoEvent ) :
760+ elif event . type == EVENT_REQUEST_INFO :
748761 # Workflow is requesting user input
749762 if isinstance (event .data , HandoffAgentUserRequest ):
750- # Extract conversation history from agent_response.messages (updated API)
751- messages = event .data .agent_response .messages if hasattr (event .data , 'agent_response' ) and event .data .agent_response else []
752- if not isinstance (messages , list ):
753- messages = [messages ] if messages else []
763+ # Extract conversation history from agent_response.messages
764+ agent_resp = event .data .agent_response
765+ messages = list (agent_resp .messages ) if agent_resp and agent_resp .messages else []
754766
755767 conversation_text = "\n " .join ([
756768 f"{ msg .author_name or msg .role .value } : { msg .text } "
757769 for msg in messages
758770 ])
759771
760772 # Get the last message content and filter any system prompt leakage
761- last_msg_content = messages [- 1 ].text if messages else (event . data . agent_response . text if hasattr ( event . data , 'agent_response' ) and event . data . agent_response else "" )
773+ last_msg_content = messages [- 1 ].text if messages else (agent_resp . text if agent_resp else "" )
762774 last_msg_content = _filter_system_prompt_from_response (last_msg_content )
763- last_msg_agent = messages [- 1 ].author_name if messages and hasattr ( messages [ - 1 ], 'author_name' ) else "unknown"
775+ last_msg_agent = messages [- 1 ].author_name if messages else "unknown"
764776
765777 yield {
766778 "type" : "agent_response" ,
767- "agent" : last_msg_agent ,
779+ "agent" : last_msg_agent or "unknown" ,
768780 "content" : last_msg_content ,
769781 "conversation_history" : conversation_text ,
770782 "is_final" : False ,
@@ -773,9 +785,8 @@ async def process_message(
773785 "metadata" : {"conversation_id" : conversation_id }
774786 }
775787
776- elif isinstance (event , WorkflowOutputEvent ):
777- # Final output from the workflow
778- conversation = cast (list [ChatMessage ], event .data )
788+ elif event .type == EVENT_OUTPUT :
789+ conversation = cast (list [Message ], event .data )
779790 if isinstance (conversation , list ) and conversation :
780791 # Get the last assistant message as the final response
781792 assistant_messages = [
@@ -841,38 +852,38 @@ async def send_user_response(
841852 try :
842853 responses = {request_id : user_response }
843854 async for event in self ._workflow .send_responses_streaming (responses ):
844- if isinstance (event , WorkflowStatusEvent ):
855+ if event .type == EVENT_STATUS :
856+ status_name = event .state .name if event .state else str (event .data )
845857 yield {
846858 "type" : "status" ,
847- "content" : event . state . name ,
859+ "content" : status_name ,
848860 "is_final" : False ,
849861 "metadata" : {"conversation_id" : conversation_id }
850862 }
851863
852- elif isinstance ( event , RequestInfoEvent ) :
864+ elif event . type == EVENT_REQUEST_INFO :
853865 if isinstance (event .data , HandoffAgentUserRequest ):
854- # Get messages from agent_response (updated API)
855- messages = event .data .agent_response .messages if hasattr (event .data , 'agent_response' ) and event .data .agent_response else []
856- if not isinstance (messages , list ):
857- messages = [messages ] if messages else []
866+ # Get messages from agent_response
867+ agent_resp = event .data .agent_response
868+ messages = list (agent_resp .messages ) if agent_resp and agent_resp .messages else []
858869
859870 # Get the last message content and filter any system prompt leakage
860- last_msg_content = messages [- 1 ].text if messages else (event . data . agent_response . text if hasattr ( event . data , 'agent_response' ) and event . data . agent_response else "" )
871+ last_msg_content = messages [- 1 ].text if messages else (agent_resp . text if agent_resp else "" )
861872 last_msg_content = _filter_system_prompt_from_response (last_msg_content )
862- last_msg_agent = messages [- 1 ].author_name if messages and hasattr ( messages [ - 1 ], 'author_name' ) else "unknown"
873+ last_msg_agent = messages [- 1 ].author_name if messages else "unknown"
863874
864875 yield {
865876 "type" : "agent_response" ,
866- "agent" : last_msg_agent ,
877+ "agent" : last_msg_agent or "unknown" ,
867878 "content" : last_msg_content ,
868879 "is_final" : False ,
869880 "requires_user_input" : True ,
870881 "request_id" : event .request_id ,
871882 "metadata" : {"conversation_id" : conversation_id }
872883 }
873884
874- elif isinstance ( event , WorkflowOutputEvent ) :
875- conversation = cast (list [ChatMessage ], event .data )
885+ elif event . type == EVENT_OUTPUT :
886+ conversation = cast (list [Message ], event .data )
876887 if isinstance (conversation , list ) and conversation :
877888 assistant_messages = [
878889 msg for msg in conversation
0 commit comments