diff --git a/dotnet/agent-framework/sample-agent/Agent/MyAgent.cs b/dotnet/agent-framework/sample-agent/Agent/MyAgent.cs index e189e7b5..ebf88c3e 100644 --- a/dotnet/agent-framework/sample-agent/Agent/MyAgent.cs +++ b/dotnet/agent-framework/sample-agent/Agent/MyAgent.cs @@ -15,6 +15,7 @@ using Microsoft.Agents.Core.Serialization; using Microsoft.Extensions.AI; using System.Collections.Concurrent; +using System.Linq; using System.Text.Json; namespace Agent365AgentFrameworkSampleAgent.Agent @@ -256,8 +257,12 @@ await A365OtelWrapper.InvokeObservedAgentOperation( var userText = turnContext.Activity.Text?.Trim() ?? string.Empty; var _agent = await GetClientAgent(turnContext, turnState, _toolService, ToolAuthHandlerName); - // Read or Create the conversation thread for this conversation. - AgentThread? thread = GetConversationThread(_agent, turnState); + // Snapshot the persisted thread before the run. If Purview blocks this turn + // we restore this snapshot so the blocked prompt is not baked into history. + string? threadSnapshot = turnState.Conversation.GetValue("conversation.threadInfo", () => null); + + // Read or Create the conversation session for this conversation. + AgentSession? thread = await GetConversationSessionAsync(_agent, turnState, cancellationToken); if (turnContext?.Activity?.Attachments?.Count > 0) { @@ -270,15 +275,43 @@ await A365OtelWrapper.InvokeObservedAgentOperation( } } - // Stream the response back to the user as we receive it from the agent. + // Stream the response back to the user. Purview wraps its block message in a + // PurviewTextContent / PurviewBinaryContent (Microsoft.Agents.AI.Purview namespace); + // detecting that content type tells us the turn was blocked by policy. + const string PurviewContentNamespace = "Microsoft.Agents.AI.Purview"; + var purviewBlocked = false; await foreach (var response in _agent!.RunStreamingAsync(userText, thread, cancellationToken: cancellationToken)) { - if (response.Role == ChatRole.Assistant && !string.IsNullOrEmpty(response.Text)) + if (string.IsNullOrEmpty(response.Text)) + { + continue; + } + + turnContext?.StreamingResponse.QueueTextChunk(response.Text); + if (response.Contents is not null && + response.Contents.Any(c => c.GetType().FullName?.StartsWith(PurviewContentNamespace, StringComparison.Ordinal) == true)) + { + purviewBlocked = true; + } + } + + // On a Purview block, restore the pre-turn snapshot so the blocked exchange is + // not persisted to history (otherwise Purview re-blocks every subsequent turn). + if (purviewBlocked) + { + if (threadSnapshot is null) + { + turnState.Conversation.DeleteValue("conversation.threadInfo"); + } + else { - turnContext?.StreamingResponse.QueueTextChunk(response.Text); + turnState.Conversation.SetValue("conversation.threadInfo", threadSnapshot); } } - turnState.Conversation.SetValue("conversation.threadInfo", ProtocolJsonSerializer.ToJson(thread.Serialize())); + else + { + turnState.Conversation.SetValue("conversation.threadInfo", ProtocolJsonSerializer.ToJson(await _agent!.SerializeSessionAsync(thread, jsonSerializerOptions: null, cancellationToken))); + } } finally { @@ -391,25 +424,20 @@ await A365OtelWrapper.InvokeObservedAgentOperation( } } - // Create Chat Options with tools: + // Create Chat Options with instructions, tools, and temperature. var toolOptions = new ChatOptions { + Instructions = GetAgentInstructions(displayName), Temperature = (float?)0.2, Tools = toolList }; - // Create the chat Client passing in agent instructions and tools: - return new ChatClientAgent(_chatClient!, + // Create the chat agent with the configured options. + return new ChatClientAgent( + _chatClient!, new ChatClientAgentOptions { - Instructions = GetAgentInstructions(displayName), ChatOptions = toolOptions, - ChatMessageStoreFactory = ctx => - { -#pragma warning disable MEAI001 // MessageCountingChatReducer is for evaluation purposes only and is subject to change or removal in future updates - return new InMemoryChatMessageStore(new MessageCountingChatReducer(10), ctx.SerializedState, ctx.JsonSerializerOptions); -#pragma warning restore MEAI001 // MessageCountingChatReducer is for evaluation purposes only and is subject to change or removal in future updates - } }) .AsBuilder() .UseOpenTelemetry(sourceName: AgentMetrics.SourceName, (cfg) => cfg.EnableSensitiveData = true) @@ -417,26 +445,27 @@ await A365OtelWrapper.InvokeObservedAgentOperation( } /// - /// Manage Agent threads against the conversation state. + /// Manage Agent sessions against the conversation state. /// /// ChatAgent /// State Manager for the Agent. + /// Cancellation token. /// - private static AgentThread GetConversationThread(AIAgent? agent, ITurnState turnState) + private static async Task GetConversationSessionAsync(AIAgent? agent, ITurnState turnState, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(agent); - AgentThread thread; - string? agentThreadInfo = turnState.Conversation.GetValue("conversation.threadInfo", () => null); - if (string.IsNullOrEmpty(agentThreadInfo)) + AgentSession session; + string? agentSessionInfo = turnState.Conversation.GetValue("conversation.threadInfo", () => null); + if (string.IsNullOrEmpty(agentSessionInfo)) { - thread = agent.GetNewThread(); + session = await agent.CreateSessionAsync(cancellationToken); } else { - JsonElement ele = ProtocolJsonSerializer.ToObject(agentThreadInfo); - thread = agent.DeserializeThread(ele); + JsonElement ele = ProtocolJsonSerializer.ToObject(agentSessionInfo); + session = await agent.DeserializeSessionAsync(ele, jsonSerializerOptions: null, cancellationToken); } - return thread; + return session; } private string GetToolCacheKey(ITurnState turnState) diff --git a/dotnet/agent-framework/sample-agent/AgentFrameworkSampleAgent.csproj b/dotnet/agent-framework/sample-agent/AgentFrameworkSampleAgent.csproj index 8b72a305..bcdd4472 100644 --- a/dotnet/agent-framework/sample-agent/AgentFrameworkSampleAgent.csproj +++ b/dotnet/agent-framework/sample-agent/AgentFrameworkSampleAgent.csproj @@ -1,7 +1,7 @@  - net8.0 + net10.0 enable 7a8f9d79-5c4c-495f-8d56-1db8168ef8bd enable @@ -19,18 +19,21 @@ + + + - - + + - + diff --git a/dotnet/agent-framework/sample-agent/Program.cs b/dotnet/agent-framework/sample-agent/Program.cs index ad55e9ce..0114dff8 100644 --- a/dotnet/agent-framework/sample-agent/Program.cs +++ b/dotnet/agent-framework/sample-agent/Program.cs @@ -6,8 +6,10 @@ using Agent365AgentFrameworkSampleAgent.telemetry; using Azure; using Azure.AI.OpenAI; +using Azure.Identity; using Microsoft.Agents.A365.Tooling.Extensions.AgentFramework.Services; using Microsoft.Agents.A365.Tooling.Services; +using Microsoft.Agents.AI.Purview; using Microsoft.Agents.Builder; using Microsoft.Agents.Core; using Microsoft.Agents.Hosting.AspNetCore; @@ -79,13 +81,40 @@ var apiKeyCredential = new AzureKeyCredential(apiKey); // Create and return the AzureOpenAIClient's ChatClient - return new AzureOpenAIClient(endpointUri, apiKeyCredential) + var chatClientBuilder = new AzureOpenAIClient(endpointUri, apiKeyCredential) .GetChatClient(deployment) .AsIChatClient() - .AsBuilder() + .AsBuilder(); + + // Add Purview middleware if configured + var purviewClientAppId = confSvc["Purview:ClientAppId"]; + var purviewAppName = confSvc["Purview:AppName"]; + var purviewTenantId = confSvc["Purview:TenantId"]; + var purviewClientSecret = confSvc["Purview:ClientSecret"]; + var purviewUserId = confSvc["Purview:UserId"]; + + if (!string.IsNullOrEmpty(purviewClientAppId) && + !string.IsNullOrEmpty(purviewAppName) && + !string.IsNullOrEmpty(purviewTenantId) && + !string.IsNullOrEmpty(purviewClientSecret)) + { + // Stamp each user message with the Purview userId so the Purview middleware + // can identify the user when authenticating with app-level credentials. + if (!string.IsNullOrEmpty(purviewUserId)) + { + chatClientBuilder = chatClientBuilder.Use((innerClient) => + new PurviewUserIdStampingClient(innerClient, purviewUserId)); + } + + var purviewCredential = new ClientSecretCredential(purviewTenantId, purviewClientAppId, purviewClientSecret); + var purviewSettings = new PurviewSettings(purviewAppName); + chatClientBuilder = chatClientBuilder.WithPurview(purviewCredential, purviewSettings); + } + + return chatClientBuilder .UseFunctionInvocation() .UseOpenTelemetry(sourceName: AgentMetrics.SourceName, configure: (cfg) => cfg.EnableSensitiveData = true) - .Build(); + .Build(); }); // Uncomment to add transcript logging middleware to log all conversations to files diff --git a/dotnet/agent-framework/sample-agent/PurviewUserIdStampingClient.cs b/dotnet/agent-framework/sample-agent/PurviewUserIdStampingClient.cs new file mode 100644 index 00000000..6ac4c2f7 --- /dev/null +++ b/dotnet/agent-framework/sample-agent/PurviewUserIdStampingClient.cs @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Extensions.AI; + +namespace Agent365AgentFrameworkSampleAgent; + +/// +/// A delegating that stamps each user message with a +/// Purview userId before forwarding the call to the inner client. +/// This is required when authenticating to Purview with app-level credentials +/// (e.g. ClientSecretCredential) since the userId cannot be inferred from the token. +/// +internal sealed class PurviewUserIdStampingClient(IChatClient innerClient, string userId) + : DelegatingChatClient(innerClient) +{ + private const string UserIdKey = "userId"; + + public override Task GetResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + CancellationToken cancellationToken = default) + { + StampMessages(messages); + return base.GetResponseAsync(messages, options, cancellationToken); + } + + public override IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + CancellationToken cancellationToken = default) + { + StampMessages(messages); + return base.GetStreamingResponseAsync(messages, options, cancellationToken); + } + + private void StampMessages(IEnumerable messages) + { + foreach (var message in messages) + { + if (message.Role == ChatRole.User) + { + message.AdditionalProperties ??= new AdditionalPropertiesDictionary(); + if (!message.AdditionalProperties.ContainsKey(UserIdKey)) + { + message.AdditionalProperties[UserIdKey] = userId; + } + } + } + } +} diff --git a/dotnet/agent-framework/sample-agent/appsettings.Playground.json b/dotnet/agent-framework/sample-agent/appsettings.Playground.json index 3097e295..6c2ccf5a 100644 --- a/dotnet/agent-framework/sample-agent/appsettings.Playground.json +++ b/dotnet/agent-framework/sample-agent/appsettings.Playground.json @@ -28,5 +28,12 @@ "ApiKey": "---" // This is the API Key of the Azure OpenAI model deployment } }, - "OpenWeatherApiKey": "---" //https://openweathermap.org/api - You will need to create a free account to get an API key. + "OpenWeatherApiKey": "---", //https://openweathermap.org/api - You will need to create a free account to get an API key. + "Purview": { + "ClientAppId": "---", + "AppName": "---", + "TenantId": "---", + "ClientSecret": "---", + "UserId": "---" + } } \ No newline at end of file diff --git a/dotnet/agent-framework/sample-agent/appsettings.json b/dotnet/agent-framework/sample-agent/appsettings.json index ea668f2d..4e75f484 100644 --- a/dotnet/agent-framework/sample-agent/appsettings.json +++ b/dotnet/agent-framework/sample-agent/appsettings.json @@ -74,5 +74,12 @@ "ApiKey": "----" // This is the API Key of the Azure OpenAI model deployment } }, - "OpenWeatherApiKey": "----" //https://openweathermap.org/price - You will need to create a free account to get an API key (its at the bottom of the page). + "OpenWeatherApiKey": "----", //https://openweathermap.org/price - You will need to create a free account to get an API key (its at the bottom of the page). + "Purview": { + "ClientAppId": "<>", + "AppName": "<>", + "TenantId": "<>", + "ClientSecret": "<>", + "UserId": "<>" + } } diff --git a/python/agent-framework/sample-agent/.env.template b/python/agent-framework/sample-agent/.env.template index b7cd2aaf..f3aa149e 100644 --- a/python/agent-framework/sample-agent/.env.template +++ b/python/agent-framework/sample-agent/.env.template @@ -1,5 +1,5 @@ # This is a demo .env file -# Replace with your actual OpenAI API key +# Replace placeholders with your actual values OPENAI_API_KEY= MCP_SERVER_HOST= MCP_PLATFORM_ENDPOINT= @@ -20,6 +20,11 @@ OPENAI_MODEL= USE_AGENTIC_AUTH=true +# Set DISABLE_MCP=true to skip MCP tool registration. Useful for local +# Microsoft 365 Agents Playground runs where the cloud MCP servers reject +# requests because the playground has no real M365 tenant context. +DISABLE_MCP=false + # Agent 365 Agentic Authentication Configuration CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTID= CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTSECRET= @@ -50,3 +55,22 @@ PYTHON_ENVIRONMENT=development # Enable otel logs on AgentFramework SDK. Required for auto instrumentation ENABLE_OTEL=true ENABLE_SENSITIVE_DATA=true + +# Purview Policy Enforcement (Optional) +# Enables Microsoft Purview policy evaluation on agent prompts and responses. +# Set PURVIEW_CLIENT_APP_ID to enable Purview; leave empty to skip. +# +# Auth priority (first match wins): +# 1. Client Secret — set PURVIEW_CLIENT_SECRET + PURVIEW_TENANT_ID +# 2. Certificate — set PURVIEW_USE_CERT_AUTH=true + PURVIEW_CERT_PATH +# 3. Interactive Browser — fallback (opens browser for sign-in) +PURVIEW_CLIENT_APP_ID= +PURVIEW_CLIENT_SECRET= +PURVIEW_TENANT_ID= +PURVIEW_APP_NAME=A365 Agent Framework Sample App +PURVIEW_DEFAULT_USER_ID= + +# Certificate auth only (set PURVIEW_USE_CERT_AUTH=true to use) +PURVIEW_USE_CERT_AUTH=false +PURVIEW_CERT_PATH= +PURVIEW_CERT_PASSWORD= diff --git a/python/agent-framework/sample-agent/AGENT-CODE-WALKTHROUGH.md b/python/agent-framework/sample-agent/AGENT-CODE-WALKTHROUGH.md index 8f1997b3..6c6cfd0f 100644 --- a/python/agent-framework/sample-agent/AGENT-CODE-WALKTHROUGH.md +++ b/python/agent-framework/sample-agent/AGENT-CODE-WALKTHROUGH.md @@ -33,8 +33,8 @@ Each section follows this pattern: ```python # AgentFramework SDK -from agent_framework.azure import AzureOpenAIChatClient -from agent_framework import ChatAgent +from agent_framework.openai import OpenAIChatClient +from agent_framework import Agent from azure.identity import AzureCliCredential # Agent Interface @@ -119,11 +119,11 @@ def _create_chat_client(self): if not api_version: raise ValueError("AZURE_OPENAI_API_VERSION environment variable is required") - self.chat_client = AzureOpenAIChatClient( - endpoint=endpoint, + self.chat_client = OpenAIChatClient( + model=deployment, + azure_endpoint=endpoint, + api_version=api_version, credential=AzureCliCredential(), - deployment_name=deployment, - api_version=api_version ) ``` @@ -149,8 +149,8 @@ def _create_agent(self): try: logger.info("Creating AgentFramework agent...") - self.agent = ChatAgent( - chat_client=self.chat_client, + self.agent = Agent( + client=self.chat_client, instructions="You are a helpful assistant with access to tools.", tools=[] # Tools will be added dynamically by MCP setup ) diff --git a/python/agent-framework/sample-agent/agent.py b/python/agent-framework/sample-agent/agent.py index 04974e8b..cdf40b81 100644 --- a/python/agent-framework/sample-agent/agent.py +++ b/python/agent-framework/sample-agent/agent.py @@ -20,6 +20,7 @@ import asyncio import logging import os +import uuid from typing import Optional from dotenv import load_dotenv @@ -37,8 +38,8 @@ # # AgentFramework SDK -from agent_framework import ChatAgent -from agent_framework.azure import AzureOpenAIChatClient +from agent_framework import Agent, Message, Role +from agent_framework.openai import OpenAIChatClient # Agent Interface from agent_interface import AgentInterface @@ -64,6 +65,18 @@ # +def _valid_guid(val) -> str | None: + """Return a normalized GUID string if val is a valid UUID, else None.""" + if not val: + return None + if isinstance(val, uuid.UUID): + return str(val) + try: + return str(uuid.UUID(val)) + except (ValueError, AttributeError, TypeError): + return None + + class AgentFrameworkAgent(AgentInterface): """AgentFramework Agent integrated with MCP servers and Observability""" @@ -131,29 +144,30 @@ def _create_chat_client(self): ) # Use API key if provided, otherwise fall back to Azure CLI credential + client_kwargs: dict = { + "model": deployment, + "azure_endpoint": endpoint, + "api_version": api_version, + } if api_key: - from azure.core.credentials import AzureKeyCredential - credential = AzureKeyCredential(api_key) + client_kwargs["api_key"] = api_key logger.info("Using API key authentication for Azure OpenAI") else: - credential = AzureCliCredential() + client_kwargs["credential"] = AzureCliCredential() logger.info("Using Azure CLI authentication for Azure OpenAI") - self.chat_client = AzureOpenAIChatClient( - endpoint=endpoint, - credential=credential, - deployment_name=deployment, - api_version=api_version, - ) - logger.info("✅ AzureOpenAIChatClient created") + self.chat_client = OpenAIChatClient(**client_kwargs) + logger.info("✅ OpenAIChatClient (Azure) created") def _create_agent(self): """Create the AgentFramework agent with initial configuration""" try: - self.agent = ChatAgent( - chat_client=self.chat_client, + middleware = self._build_purview_middleware() + self.agent = Agent( + client=self.chat_client, instructions=self.AGENT_PROMPT, tools=[], + middleware=middleware if middleware else None, ) logger.info("✅ AgentFramework agent created") except Exception as e: @@ -162,6 +176,68 @@ def _create_agent(self): # + # ========================================================================= + # PURVIEW POLICY INTEGRATION + # ========================================================================= + # + + def _build_purview_middleware(self) -> list: + """Build Purview policy middleware if PURVIEW_CLIENT_APP_ID is configured.""" + client_app_id = os.getenv("PURVIEW_CLIENT_APP_ID") + if not client_app_id: + logger.info("ℹ️ Purview not configured (PURVIEW_CLIENT_APP_ID not set)") + return [] + + try: + from agent_framework.microsoft import PurviewPolicyMiddleware, PurviewSettings + from azure.identity import ( + CertificateCredential, + ClientSecretCredential, + InteractiveBrowserCredential, + ) + + tenant_id = os.getenv("PURVIEW_TENANT_ID") + client_secret = os.getenv("PURVIEW_CLIENT_SECRET") + use_cert = os.getenv("PURVIEW_USE_CERT_AUTH", "false").lower() == "true" + + if client_secret and tenant_id: + credential = ClientSecretCredential( + tenant_id=tenant_id, + client_id=client_app_id, + client_secret=client_secret, + ) + logger.info("🔐 Purview: using client secret authentication") + elif use_cert: + cert_path = os.getenv("PURVIEW_CERT_PATH") + cert_password = os.getenv("PURVIEW_CERT_PASSWORD") + if not tenant_id or not cert_path: + logger.warning("⚠️ Purview cert auth requires PURVIEW_TENANT_ID and PURVIEW_CERT_PATH") + return [] + credential = CertificateCredential( + tenant_id=tenant_id, + client_id=client_app_id, + certificate_path=cert_path, + password=cert_password, + ) + logger.info("🔐 Purview: using certificate authentication") + else: + credential = InteractiveBrowserCredential(client_id=client_app_id) + logger.info("🔐 Purview: using interactive browser authentication") + + app_name = os.getenv("PURVIEW_APP_NAME", "A365 Agent Framework Sample App") + middleware = PurviewPolicyMiddleware( + credential, + PurviewSettings(app_name=app_name), + ) + logger.info("✅ Purview policy middleware enabled") + return [middleware] + + except Exception as e: + logger.warning(f"⚠️ Failed to initialize Purview middleware: {e}") + return [] + + # + # ========================================================================= # OBSERVABILITY CONFIGURATION # ========================================================================= @@ -199,6 +275,11 @@ async def setup_mcp_servers(self, auth: Authorization, auth_handler_name: Option if self.mcp_servers_initialized: return + if os.getenv("DISABLE_MCP", "false").lower() == "true": + logger.info("⚠️ MCP disabled via DISABLE_MCP=true; using base agent without MCP tools") + self.mcp_servers_initialized = True + return + try: if not self.tool_service: logger.warning("⚠️ MCP tool service unavailable") @@ -265,7 +346,19 @@ async def process_user_message( try: await self.setup_mcp_servers(auth, auth_handler_name, context, instructions=personalized_prompt) - result = await self.agent.run(message) + + # Build a Message with user_id for Purview policy evaluation. + # Purview middleware requires a valid AAD user GUID to make API calls. + aad_id = getattr(from_prop, "aad_object_id", None) + user_id = _valid_guid(aad_id) or _valid_guid(os.getenv("PURVIEW_DEFAULT_USER_ID")) + logger.debug(f"Purview user_id resolved: valid={user_id is not None}") + chat_message = Message( + role=Role("user"), + contents=[message], + additional_properties={"user_id": user_id} if user_id else None, + ) + + result = await self.agent.run(chat_message) return self._extract_result(result) or "I couldn't process your request at this time." except Exception as e: logger.error(f"Error processing message: {e}") diff --git a/python/agent-framework/sample-agent/pyproject.toml b/python/agent-framework/sample-agent/pyproject.toml index 8cd7ab32..57d73e00 100644 --- a/python/agent-framework/sample-agent/pyproject.toml +++ b/python/agent-framework/sample-agent/pyproject.toml @@ -6,8 +6,10 @@ authors = [ { name = "Microsoft", email = "support@microsoft.com" } ] dependencies = [ - # AgentFramework SDK - The official package + # AgentFramework SDK + "agent-framework", "agent-framework-azure-ai", + "agent-framework-purview", # Azure AI Projects - explicitly require pre-release version "azure-ai-agents>=1.2.0b5",