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,7 +539,6 @@ 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
@@ -552,11 +554,11 @@ def get_token() -> str:
552554 api_version = app_settings .azure_openai .api_version
553555
554556 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 ,
557+ self ._chat_client = OpenAIChatCompletionClient (
558+ azure_endpoint = azure_endpoint ,
559+ model = model_deployment ,
558560 api_version = api_version ,
559- ad_token_provider = get_token ,
561+ credential = get_token ,
560562 )
561563 else :
562564 # Azure OpenAI Direct mode
@@ -569,12 +571,12 @@ def get_token() -> str:
569571 token = self ._credential .get_token (TOKEN_ENDPOINT )
570572 return token .token
571573
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 ,
574+ logger .info ("Using Azure OpenAI Direct mode with DefaultAzureCredential token provider " )
575+ self ._chat_client = OpenAIChatCompletionClient (
576+ azure_endpoint = endpoint ,
577+ model = app_settings .azure_openai .gpt_model ,
576578 api_version = app_settings .azure_openai .api_version ,
577- ad_token_provider = get_token ,
579+ credential = get_token ,
578580 )
579581 return self ._chat_client
580582
@@ -589,40 +591,60 @@ def initialize(self) -> None:
589591 # Get the chat client
590592 chat_client = self ._get_chat_client ()
591593
592- # Agent names - use underscores (AzureOpenAIChatClient works with both modes now)
594+ # Agent names - always use underscores so that instruction strings
595+ # (TRIAGE_INSTRUCTIONS, *_CONTENT_INSTRUCTIONS, etc.) and the
596+ # SYSTEM_PROMPT_PATTERNS leakage-detection regexes stay in sync.
597+ # Foundry workflows accept underscore names; no hyphen conversion needed.
593598 name_sep = "_"
594599
595600 # Create all agents
596- triage_agent = chat_client .create_agent (
601+ # NOTE: Handoff workflow participants must set
602+ # require_per_service_call_history_persistence=True so local conversation
603+ # history stays consistent with the service across handoff tool-call
604+ # short-circuits (required by agent_framework.orchestrations.HandoffBuilder).
605+ triage_agent = Agent (
606+ client = chat_client ,
597607 name = f"triage{ name_sep } agent" ,
598608 instructions = TRIAGE_INSTRUCTIONS ,
609+ require_per_service_call_history_persistence = True ,
599610 )
600611
601- planning_agent = chat_client .create_agent (
612+ planning_agent = Agent (
613+ client = chat_client ,
602614 name = f"planning{ name_sep } agent" ,
603615 instructions = PLANNING_INSTRUCTIONS ,
616+ require_per_service_call_history_persistence = True ,
604617 )
605618
606- research_agent = chat_client .create_agent (
619+ research_agent = Agent (
620+ client = chat_client ,
607621 name = f"research{ name_sep } agent" ,
608622 instructions = RESEARCH_INSTRUCTIONS ,
623+ require_per_service_call_history_persistence = True ,
609624 )
610625
611- text_content_agent = chat_client .create_agent (
626+ text_content_agent = Agent (
627+ client = chat_client ,
612628 name = f"text{ name_sep } content{ name_sep } agent" ,
613629 instructions = TEXT_CONTENT_INSTRUCTIONS ,
630+ require_per_service_call_history_persistence = True ,
614631 )
615632
616- image_content_agent = chat_client .create_agent (
633+ image_content_agent = Agent (
634+ client = chat_client ,
617635 name = f"image{ name_sep } content{ name_sep } agent" ,
618636 instructions = IMAGE_CONTENT_INSTRUCTIONS ,
637+ require_per_service_call_history_persistence = True ,
619638 )
620639
621- compliance_agent = chat_client .create_agent (
640+ compliance_agent = Agent (
641+ client = chat_client ,
622642 name = f"compliance{ name_sep } agent" ,
623643 instructions = COMPLIANCE_INSTRUCTIONS ,
644+ require_per_service_call_history_persistence = True ,
624645 )
625- self ._rai_agent = chat_client .create_agent (
646+ self ._rai_agent = Agent (
647+ client = chat_client ,
626648 name = f"rai{ name_sep } agent" ,
627649 instructions = RAI_INSTRUCTIONS ,
628650 )
@@ -636,7 +658,7 @@ def initialize(self) -> None:
636658 "compliance" : compliance_agent ,
637659 }
638660
639- # Workflow name - Foundry requires hyphens
661+ # Workflow name
640662 workflow_name = f"content{ name_sep } generation{ name_sep } workflow"
641663
642664 # Build the handoff workflow
@@ -736,35 +758,35 @@ async def process_message(
736758 events .append (event )
737759
738760 # Handle different event types from the workflow
739- if isinstance (event , WorkflowStatusEvent ):
761+ if event .type == EVENT_STATUS :
762+ status_name = event .state .name if event .state else str (event .data )
740763 yield {
741764 "type" : "status" ,
742- "content" : event . state . name ,
765+ "content" : status_name ,
743766 "is_final" : False ,
744767 "metadata" : {"conversation_id" : conversation_id }
745768 }
746769
747- elif isinstance ( event , RequestInfoEvent ) :
770+ elif event . type == EVENT_REQUEST_INFO :
748771 # Workflow is requesting user input
749772 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 []
773+ # Extract conversation history from agent_response.messages
774+ agent_resp = event .data .agent_response
775+ messages = list (agent_resp .messages ) if agent_resp and agent_resp .messages else []
754776
755777 conversation_text = "\n " .join ([
756778 f"{ msg .author_name or msg .role .value } : { msg .text } "
757779 for msg in messages
758780 ])
759781
760782 # 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 "" )
783+ last_msg_content = messages [- 1 ].text if messages else (agent_resp . text if agent_resp else "" )
762784 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"
785+ last_msg_agent = messages [- 1 ].author_name if messages else "unknown"
764786
765787 yield {
766788 "type" : "agent_response" ,
767- "agent" : last_msg_agent ,
789+ "agent" : last_msg_agent or "unknown" ,
768790 "content" : last_msg_content ,
769791 "conversation_history" : conversation_text ,
770792 "is_final" : False ,
@@ -773,9 +795,8 @@ async def process_message(
773795 "metadata" : {"conversation_id" : conversation_id }
774796 }
775797
776- elif isinstance (event , WorkflowOutputEvent ):
777- # Final output from the workflow
778- conversation = cast (list [ChatMessage ], event .data )
798+ elif event .type == EVENT_OUTPUT :
799+ conversation = cast (list [Message ], event .data )
779800 if isinstance (conversation , list ) and conversation :
780801 # Get the last assistant message as the final response
781802 assistant_messages = [
@@ -841,38 +862,38 @@ async def send_user_response(
841862 try :
842863 responses = {request_id : user_response }
843864 async for event in self ._workflow .send_responses_streaming (responses ):
844- if isinstance (event , WorkflowStatusEvent ):
865+ if event .type == EVENT_STATUS :
866+ status_name = event .state .name if event .state else str (event .data )
845867 yield {
846868 "type" : "status" ,
847- "content" : event . state . name ,
869+ "content" : status_name ,
848870 "is_final" : False ,
849871 "metadata" : {"conversation_id" : conversation_id }
850872 }
851873
852- elif isinstance ( event , RequestInfoEvent ) :
874+ elif event . type == EVENT_REQUEST_INFO :
853875 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 []
876+ # Get messages from agent_response
877+ agent_resp = event .data .agent_response
878+ messages = list (agent_resp .messages ) if agent_resp and agent_resp .messages else []
858879
859880 # 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 "" )
881+ last_msg_content = messages [- 1 ].text if messages else (agent_resp . text if agent_resp else "" )
861882 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"
883+ last_msg_agent = messages [- 1 ].author_name if messages else "unknown"
863884
864885 yield {
865886 "type" : "agent_response" ,
866- "agent" : last_msg_agent ,
887+ "agent" : last_msg_agent or "unknown" ,
867888 "content" : last_msg_content ,
868889 "is_final" : False ,
869890 "requires_user_input" : True ,
870891 "request_id" : event .request_id ,
871892 "metadata" : {"conversation_id" : conversation_id }
872893 }
873894
874- elif isinstance ( event , WorkflowOutputEvent ) :
875- conversation = cast (list [ChatMessage ], event .data )
895+ elif event . type == EVENT_OUTPUT :
896+ conversation = cast (list [Message ], event .data )
876897 if isinstance (conversation , list ) and conversation :
877898 assistant_messages = [
878899 msg for msg in conversation
0 commit comments