Skip to content

Commit bcdb280

Browse files
Merge pull request #866 from microsoft/dev
chore: dev to main merge
2 parents 972ded5 + b0bd21b commit bcdb280

6 files changed

Lines changed: 263 additions & 145 deletions

File tree

infra/vscode_web/requirements.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
1-
azure-ai-projects==2.0.0b3
1+
azure-ai-projects==2.1.0
22
azure-identity==1.20.0
33
ansible-core~=2.17.0

src/backend/orchestrator.py

Lines changed: 79 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -22,14 +22,12 @@
2222
from typing import AsyncIterator, Optional, cast
2323

2424
from 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
3331
from azure.identity import DefaultAzureCredential
3432

3533
# Foundry imports - only used when USE_FOUNDRY=true
@@ -48,6 +46,11 @@
4846
# Token endpoint for Azure Cognitive Services (used for Azure OpenAI)
4947
TOKEN_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

src/backend/requirements.txt

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,9 @@ quart-cors>=0.7.0
66
hypercorn>=0.17.0
77

88
# Microsoft Agent Framework
9-
agent-framework-azure-ai==1.0.0b260114
10-
agent-framework-core==1.0.0b260114
9+
agent-framework-foundry==1.1.1
10+
agent-framework-core==1.1.1
11+
agent-framework-orchestrations==1.0.0b260421
1112

1213
# OpenTelemetry (required by agent-framework)
1314
opentelemetry-semantic-conventions-ai==0.4.13
@@ -18,7 +19,7 @@ azure-cosmos>=4.7.0
1819
azure-storage-blob>=12.22.0
1920
azure-search-documents>=11.4.0
2021
azure-ai-contentsafety>=1.0.0
21-
azure-ai-projects==2.0.0b3 # Azure AI Foundry SDK (optional, for USE_FOUNDRY=true)
22+
azure-ai-projects==2.1.0 # Azure AI Foundry SDK (optional, for USE_FOUNDRY=true)
2223

2324
# OpenAI
2425
openai>=1.45.0

src/backend/services/title_service.py

Lines changed: 8 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -9,16 +9,14 @@
99
import re
1010
from typing import Optional
1111

12-
from agent_framework.azure import AzureOpenAIChatClient
12+
from agent_framework import Agent
13+
from agent_framework.openai import OpenAIChatCompletionClient
1314
from azure.identity import DefaultAzureCredential
1415

1516
from settings import app_settings
1617

1718
logger = logging.getLogger(__name__)
1819

19-
# Token endpoint for Azure OpenAI authentication
20-
TOKEN_ENDPOINT = "https://cognitiveservices.azure.com/.default"
21-
2220
# Title generation instructions (from MS reference accelerator)
2321
TITLE_INSTRUCTIONS = """Summarize the conversation so far into a 4-word or less title.
2422
Do not use any quotation marks or punctuation.
@@ -57,20 +55,15 @@ def initialize(self) -> None:
5755

5856
api_version = app_settings.azure_openai.api_version
5957

60-
# Create token provider function
61-
def get_token() -> str:
62-
"""Token provider callable - invoked for each request to ensure fresh tokens."""
63-
token = self._credential.get_token(TOKEN_ENDPOINT)
64-
return token.token
65-
66-
chat_client = AzureOpenAIChatClient(
67-
endpoint=endpoint,
68-
deployment_name=deployment,
58+
chat_client = OpenAIChatCompletionClient(
59+
azure_endpoint=endpoint,
60+
model=deployment,
6961
api_version=api_version,
70-
ad_token_provider=get_token,
62+
credential=self._credential,
7163
)
7264

73-
self._agent = chat_client.create_agent(
65+
self._agent = Agent(
66+
client=chat_client,
7467
name="title_agent",
7568
instructions=TITLE_INSTRUCTIONS,
7669
)

0 commit comments

Comments
 (0)